From ced0d5c13e2fdddac6a7d975dc64bb3ef223c361 Mon Sep 17 00:00:00 2001 From: Brandon Keepers Date: Sat, 7 Mar 2026 15:36:38 -0500 Subject: [PATCH 1/6] Update tsdown to 0.21.0 and refactor build script for SEA binary --- .changeset/cli-tsdown-sea.md | 5 +++ package.json | 2 +- packages/cli/package.json | 3 +- packages/cli/scripts/build-sea.ts | 72 ------------------------------- packages/cli/tsdown.sea.config.ts | 30 +++++++++++++ 5 files changed, 37 insertions(+), 75 deletions(-) create mode 100644 .changeset/cli-tsdown-sea.md delete mode 100644 packages/cli/scripts/build-sea.ts create mode 100644 packages/cli/tsdown.sea.config.ts diff --git a/.changeset/cli-tsdown-sea.md b/.changeset/cli-tsdown-sea.md new file mode 100644 index 00000000..c7daaa1f --- /dev/null +++ b/.changeset/cli-tsdown-sea.md @@ -0,0 +1,5 @@ +--- +"@neaps/cli": patch +--- + +Migrate SEA (Single Executable Application) build from a custom esbuild script to tsdown's built-in `exe` option. Removes the `esbuild` dev dependency. diff --git a/package.json b/package.json index 90b7ef6c..48ac1597 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "make-fetch-happen": "^15.0.3", "npm-run-all": "^4.1.5", "prettier": "^3.7.4", - "tsdown": "^0.20.1", + "tsdown": "^0.21.0", "typescript": "^5.3.3", "typescript-eslint": "^8.56.0", "vitest": "^4.0.15" diff --git a/packages/cli/package.json b/packages/cli/package.json index 23bfb6b7..bb9a9079 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -34,7 +34,7 @@ ], "scripts": { "build": "tsdown", - "build:sea": "node scripts/build-sea.ts", + "build:sea": "tsdown --config tsdown.sea.config.ts", "prepack": "npm run build", "test": "vitest" }, @@ -48,7 +48,6 @@ }, "devDependencies": { "@types/node": "^25.0.2", - "esbuild": "^0.27.3", "nock": "^14.0.11" } } diff --git a/packages/cli/scripts/build-sea.ts b/packages/cli/scripts/build-sea.ts deleted file mode 100644 index 3ffe5844..00000000 --- a/packages/cli/scripts/build-sea.ts +++ /dev/null @@ -1,72 +0,0 @@ -/** - * Build a Node.js Single Executable Application (SEA). - * - * 1. Bundle ESM sources into a single CJS file with esbuild - * 2. Build SEA binary with `node --build-sea` (Node 25.5+) - */ -import { build } from "esbuild"; -import { existsSync, mkdirSync, statSync, writeFileSync } from "node:fs"; -import { execFileSync } from "node:child_process"; -import { resolve, dirname } from "node:path"; -import { fileURLToPath } from "node:url"; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const root = resolve(__dirname, ".."); -const distDir = resolve(root, "dist"); -const bundlePath = resolve(distDir, "sea-bundle.cjs"); - -const isWindows = process.platform === "win32"; -const ext = isWindows ? ".exe" : ""; -const outputPath = resolve(distDir, `neaps${ext}`); - -if (!existsSync(distDir)) { - mkdirSync(distDir, { recursive: true }); -} - -// Step 1: Bundle ESM → single CJS file -console.log("Bundling with esbuild..."); -await build({ - entryPoints: [resolve(root, "src/index.ts")], - bundle: true, - platform: "node", - format: "cjs", - outfile: bundlePath, - minify: true, - sourcemap: false, - external: [], - target: "node25", - banner: { - js: "// Single executable application bundle", - }, -}); -console.log(`Bundled to ${bundlePath}`); - -// Step 2: Build SEA binary -const configPath = resolve(distDir, "sea-config.json"); -writeFileSync( - configPath, - JSON.stringify({ - main: "./dist/sea-bundle.cjs", - output: `./dist/neaps${ext}`, - disableExperimentalSEAWarning: true, - useCodeCache: false, - executable: process.execPath, - }), -); - -console.log("Building SEA binary..."); -execFileSync("node", ["--build-sea", configPath], { stdio: "inherit", cwd: root }); - -// macOS requires ad-hoc signing after injection -if (process.platform === "darwin") { - console.log("Signing binary (macOS)..."); - execFileSync("codesign", ["--sign", "-", "--identifier", "io.openwaters.neaps", outputPath], { - stdio: "inherit", - }); -} - -console.log(`\nSingle executable built: ${outputPath}`); - -const stats = statSync(outputPath); -const sizeMB = (stats.size / (1024 * 1024)).toFixed(1); -console.log(`Size: ${sizeMB} MB`); diff --git a/packages/cli/tsdown.sea.config.ts b/packages/cli/tsdown.sea.config.ts new file mode 100644 index 00000000..176ad4a7 --- /dev/null +++ b/packages/cli/tsdown.sea.config.ts @@ -0,0 +1,30 @@ +import { execFileSync } from "node:child_process"; +import { resolve } from "node:path"; +import { defineConfig } from "tsdown"; + +export default defineConfig({ + entry: ["./src/index.ts"], + platform: "node", + exe: { + fileName: "neaps", + seaConfig: { + disableExperimentalSEAWarning: true, + }, + }, + onSuccess: (config) => { + // Re-sign with the stable identifier for reproducible builds. + // tsdown performs an initial ad-hoc sign (defaulting to the binary + // name as the identifier); this overwrites it with the canonical one. + if (process.platform === "darwin") { + const outputPath = resolve(config.outDir, "neaps"); + execFileSync("codesign", [ + "--sign", + "-", + "--identifier", + "io.openwaters.neaps", + "--force", + outputPath, + ]); + } + }, +}); From 1e2cda3427d125859651593e278920423510729b Mon Sep 17 00:00:00 2001 From: Brandon Keepers Date: Sat, 7 Mar 2026 18:47:04 -0500 Subject: [PATCH 2/6] Add deps configuration to tsdown.sea.config.ts --- packages/cli/tsdown.sea.config.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/cli/tsdown.sea.config.ts b/packages/cli/tsdown.sea.config.ts index 176ad4a7..2ee6bd0e 100644 --- a/packages/cli/tsdown.sea.config.ts +++ b/packages/cli/tsdown.sea.config.ts @@ -5,6 +5,9 @@ import { defineConfig } from "tsdown"; export default defineConfig({ entry: ["./src/index.ts"], platform: "node", + deps: { + skipNodeModulesBundle: false, + }, exe: { fileName: "neaps", seaConfig: { From b47cd9575ac8f5360c87f43aeb1215461ce38576 Mon Sep 17 00:00:00 2001 From: Brandon Keepers Date: Sat, 7 Mar 2026 18:49:35 -0500 Subject: [PATCH 3/6] Add smoke test for SEA binary to verify startup and response --- .github/workflows/build-binaries.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/build-binaries.yml b/.github/workflows/build-binaries.yml index 906a2fa6..25df2eec 100644 --- a/.github/workflows/build-binaries.yml +++ b/.github/workflows/build-binaries.yml @@ -71,6 +71,14 @@ jobs: - name: Build SEA binary run: npm run build:sea --workspace=packages/cli + - name: Smoke test binary + shell: bash + run: | + EXT=${{ matrix.target == 'win-x64' && '".exe"' || '""' }} + BIN="packages/cli/dist/neaps${EXT}" + "$BIN" --version + "$BIN" --help + - name: Verify reproducible build shell: bash run: | From 8b3d15f4bd32aa78ca56ffd7fc073c16101f8d49 Mon Sep 17 00:00:00 2001 From: Brandon Keepers Date: Sat, 7 Mar 2026 19:01:04 -0500 Subject: [PATCH 4/6] Fix SEA build: bundle all dependencies with deps.alwaysBundle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tsdown's DepPlugin externalizes any package listed in package.json dependencies by default. For a SEA binary every dependency must be bundled into the single output file, so override this with deps.alwaysBundle: () => true. The previous deps.skipNodeModulesBundle: false was a no-op — that flag only controls automatic externalization of node_modules that are NOT production deps, and was already false by default. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- packages/cli/tsdown.sea.config.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/cli/tsdown.sea.config.ts b/packages/cli/tsdown.sea.config.ts index 2ee6bd0e..51303601 100644 --- a/packages/cli/tsdown.sea.config.ts +++ b/packages/cli/tsdown.sea.config.ts @@ -6,7 +6,9 @@ export default defineConfig({ entry: ["./src/index.ts"], platform: "node", deps: { - skipNodeModulesBundle: false, + // SEA needs everything bundled — override the default behavior that + // externalizes packages listed in package.json dependencies. + alwaysBundle: () => true, }, exe: { fileName: "neaps", From 7f77955593baed1f8e061c87f27008da98889edc Mon Sep 17 00:00:00 2001 From: Brandon Keepers Date: Sat, 7 Mar 2026 19:51:24 -0500 Subject: [PATCH 5/6] Use CJS format for SEA build to restore fast startup time ESM SEA binaries load ~7x slower than CJS due to async module graph initialization (compileSourceTextModule). The old esbuild script used format: cjs explicitly; tsdown defaults to esm. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- packages/cli/tsdown.sea.config.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/cli/tsdown.sea.config.ts b/packages/cli/tsdown.sea.config.ts index 51303601..dc7db305 100644 --- a/packages/cli/tsdown.sea.config.ts +++ b/packages/cli/tsdown.sea.config.ts @@ -5,6 +5,9 @@ import { defineConfig } from "tsdown"; export default defineConfig({ entry: ["./src/index.ts"], platform: "node", + // CJS format has significantly faster startup than ESM in SEA binaries + // (synchronous require vs async module graph loading). + format: "cjs", deps: { // SEA needs everything bundled — override the default behavior that // externalizes packages listed in package.json dependencies. From 7b6598b6290b53bb5cf21a75fca5f95c7adfbf9e Mon Sep 17 00:00:00 2001 From: Brandon Keepers Date: Sat, 7 Mar 2026 19:52:20 -0500 Subject: [PATCH 6/6] Minify SEA bundle to match main branch build size The original esbuild script used minify: true. Smaller bundle means less to parse and load at startup. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- packages/cli/tsdown.sea.config.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/cli/tsdown.sea.config.ts b/packages/cli/tsdown.sea.config.ts index dc7db305..43a50c5b 100644 --- a/packages/cli/tsdown.sea.config.ts +++ b/packages/cli/tsdown.sea.config.ts @@ -8,6 +8,7 @@ export default defineConfig({ // CJS format has significantly faster startup than ESM in SEA binaries // (synchronous require vs async module graph loading). format: "cjs", + minify: true, deps: { // SEA needs everything bundled — override the default behavior that // externalizes packages listed in package.json dependencies.