Skip to content

Commit 5b1bb79

Browse files
authored
Migrate to typed-firestore and make common a build package (#36)
* Migrate to typed-firestore and make common built * Simplify readme
1 parent f9b040f commit 5b1bb79

File tree

18 files changed

+210
-196
lines changed

18 files changed

+210
-196
lines changed

.github/checks.yml

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
name: Checks
2+
3+
on:
4+
pull_request:
5+
branches:
6+
- "**"
7+
push:
8+
branches:
9+
- main
10+
11+
concurrency:
12+
group: ${{ github.workflow }}-${{ github.ref }}
13+
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
14+
15+
jobs:
16+
init:
17+
runs-on: ubuntu-latest
18+
outputs:
19+
gcloud_project: ${{ steps.set-project-value.outputs.value }}
20+
phase: ${{ steps.set-phase-value.outputs.value }}
21+
strategy:
22+
matrix:
23+
node-version: [22]
24+
steps:
25+
- uses: actions/checkout@v4
26+
- uses: pnpm/action-setup@v3
27+
- uses: actions/setup-node@v4
28+
with:
29+
node-version: 22
30+
cache: "pnpm"
31+
- run: pnpm install --frozen-lockfile
32+
33+
lint:
34+
runs-on: ubuntu-latest
35+
needs: init
36+
steps:
37+
- uses: actions/checkout@v4
38+
- uses: pnpm/action-setup@v3
39+
- uses: actions/setup-node@v4
40+
with:
41+
node-version: 22
42+
cache: "pnpm"
43+
- run: pnpm install --frozen-lockfile
44+
- run: pnpm run lint
45+
46+
prettier:
47+
runs-on: ubuntu-latest
48+
needs: init
49+
steps:
50+
- uses: actions/checkout@v4
51+
- uses: pnpm/action-setup@v3
52+
- uses: actions/setup-node@v4
53+
with:
54+
node-version: 22
55+
cache: "pnpm"
56+
- run: pnpm install --frozen-lockfile
57+
- run: pnpm run prettier:check
58+
59+
test:
60+
runs-on: ubuntu-latest
61+
needs: init
62+
steps:
63+
- uses: actions/checkout@v4
64+
- uses: pnpm/action-setup@v3
65+
- uses: actions/setup-node@v4
66+
with:
67+
node-version: 22
68+
cache: "pnpm"
69+
- run: pnpm install --frozen-lockfile
70+
- run: pnpm run test
71+
72+
compile:
73+
runs-on: ubuntu-latest
74+
needs: init
75+
steps:
76+
- uses: actions/checkout@v4
77+
- uses: pnpm/action-setup@v3
78+
- uses: actions/setup-node@v4
79+
with:
80+
node-version: 22
81+
cache: "pnpm"
82+
- run: pnpm install --frozen-lockfile
83+
- run: pnpm run compile

README.md

Lines changed: 14 additions & 131 deletions
Original file line numberDiff line numberDiff line change
@@ -6,17 +6,11 @@
66
- [Features](#features)
77
- [Install](#install)
88
- [Usage](#usage)
9-
- [Workspace](#workspace)
9+
- [Monorepo Setup](#monorepo-setup)
1010
- [Namespace](#namespace)
1111
- [Packages](#packages)
1212
- [Apps](#apps)
1313
- [Services](#services)
14-
- [The "built packages" strategy](#the-built-packages-strategy)
15-
- [Convert path aliases](#convert-path-aliases)
16-
- [Write ESM without import file extensions](#write-esm-without-import-file-extensions)
17-
- [Tree shaking](#tree-shaking)
18-
- [The "internal packages" strategy](#the-internal-packages-strategy)
19-
- [Live code changes from internal packages](#live-code-changes-from-internal-packages)
2014
- [Firebase](#firebase)
2115
- [Demo Project](#demo-project)
2216
- [Deploying](#deploying)
@@ -27,12 +21,12 @@
2721

2822
## Introduction
2923

24+
This is a personal quest for the perfect Typescript monorepo setup.
25+
3026
> There is an accompanying article
3127
> ["My quest for the perfect TS monorepo"](https://thijs-koerselman.medium.com/my-quest-for-the-perfect-ts-monorepo-62653d3047eb)
3228
> that you might want to read for context.
3329
34-
This is a personal quest for the perfect Typescript monorepo setup.
35-
3630
It is the best I could come up with given the tooling that is available, so
3731
expect this repository to change over time as the ecosystem around Typescript
3832
evolves.
@@ -59,20 +53,19 @@ and I recommend reading
5953

6054
- [Turborepo](https://turbo.build/) to orchestrate the build process and
6155
dependencies, including the v2 watch task.
62-
- Showcasing a traditional "built package" with multiple entry points, as well
63-
as the ["internal package"](#the-internal-packages-strategy) strategy
64-
referencing Typescript code directly.
6556
- Multiple isolated Firebase deployments, using
6657
[firebase-tools-with-isolate](https://github.com/0x80/firebase-tools-with-isolate)
6758
- Firebase emulators with hot reloading
6859
- A web app based on Next.js with [ShadCN](https://ui.shadcn.com/) and
6960
[Tailwind CSS](https://tailwindcss.com/)
7061
- Working IDE go-to-definition and go-to-type-definition using `.d.ts.map` files
71-
- ES modules for everything
62+
- ESM everything
7263
- Path aliases
7364
- Shared configurations for ESLint
74-
- Simple standardized configuration for TypeScript
65+
- Simple standard configuration for TypeScript
7566
- Vitest
67+
- Clean, strongly-typed Firestore code for both React (using
68+
`@typed-firestore/react`) and Node.js (using `@typed-firestore/server`)
7669

7770
## Install
7871

@@ -112,7 +105,11 @@ UI on http://localhost:4000.
112105
You should now have a working local setup, in which code changes to any package
113106
are picked up.
114107

115-
## Workspace
108+
## Monorepo Setup
109+
110+
> There is an accompanying article
111+
> ["My quest for the perfect TS monorepo"](https://thijs-koerselman.medium.com/my-quest-for-the-perfect-ts-monorepo-62653d3047eb)
112+
> that you might want to read for context.
116113
117114
### Namespace
118115

@@ -143,127 +140,13 @@ clear, but I went with `@repo` because I expect it will become the standard.
143140
- [api](./services/api) A 2nd gen Firebase function (based on Cloud Run) serving
144141
as an API endpoint. This package also illustrates how to use secrets.
145142

146-
## The "built packages" strategy
147-
148-
In a traditional monorepo setup, each package exposes its code as if it was a
149-
published NPM package. For Typescript this means the code has to be transpiled
150-
and the manifest entry points reference to the build output files. You can use
151-
Typescript `tsc` compiler for this, but it is likely you will want to use a
152-
bundler for the reasons explained below.
153-
154-
The `services` in this codebase use TSUP as a bundler. It is a modern, simple to
155-
use Rollup-inspired bundler for Typescript.
156-
157-
The advantages of using a bundler are discussed below.
158-
159-
### Convert path aliases
160-
161-
If you use path aliases like `~/*` or `@/*` to conveniently reference top-level
162-
folders from deeply nested import statements, these paths are not converted by
163-
the standard Typescript `tsc` compiler.
164-
165-
A bundler will typically remove path aliases, because it combines your code into
166-
self-contained files that do not import other local files themselves.
167-
168-
If you target platforms without using bundler, you will have to convert them.
169-
You can run something like `tsc-alias` after your build step. Note that path
170-
aliases can also end up in `d.ts` files.
171-
172-
### Write ESM without import file extensions
173-
174-
A bundler will allow you to output ESM-compatible code without having to adhere
175-
to the ESM import rules. ESM requires all relative imports to be explicit,
176-
appending a `.js` file extension for importing TS files and appending
177-
`/index.js` when importing from the root of a directory.
178-
179-
The reason you need to use `.js` and not `.ts` is because these imports, like
180-
path aliases are not converted by the Typescript compiler, and so at runtime the
181-
transpiled JS file is what is getting imported.
182-
183-
Because a bundler, by nature, will bundle code into one or more isolated files,
184-
those files do not use relative imports and only contain imports from
185-
`node_modules`, which do not require a file extension. For this reason, a
186-
bundled js file that uses import and export keywords is an ES module.
187-
188-
An advantage of writing your code as ESM is that you can import both ES modules
189-
and CommonJS without conversion. An application that uses CJS can not import ESM
190-
directly, because CJS imports are synchronous and ESM imports are asynchronous.
191-
192-
Not having to use ESM import extensions can be especially valuable if you are
193-
trying to convert a large codebase to ESM, because I have yet to find a solution
194-
that can convert existing imports. There is
195-
[this ESLint plugin](https://github.com/solana-labs/eslint-plugin-require-extensions)
196-
that you could use it in combination with the --fix flag to inject the
197-
extensions, but at the time of writing it does not understand path aliases.
198-
199-
### Tree shaking
200-
201-
Some bundlers like TSUP are capable of eliminating dead code by tree-shaking the
202-
build output, so that less code remains to be deployed. Eliminating dead code is
203-
most important for client-side code that is shipped to the user, but for the
204-
core it can also reduce cold-start times for serverless functions, although in
205-
most situations, it is probably not going to be noticeable.
206-
207-
## The "internal packages" strategy
208-
209-
The
210-
[internal packages](https://turbo.build/blog/you-might-not-need-typescript-project-references)
211-
strategy, as it was coined by Jared Palmer of Turborepo, removes the build step
212-
from the internal packages by linking directly to the Typescript source files in
213-
the package manifest.
214-
215-
There are some advantages to this approach:
216-
217-
- Code and type changes can be picked up directly, removing the need for a watch
218-
task in development mode.
219-
- Removing the build step reduces overall complexity where you might otherwise
220-
use a bundler with configuration.
221-
- IDE go-to-definition, in which cmd-clicking on a reference takes you to the
222-
source location instead of the typed exports, works without the need for
223-
Typescript project references or generating `d.ts.map` files.
224-
225-
But, as always, there are also some disadvantages you should be aware of:
226-
227-
- You can not publish the shared packages to NPM, as you do not expose them as
228-
Javascript.
229-
- If you use path aliases like `~/`, you will need to make sure every package
230-
has its own unique aliases. You might not need aliases, because shared
231-
packages typically do not have a deeply nested folder structure anyway.
232-
- Since all source code gets compiled by the consuming application, build times
233-
can start to suffer when the codebase grows. See
234-
[caveats](https://turbo.build/blog/you-might-not-need-typescript-project-references#caveats)
235-
for more info.
236-
- The shared package is effectively just a source folder, and as a whole it
237-
needs to be transpiled and bundled into the consuming package. This means that
238-
its dependencies must also be available in the consuming package. Next.js can
239-
do this for you with the `transpilePackage` setting, but this is the reason
240-
`services/api` includes `remeda`, as it is used by `packages/common`.
241-
242-
For testing and comparison, mono-ts uses the internal packages approach for
243-
`@repo/common` and a traditional built approach for `@repo/core`. Both are
244-
compatible with `isolate-package` for deploying to Firebase.
245-
246-
## Live code changes from internal packages
247-
248-
Traditionally in a monorepo, each package is treated similarly to a released NPM
249-
package, meaning that the code and types are resolved from the built "dist"
250-
output for each module. Adding new types and changing code in shared packages
251-
therefore requires you to rebuild these, which can be cumbersome during
252-
development.
253-
254-
Turborepo does not (yet) include a watch task, so
255-
[Turbowatch](https://github.com/gajus/turbowatch) was created to solve this
256-
issue. I haven't tried it but it looks like a neat solution. However, you might
257-
want to use the
258-
[internal packages strategy instead](#the-internal-packages-strategy).
259-
260143
## Firebase
261144

262145
In their
263146
[documentation for monorepos](https://firebase.google.com/docs/functions/organize-functions?gen=2nd#managing_multiple_source_packages_monorepo),
264147
Firebase recommends putting all configurations in the root of the monorepo. This
265-
makes it possible to deploy all packages at once, and most importantly, run the
266-
emulators shared on common ports.
148+
makes it possible to deploy all packages at once, and easily start the emulators
149+
shared between all packages.
267150

268151
### Demo Project
269152

apps/web/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,14 @@
1515
"@radix-ui/react-icons": "^1.3.2",
1616
"@radix-ui/react-slot": "^1.1.1",
1717
"@repo/common": "workspace:*",
18+
"@typed-firestore/react": "1.0.0-0",
1819
"class-variance-authority": "^0.7.1",
1920
"clsx": "^2.1.1",
2021
"firebase": "^11.1.0",
2122
"lucide-react": "^0.471.0",
2223
"next": "15.1.4",
2324
"react": "19.0.0",
2425
"react-dom": "19.0.0",
25-
"react-firebase-hooks": "^5.1.1",
2626
"tailwind-merge": "^2.6.0",
2727
"tailwindcss-animate": "^1.0.7",
2828
"typescript": "^5.7.3"

apps/web/src/app/components/counter-view.tsx

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,10 @@
1-
import type { Counter } from "@repo/common";
2-
import { doc } from "firebase/firestore";
3-
import { useTypedDocument } from "~/lib/firestore";
4-
import { refs } from "~/refs";
1+
import { useDocument } from "@typed-firestore/react";
2+
import { refs } from "~/db-refs";
53
import KeyValueList from "./key-value-list";
64

75
export function CounterView(props: { counterId: string }) {
8-
const [counter, isLoading] = useTypedDocument<Counter>(
9-
doc(refs.counters, props.counterId)
10-
);
6+
/** Note that counter is typed correctly here by `@typed-firestore/react` ✨ */
7+
const [counter, isLoading] = useDocument(refs.counters, props.counterId);
118

129
if (isLoading) {
1310
return <div>Loading...</div>;

apps/web/src/db-refs.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import type { Counter } from "@repo/common";
2+
import { collection, type CollectionReference } from "firebase/firestore";
3+
import { db } from "~/lib/firebase";
4+
5+
/**
6+
* Here we define reusable references to collections and type each of them so
7+
* that functions from `@typed-firestore/react` can infer the types
8+
* automatically for us.
9+
*
10+
* Note that this file is slightly different from the one in `services/api`
11+
* because the backend types from firebase-admin are possibly not compatible.
12+
*/
13+
export const refs = {
14+
counters: collection(db, "counters") as CollectionReference<Counter>,
15+
};

apps/web/src/refs.ts

Lines changed: 0 additions & 6 deletions
This file was deleted.

packages/common/package.json

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,19 @@
33
"description": "Common code for both backend and frontend",
44
"version": "0.0.0",
55
"type": "module",
6-
"types": "./src/index.ts",
6+
"types": "./dist/index.d.ts",
77
"exports": {
8-
".": "./src/index.ts"
8+
".": "./dist/index.js"
99
},
1010
"files": [
11-
"src"
11+
"dist"
1212
],
1313
"scripts": {
1414
"compile": "tsc --noEmit",
1515
"test": "vitest",
1616
"lint": "eslint . --max-warnings 0",
17-
"build?": "No build step required. This package links directly to its source files",
17+
"build": "tsup && tsc --emitDeclarationOnly",
18+
"clean": "del-cli dist tsconfig.tsbuildinfo",
1819
"coverage": "vitest run --coverage "
1920
},
2021
"license": "MIT",
@@ -28,6 +29,7 @@
2829
"eslint": "^8.57.1",
2930
"eslint-plugin-require-extensions": "^0.1.3",
3031
"prettier": "^3.4.2",
32+
"tsup": "^8.3.5",
3133
"typescript": "^5.7.3",
3234
"vitest": "^2.1.8"
3335
}
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import * as R from "remeda";
22

3-
export function unique<T>(array: T[]) {
4-
return R.unique<T>(array);
3+
export function unique<T extends readonly unknown[]>(array: T) {
4+
return R.unique(array);
55
}

packages/common/tsup.config.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { defineConfig } from "tsup";
2+
3+
export default defineConfig({
4+
entry: {
5+
index: "src/index.ts",
6+
},
7+
format: ["esm"],
8+
target: "es2022",
9+
sourcemap: true,
10+
11+
/**
12+
* Do not use tsup for generating d.ts files because it can not generate type
13+
* the definition maps required for go-to-definition to work in our IDE. We
14+
* use tsc for that.
15+
*/
16+
});

0 commit comments

Comments
 (0)