Skip to content

Commit

Permalink
build: use esbuild to bundle server code
Browse files Browse the repository at this point in the history
A problem was that in production mode (compiled to ESM JS and running
with `node`), importing internal packages (`@serieslist/*`) pointed to
the TypeScript files. This meant that importing those packages DID NOT
WORK (e.g., `@serieslist/logger`). `tsc` does not bundle any
dependencies.

To fix this, we can bundle the code while building, bundling the
`@serieslist/*` packages INTO the `dist/` folder and marking all other
direct dependencies as external. This means that we can keep
`package.json` `main` and `exports` fields referencing the TypeScript
files.

I'm not super happy with the bundling, though, as there are some weird
things going on with ESM. I considered these options for the bundler:
- `esbuild`
- Vite with `vite-node` might be nice as we're already using Vitest
- `swc`
- `webpack`

The packages themselves are never built on their own. In order to build
a package, one of the consuming apps needs to be built and pull the
package code into the apps dist folder.

This seems to be the approach that's mostly recommended for smaller and
medium sized projects.

Some more resources:
- https://turbo.build/repo/docs/handbook/sharing-code/internal-packages
- https://turbo.build/blog/you-might-not-need-typescript-project-references
- https://www.reddit.com/r/typescript/comments/10ebgbs/handling_typescript_in_a_monorepo/
- https://nx.dev/concepts/more-concepts/incremental-builds

I also tried to build the packages themselves and point `main` and
`exports` to the compiled JS files in `dist/`, but that caused a couple
issues like Go To Definition going to the type definition in `dist/`,
not to the source code (which is super inconvenient). It would also mean
that each package needs its own build step with configuration, which
would be a bit more painful to set up.

Related to #83
  • Loading branch information
JoosepAlviste committed Dec 31, 2023
1 parent d6f42b9 commit 6f25b13
Show file tree
Hide file tree
Showing 13 changed files with 258 additions and 128 deletions.
10 changes: 10 additions & 0 deletions apps/api/bin/build.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { buildEsbuild } from '@serieslist/esbuild'

import pkg from '../package.json'

await buildEsbuild({
packageJson: pkg,
entryPoints: ['src/main.ts'],
tsconfig: 'tsconfig.build.json',
external: ['pg-native'],
})
3 changes: 2 additions & 1 deletion apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
"type": "module",
"module": "src/main.ts",
"scripts": {
"build": "NODE_ENV=production rimraf dist && tsc -p tsconfig.build.json && resolve-tspaths",
"build": "NODE_ENV=production rimraf dist && tsx bin/build.ts && resolve-tspaths",
"start": "NODE_ENV=development tsx watch --clear-screen=false src/main.ts",
"start:prod": "NODE_ENV=production node dist/main.js",
"start:e2e": "dotenv -e ../../.env.e2e -v NODE_ENV=test tsx watch src/main.ts",
Expand Down Expand Up @@ -73,6 +73,7 @@
"@graphql-codegen/client-preset": "^4.0.1",
"@graphql-tools/executor-http": "^1.0.0",
"@graphql-typed-document-node/core": "^3.2.0",
"@serieslist/esbuild": "workspace:*",
"@serieslist/eslint-config-base": "workspace:*",
"@serieslist/prettier-config": "workspace:*",
"@serieslist/type-utils": "workspace:*",
Expand Down
10 changes: 10 additions & 0 deletions apps/webapp/bin/build.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { buildEsbuild } from '@serieslist/esbuild'

import pkg from '../package.json'

await buildEsbuild({
packageJson: pkg,
entryPoints: ['src/server/server.ts'],
outdir: 'dist/prodServer',
tsconfig: 'tsconfig.server.json',
})
7 changes: 4 additions & 3 deletions apps/webapp/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@
"scripts": {
"start": "tsx ./src/server/server.ts",
"start:e2e": "dotenv -e ../../.env.e2e pnpm start",
"build": "rimraf dist && vite build && pnpm build:server && resolve-tspaths --out dist/prodServer",
"build:server": "tsc -p tsconfig.server.json",
"start:prod": "cross-env NODE_ENV=production node dist/prodServer/server/server.js",
"build": "rimraf dist && vite build && pnpm build:server",
"build:server": "tsx bin/build.ts && resolve-tspaths --out dist/prodServer",
"start:prod": "cross-env NODE_ENV=production node dist/prodServer/server.js",
"lint": "eslint src && madge --circular --extensions ts,tsx ./src",
"lint:fix": "eslint --fix src",
"tsc": "tsc --noEmit",
Expand Down Expand Up @@ -62,6 +62,7 @@
"@graphql-codegen/cli": "4.0.1",
"@graphql-codegen/client-preset": "4.0.1",
"@sentry/vite-plugin": "^2.8.0",
"@serieslist/esbuild": "workspace:*",
"@serieslist/eslint-config-react": "workspace:*",
"@serieslist/prettier-config": "workspace:*",
"@serieslist/type-utils": "workspace:*",
Expand Down
7 changes: 2 additions & 5 deletions apps/webapp/src/server/root.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,7 @@
// https://stackoverflow.com/questions/46745014/alternative-for-dirname-in-node-when-using-the-experimental-modules-flag/50052194#50052194

import { dirname } from 'path'
import { dirname, join } from 'path'
import { fileURLToPath } from 'url'

const __dirname = dirname(fileURLToPath(import.meta.url))
export const root =
process.env.NODE_ENV === 'production'
? `${__dirname}/../../..`
: `${__dirname}/../..`
export const root = join(__dirname, '..', '..')
4 changes: 4 additions & 0 deletions packages/esbuild/.eslintrc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"extends": "@serieslist/eslint-config-base",
"root": true
}
1 change: 1 addition & 0 deletions packages/esbuild/.prettierrc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"@serieslist/prettier-config"
19 changes: 19 additions & 0 deletions packages/esbuild/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{
"name": "@serieslist/esbuild",
"version": "1.0.0",
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": {
"import": "./src/index.ts",
"types": "./src/index.ts"
}
},
"devDependencies": {
"@serieslist/eslint-config-base": "workspace:*",
"@serieslist/prettier-config": "workspace:*",
"@serieslist/typescript-config-base": "workspace:*",
"esbuild": "^0.19.11"
}
}
51 changes: 51 additions & 0 deletions packages/esbuild/src/esbuild.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { type BuildOptions, build } from 'esbuild'

type EsbuildOptions = BuildOptions & {
/**
* Whole package.json file, import with
* ```typescript
* import packageJson from './package.json'
* ```
*/
packageJson: {
dependencies: Record<string, string>
devDependencies: Record<string, string>
}
}

/**
* Build options for compiling TypeScript for Node with esbuild.
*/
const buildEsbuildConfig = ({
packageJson,
...options
}: EsbuildOptions): BuildOptions => {
const { external = [], ...optionsWithoutExternal } = options

const packages = Object.keys(packageJson.dependencies)
.concat(Object.keys(packageJson.devDependencies))
.filter((name) => !name.startsWith('@serieslist'))

return {
outdir: 'dist',
platform: 'node',
target: 'esnext',
format: 'esm',
bundle: true,
tsconfig: 'tsconfig.json',
external: [...packages, ...external],
banner: {
// require does not exist in ESM, but some packages that are using require
// are bundled into `dist/` and esbuild does not convert requires to
// imports. This snippets creates a new `require` function that's used in
// the bundle.
// https://github.com/evanw/esbuild/issues/1921#issuecomment-1152991694
js: "import { createRequire } from 'module'; const require = createRequire(import.meta.url);",
},
...optionsWithoutExternal,
}
}

export const buildEsbuild = async (options: EsbuildOptions) => {
return await build(buildEsbuildConfig(options))
}
1 change: 1 addition & 0 deletions packages/esbuild/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './esbuild'
3 changes: 3 additions & 0 deletions packages/esbuild/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"extends": "@serieslist/typescript-config-base"
}
3 changes: 2 additions & 1 deletion packages/typescript-config-base/tsconfig.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"compilerOptions": {
"module": "ESNext",
"target": "ES2020",
Expand All @@ -8,6 +7,8 @@
"skipLibCheck": true,
"strict": true,
"esModuleInterop": true,
"isolatedModules": true,
"resolveJsonModule": true,
"paths": {
"#/*": ["./src/*"]
}
Expand Down
Loading

0 comments on commit 6f25b13

Please sign in to comment.