authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-13 21:24:20-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-13 21:24:20-07:00
log06de59b3d67e5be427ce4c7c8d41e91265709aec
tree368aa98631522c7fd97c8c1b0cf7dd4bc66a7cfd
parent683aa87096899aa191e150429415ecc78966f538
signature Signed by PGP key B5690EEEBB952194

build(server): make the CLI bundle loadable as a Node single-executable (#11316)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

20 files changed, 255 insertions(+), 407 deletions(-)

.gitignore+1
......@@ -5,6 +5,7 @@ node_modules
55*.log
66*.tsbuildinfo
77apps/*/dist
8apps/*/dist-exe
89infra/*/dist
910.astro
1011packages/*/dist
apps/server/package.json+1-3
......@@ -17,16 +17,15 @@
1717 "scripts": {
1818 "dev": "node --watch src/bin.ts",
1919 "build:bundle": "vp pack && vp pack src/service-launcher.ts --out-dir dist --no-clean",
20 "build:exe": "node scripts/cli.ts build-exe",
2021 "start": "node dist/bin.mjs",
2122 "typecheck": "tsc --noEmit",
2223 "test": "vp test run"
2324 },
2425 "dependencies": {
2526 "@anthropic-ai/claude-agent-sdk": "^0.3.260",
26 "@effect/platform-bun": "catalog:",
2727 "@effect/platform-node": "catalog:",
2828 "@effect/platform-node-shared": "catalog:",
29 "@effect/sql-sqlite-bun": "catalog:",
3029 "@ff-labs/fff-node": "0.9.4",
3130 "@opencode-ai/sdk": "^1.3.15",
3231 "effect": "catalog:",
......@@ -44,7 +43,6 @@
4443 "@t3tools/ssh": "workspace:*",
4544 "@t3tools/tailscale": "workspace:*",
4645 "@t3tools/web": "workspace:*",
47 "@types/bun": "1.3.14",
4846 "@types/node": "catalog:",
4947 "@types/yauzl": "^3.4.0",
5048 "effect-acp": "workspace:*",
apps/server/scripts/cli.ts+49-1
......@@ -15,6 +15,7 @@ import {
1515 resolveWebAssetBrandForPackageVersion,
1616 resolveWebIconOverrides,
1717} from "../../../scripts/lib/brand-assets.ts";
18import { findEsmImportsOfExternalPackages } from "../../../scripts/lib/cli-external-packages.ts";
1819import { resolveCatalogDependencies } from "../../../scripts/lib/resolve-catalog.ts";
1920import { fromJsonStringPretty } from "@t3tools/shared/schemaJson";
2021import { fromYaml } from "@t3tools/shared/schemaYaml";
......@@ -25,6 +26,7 @@ import {
2526 ServerCliCommandExitError,
2627 ServerCliDevelopmentIconSourceMissingError,
2728 ServerCliDevelopmentIconTargetMissingError,
29 ServerCliExecutableImportError,
2830 ServerCliPublishIconSourceMissingError,
2931 ServerCliPublishIconTargetMissingError,
3032} from "./cliErrors.ts";
......@@ -175,6 +177,52 @@ const buildCmd = Command.make(
175177 }),
176178).pipe(Command.withDescription("Build the server package (tsdown + bundle web client)."));
177179
180// ---------------------------------------------------------------------------
181// build-exe subcommand
182// ---------------------------------------------------------------------------
183
184const buildExeCmd = Command.make(
185 "build-exe",
186 {
187 verbose: Flag.boolean("verbose").pipe(Flag.withDefault(false)),
188 },
189 (config) =>
190 Effect.gen(function* () {
191 const path = yield* Path.Path;
192 const fs = yield* FileSystem.FileSystem;
193 const repoRoot = yield* RepoRoot;
194 const serverDir = path.join(repoRoot, "apps/server");
195
196 yield* Effect.log("[cli] Building single-executable...");
197 const spawnCommand = yield* resolveSpawnCommand("vp", ["pack"]);
198 yield* runCommand(
199 ChildProcess.make(spawnCommand.command, spawnCommand.args, {
200 cwd: serverDir,
201 env: { ...process.env, T3CODE_PACK_EXE: "1" },
202 stdout: config.verbose ? "inherit" : "ignore",
203 stderr: "inherit",
204 shell: spawnCommand.shell,
205 }),
206 );
207
208 // The executable can only `import` built-ins. A file-backed import
209 // passes the bundler and `node dist/bin.mjs`, then throws inside the
210 // binary, so read the emitted module graph rather than trusting config.
211 const bundlePath = path.join(serverDir, "dist-exe/bin.mjs");
212 const specifiers = findEsmImportsOfExternalPackages(yield* fs.readFileString(bundlePath));
213 if (specifiers.length > 0) {
214 return yield* new ServerCliExecutableImportError({ bundlePath, specifiers });
215 }
216 yield* Effect.log(
217 "[cli] Built dist-exe/t3 (expects client/, resource-monitor/, and the runtime-external node_modules beside it; scripts/build-cli-archive.ts assembles that tree)",
218 );
219 }),
220).pipe(
221 Command.withDescription(
222 "Build the server as a Node single-executable (needs a Node 25.7+ host for --build-sea). The binary still resolves native packages from a node_modules tree beside it.",
223 ),
224);
225
178226// ---------------------------------------------------------------------------
179227// publish subcommand
180228// ---------------------------------------------------------------------------
......@@ -309,7 +357,7 @@ const publishCmd = Command.make(
309357
310358const cli = Command.make("cli").pipe(
311359 Command.withDescription("T3 server build & publish CLI."),
312 Command.withSubcommands([buildCmd, publishCmd]),
360 Command.withSubcommands([buildCmd, buildExeCmd, publishCmd]),
313361);
314362
315363Command.run(cli, { version: "0.0.0" }).pipe(
apps/server/scripts/cliErrors.ts+12
......@@ -68,3 +68,15 @@ export class ServerCliBuildAssetMissingError extends Schema.TaggedError<ServerCl
6868 return `Missing build asset: ${this.assetPath}. Run the build subcommand first.`;
6969 }
7070}
71
72export class ServerCliExecutableImportError extends Schema.TaggedError<ServerCliExecutableImportError>()(
73 "ServerCliExecutableImportError",
74 {
75 bundlePath: Schema.String,
76 specifiers: Schema.Array(Schema.String),
77 },
78) {
79 override get message(): string {
80 return `${this.bundlePath} imports file-backed packages that a single-executable cannot resolve: ${this.specifiers.join(", ")}. Load them through createRequire instead.`;
81 }
82}
apps/server/src/persistence/Errors.test.ts-19
......@@ -3,7 +3,6 @@ import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
33import * as Effect from "effect/Effect";
44import * as Schema from "effect/Schema";
55import * as SqlClient from "effect/unstable/sql/SqlClient";
6import { classifySqliteError, SqlError } from "effect/unstable/sql/SqlError";
76
87import { PersistenceDecodeError, PersistenceSqlError, toPersistenceSqlError } from "./Errors.ts";
98
......@@ -55,24 +54,6 @@ it("reads the condition through a wrapping driver error", () => {
5554 assert.equal(error.detail, "SQLITE(5) database is locked");
5655});
5756
58it.each([{ errno: 1555, code: "SQLITE_CONSTRAINT_PRIMARYKEY" }, { errno: 1 }])(
59 "names Bun SQLite condition $errno through the SQL error wrapper",
60 (condition) => {
61 const driver = Object.assign(new Error("bun-sql-private-sentinel"), {
62 name: "SQLiteError",
63 ...condition,
64 });
65 const cause = new SqlError({ reason: classifySqliteError(driver) });
66 const error = toPersistenceSqlError("AuthSessionRepository.create:query")(cause);
67
68 assert.equal(
69 error.message,
70 `SQL error in AuthSessionRepository.create:query: SQLITE(${condition.errno})`,
71 );
72 assert.equal(error.cause, cause);
73 },
74);
75
7657it.each([
7758 new Error("unhelpful"),
7859 Object.assign(new Error("file not found"), { errno: -2, code: "ENOENT" }),
apps/server/src/persistence/Errors.ts+1-10
......@@ -75,7 +75,7 @@ const isPersistenceDecodeError = Schema.is(PersistenceDecodeError);
7575
7676/**
7777 * Read a SQLite condition through SQL error wrappers.
78 * Use Node's fixed description or Bun's numeric code, never the driver message.
78 * Use node:sqlite's fixed description, never the driver message.
7979 */
8080function sqliteCondition(cause: unknown): string | undefined {
8181 let value = cause;
......@@ -88,15 +88,6 @@ function sqliteCondition(cause: unknown): string | undefined {
8888 ) {
8989 return `SQLITE(${value.errcode}) ${value.errstr}`;
9090 }
91 if (
92 "name" in value &&
93 value.name === "SQLiteError" &&
94 "errno" in value &&
95 typeof value.errno === "number" &&
96 Number.isInteger(value.errno)
97 ) {
98 return `SQLITE(${value.errno})`;
99 }
10091 value = "cause" in value ? value.cause : undefined;
10192 }
10293 return undefined;
apps/server/src/persistence/Layers/Sqlite.ts+3-25
......@@ -3,33 +3,11 @@ import * as Layer from "effect/Layer";
33import * as FileSystem from "effect/FileSystem";
44import * as Path from "effect/Path";
55import * as SqlClient from "effect/unstable/sql/SqlClient";
6import type { SqlError } from "effect/unstable/sql/SqlError";
6import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
77
88import { runMigrations } from "../Migrations.ts";
99import { ServerConfig } from "../../config.ts";
1010
11type RuntimeSqliteLayerConfig = {
12 readonly filename: string;
13 readonly spanAttributes?: Record<string, unknown>;
14};
15
16type Loader = {
17 layer: (config: RuntimeSqliteLayerConfig) => Layer.Layer<SqlClient.SqlClient, SqlError>;
18};
19const defaultSqliteClientLoaders = {
20 bun: () => import("@effect/sql-sqlite-bun/SqliteClient"),
21 node: () => import("@t3tools/shared/nodeSqliteClient"),
22} satisfies Record<string, () => Promise<Loader>>;
23
24const makeRuntimeSqliteLayer = Effect.fn("makeRuntimeSqliteLayer")(function* (
25 config: RuntimeSqliteLayerConfig,
26) {
27 const runtime = process.versions.bun !== undefined ? "bun" : "node";
28 const loader = defaultSqliteClientLoaders[runtime];
29 const clientModule = yield* Effect.promise<Loader>(loader);
30 return clientModule.layer(config);
31}, Layer.unwrap);
32
3311const setup = Layer.effectDiscard(
3412 Effect.gen(function* () {
3513 const sql = yield* SqlClient.SqlClient;
......@@ -50,7 +28,7 @@ export const makeSqlitePersistenceLive = Effect.fn("makeSqlitePersistenceLive")(
5028
5129 return Layer.provideMerge(
5230 setup,
53 makeRuntimeSqliteLayer({
31 NodeSqliteClient.layer({
5432 filename: dbPath,
5533 spanAttributes: {
5634 "db.name": path.basename(dbPath),
......@@ -62,7 +40,7 @@ export const makeSqlitePersistenceLive = Effect.fn("makeSqlitePersistenceLive")(
6240
6341export const SqlitePersistenceMemory = Layer.provideMerge(
6442 setup,
65 makeRuntimeSqliteLayer({ filename: ":memory:" }),
43 NodeSqliteClient.layer({ filename: ":memory:" }),
6644);
6745
6846export const layerConfig = Layer.unwrap(
apps/server/src/server.ts+20-63
......@@ -1,3 +1,8 @@
1// @effect-diagnostics nodeBuiltinImport:off
2import * as NodeHttp from "node:http";
3
4import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer";
5import * as NodeServices from "@effect/platform-node/NodeServices";
16import {
27 EnvironmentHttpApi,
38 ProviderDriverKind,
......@@ -29,6 +34,7 @@ import { guardHttpResponseWriteErrors } from "./httpResponseErrorGuard.ts";
2934import { fixPath } from "./os-jank.ts";
3035import { websocketRpcRouteLayer } from "./ws.ts";
3136import * as ExternalLauncher from "./process/externalLauncher.ts";
37import * as NodePtyAdapter from "./terminal/NodePtyAdapter.ts";
3238import { pullRequestHttpApiLayer } from "./pullRequest/http.ts";
3339import * as PullRequestProviderRegistry from "./pullRequest/PullRequestProviderRegistry.ts";
3440import * as PullRequestService from "./pullRequest/PullRequestService.ts";
......@@ -163,17 +169,7 @@ const ApplicationObservabilityLive = ObservabilityLive.pipe(
163169 Layer.provideMerge(ResourceAttributionLayerLive),
164170);
165171
166const PtyAdapterLive = Layer.unwrap(
167 Effect.gen(function* () {
168 if (typeof Bun !== "undefined") {
169 const BunPtyAdapter = yield* Effect.promise(() => import("./terminal/BunPtyAdapter.ts"));
170 return BunPtyAdapter.layer;
171 } else {
172 const NodePtyAdapter = yield* Effect.promise(() => import("./terminal/NodePtyAdapter.ts"));
173 return NodePtyAdapter.layer;
174 }
175 }),
176);
172const PtyAdapterLive = NodePtyAdapter.layer;
177173
178174const ServerSettingsLayerLive = ServerSettings.layer.pipe(
179175 Layer.provide(ServerSecretStore.layer),
......@@ -226,61 +222,22 @@ const RelayClientLive = Layer.unwrap(
226222const HttpServerLive = Layer.unwrap(
227223 Effect.gen(function* () {
228224 const config = yield* ServerConfig.ServerConfig;
229 if (typeof Bun !== "undefined") {
230 const BunHttpServer = yield* Effect.promise(
231 () => import("@effect/platform-bun/BunHttpServer"),
232 );
233 return BunHttpServer.layer({
234 port: config.port,
235 hostname: config.host ?? "127.0.0.1",
236 gracefulShutdownTimeout: HTTP_PREEMPTIVE_SHUTDOWN_GRACE_MS,
237 websocket: {
238 // Negotiate permessage-deflate with clients that offer it; clients
239 // that don't still get uncompressed frames on their connection. A
240 // dedicated compressor keeps a per-connection sliding window
241 // (context takeover) so the compression dictionary is shared across
242 // server-to-client frames. Decompression uses the shared
243 // decompressor: uWebSockets' dedicated decompressor path can abort
244 // connections (close 1006) on valid DEFLATE input — see
245 // https://github.com/uNetworking/uWebSockets.js/issues/633.
246 perMessageDeflate: {
247 compress: "dedicated",
248 decompress: "shared",
249 },
250 },
251 });
252 } else {
253 const [NodeHttpServer, NodeHttp] = yield* Effect.all([
254 Effect.promise(() => import("@effect/platform-node/NodeHttpServer")),
255 Effect.promise(() => import("node:http")),
256 ]);
257 return NodeHttpServer.layer(() => guardHttpResponseWriteErrors(NodeHttp.createServer()), {
258 host: config.host ?? "127.0.0.1",
259 port: config.port,
260 gracefulShutdownTimeout: HTTP_PREEMPTIVE_SHUTDOWN_GRACE_MS,
261 // Negotiate permessage-deflate with clients that offer it; clients
262 // that don't still get uncompressed frames on their connection.
263 // Context takeover stays enabled (ws default) so the compression
264 // window is shared across frames — that also makes small frames cheap
265 // to compress, so no size threshold is set (ws only honors
266 // `threshold` when context takeover is disabled).
267 websocket: { perMessageDeflate: true },
268 });
269 }
225 return NodeHttpServer.layer(() => guardHttpResponseWriteErrors(NodeHttp.createServer()), {
226 host: config.host ?? "127.0.0.1",
227 port: config.port,
228 gracefulShutdownTimeout: HTTP_PREEMPTIVE_SHUTDOWN_GRACE_MS,
229 // Negotiate permessage-deflate with clients that offer it; clients
230 // that don't still get uncompressed frames on their connection.
231 // Context takeover stays enabled (ws default) so the compression
232 // window is shared across frames — that also makes small frames cheap
233 // to compress, so no size threshold is set (ws only honors
234 // `threshold` when context takeover is disabled).
235 websocket: { perMessageDeflate: true },
236 });
270237 }),
271238);
272239
273const PlatformServicesLive = Layer.unwrap(
274 Effect.gen(function* () {
275 if (typeof Bun !== "undefined") {
276 const { layer } = yield* Effect.promise(() => import("@effect/platform-bun/BunServices"));
277 return layer;
278 } else {
279 const { layer } = yield* Effect.promise(() => import("@effect/platform-node/NodeServices"));
280 return layer;
281 }
282 }),
283);
240const PlatformServicesLive = NodeServices.layer;
284241
285242const ReactorLayerLive = Layer.empty.pipe(
286243 Layer.provideMerge(OrchestrationReactorLive),
apps/server/src/terminal/BunPtyAdapter.test.ts deleted-44
......@@ -1,44 +0,0 @@
1import { assert, expect, it } from "@effect/vitest";
2import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
3import * as Cause from "effect/Cause";
4import * as Effect from "effect/Effect";
5import * as Exit from "effect/Exit";
6
7import * as BunPtyAdapter from "./BunPtyAdapter.ts";
8
9it("describes unavailable Bun PTY operations structurally", () => {
10 const error = new BunPtyAdapter.BunPtyOperationUnavailableError({
11 operation: "resize",
12 pid: 42,
13 });
14
15 expect(error).toMatchObject({
16 _tag: "BunPtyOperationUnavailableError",
17 operation: "resize",
18 pid: 42,
19 });
20 expect(error.message).toBe("Bun PTY resize is unavailable for process 42.");
21});
22
23it.effect("reports unsupported platforms with a structured startup defect", () =>
24 Effect.gen(function* () {
25 const exit = yield* BunPtyAdapter.make().pipe(
26 Effect.provideService(HostProcessPlatform, "win32"),
27 Effect.exit,
28 );
29
30 expect(Exit.isFailure(exit)).toBe(true);
31 if (Exit.isFailure(exit)) {
32 expect(Cause.hasDies(exit.cause)).toBe(true);
33 const error = Cause.squash(exit.cause);
34 assert.instanceOf(error, BunPtyAdapter.BunPtyUnsupportedPlatformError);
35 expect(error).toMatchObject({
36 _tag: "BunPtyUnsupportedPlatformError",
37 platform: "win32",
38 });
39 expect(error.message).toBe(
40 "Bun PTY terminal support is unavailable on win32. Please use Node.js (e.g. by running `npx t3`) instead.",
41 );
42 }
43 }),
44);
apps/server/src/terminal/BunPtyAdapter.ts deleted-155
......@@ -1,155 +0,0 @@
1/// <reference types="bun" />
2
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5import * as Schema from "effect/Schema";
6import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
7
8import * as PtyAdapter from "./PtyAdapter.ts";
9
10export class BunPtyUnsupportedPlatformError extends Schema.TaggedError<BunPtyUnsupportedPlatformError>()(
11 "BunPtyUnsupportedPlatformError",
12 {
13 platform: Schema.Literal("win32"),
14 },
15) {
16 override get message(): string {
17 return `Bun PTY terminal support is unavailable on ${this.platform}. Please use Node.js (e.g. by running \`npx t3\`) instead.`;
18 }
19}
20
21export class BunPtyOperationUnavailableError extends Schema.TaggedError<BunPtyOperationUnavailableError>()(
22 "BunPtyOperationUnavailableError",
23 {
24 operation: Schema.Literals(["write", "resize"]),
25 pid: Schema.Number,
26 },
27) {
28 override get message(): string {
29 return `Bun PTY ${this.operation} is unavailable for process ${this.pid}.`;
30 }
31}
32
33class BunPtyProcess implements PtyAdapter.PtyProcess {
34 private readonly dataListeners = new Set<(data: string) => void>();
35 private readonly exitListeners = new Set<(event: PtyAdapter.PtyExitEvent) => void>();
36 private readonly decoder = new TextDecoder();
37 private readonly process: Bun.Subprocess;
38 private didExit = false;
39
40 constructor(process: Bun.Subprocess) {
41 this.process = process;
42 void this.process.exited
43 .then((exitCode) => {
44 this.emitExit({
45 exitCode: Number.isInteger(exitCode) ? exitCode : 0,
46 signal: typeof this.process.signalCode === "number" ? this.process.signalCode : null,
47 });
48 })
49 .catch(() => {
50 this.emitExit({ exitCode: 1, signal: null });
51 });
52 }
53
54 get pid(): number {
55 return this.process.pid;
56 }
57
58 write(data: string): void {
59 if (!this.process.terminal) {
60 throw new BunPtyOperationUnavailableError({ operation: "write", pid: this.pid });
61 }
62 this.process.terminal.write(data);
63 }
64
65 resize(cols: number, rows: number): void {
66 if (!this.process.terminal?.resize) {
67 throw new BunPtyOperationUnavailableError({ operation: "resize", pid: this.pid });
68 }
69 this.process.terminal.resize(cols, rows);
70 }
71
72 kill(signal?: string): void {
73 if (!signal) {
74 this.process.kill();
75 return;
76 }
77 this.process.kill(signal as NodeJS.Signals);
78 }
79
80 onData(callback: (data: string) => void): () => void {
81 this.dataListeners.add(callback);
82 return () => {
83 this.dataListeners.delete(callback);
84 };
85 }
86
87 onExit(callback: (event: PtyAdapter.PtyExitEvent) => void): () => void {
88 this.exitListeners.add(callback);
89 return () => {
90 this.exitListeners.delete(callback);
91 };
92 }
93
94 emitData(data: Uint8Array): void {
95 if (this.didExit) return;
96 const text = this.decoder.decode(data, { stream: true });
97 if (text.length === 0) return;
98 for (const listener of this.dataListeners) {
99 listener(text);
100 }
101 }
102
103 private emitExit(event: PtyAdapter.PtyExitEvent): void {
104 if (this.didExit) return;
105 this.didExit = true;
106
107 const remainder = this.decoder.decode();
108 if (remainder.length > 0) {
109 for (const listener of this.dataListeners) {
110 listener(remainder);
111 }
112 }
113
114 for (const listener of this.exitListeners) {
115 listener(event);
116 }
117 }
118}
119
120export const make = Effect.fn("BunPtyAdapter.make")(function* () {
121 const platform = yield* HostProcessPlatform;
122 if (platform === "win32") {
123 return yield* Effect.die(new BunPtyUnsupportedPlatformError({ platform }));
124 }
125 return PtyAdapter.PtyAdapter.of({
126 spawn: (input) =>
127 Effect.try({
128 try: () => {
129 let processHandle: BunPtyProcess | null = null;
130 const command = [input.shell, ...(input.args ?? [])];
131 const subprocess = Bun.spawn(command, {
132 cwd: input.cwd,
133 env: input.env,
134 terminal: {
135 cols: input.cols,
136 rows: input.rows,
137 data: (_terminal, data) => {
138 processHandle?.emitData(data);
139 },
140 },
141 });
142 processHandle = new BunPtyProcess(subprocess);
143 return processHandle;
144 },
145 catch: (cause) =>
146 new PtyAdapter.PtySpawnError({
147 adapter: "bun",
148 shell: input.shell,
149 cause,
150 }),
151 }),
152 });
153});
154
155export const layer = Layer.effect(PtyAdapter.PtyAdapter, make());
apps/server/src/terminal/NodePtyAdapter.test.ts+6-2
......@@ -19,7 +19,7 @@ const spawn = vi.fn(() => ({
1919 onExit: vi.fn(() => ({ dispose: vi.fn() })),
2020}));
2121
22vi.mock("node-pty", () => ({ spawn }));
22const fakeNodePty = { spawn } as unknown as typeof import("node-pty");
2323
2424const makeTestLayer = (platform: NodeJS.Platform = "win32") =>
2525 NodePtyAdapter.layer.pipe(
......@@ -28,6 +28,7 @@ const makeTestLayer = (platform: NodeJS.Platform = "win32") =>
2828 NodeServices.layer,
2929 Layer.succeed(HostProcessPlatform, platform),
3030 Layer.succeed(HostProcessArchitecture, "x64"),
31 Layer.succeed(NodePtyAdapter.NodePtyModuleLoaderRef, () => Promise.resolve(fakeNodePty)),
3132 ),
3233 ),
3334 );
......@@ -125,7 +126,10 @@ it.effect("preserves a caller-provided TERM in the spawn env on win32", () =>
125126it.effect("reports native module load failures as structured startup defects", () =>
126127 Effect.gen(function* () {
127128 const cause = new Error("native binding could not be loaded");
128 const exit = yield* NodePtyAdapter.make(() => Promise.reject(cause)).pipe(Effect.exit);
129 const exit = yield* NodePtyAdapter.make().pipe(
130 Effect.provideService(NodePtyAdapter.NodePtyModuleLoaderRef, () => Promise.reject(cause)),
131 Effect.exit,
132 );
129133
130134 assert.isTrue(Exit.isFailure(exit));
131135 if (Exit.isFailure(exit)) {
apps/server/src/terminal/NodePtyAdapter.ts+18-4
......@@ -1,5 +1,6 @@
11import * as NodeModule from "node:module";
22
3import * as Context from "effect/Context";
34import * as Effect from "effect/Effect";
45import * as FileSystem from "effect/FileSystem";
56import * as Layer from "effect/Layer";
......@@ -24,10 +25,24 @@ export class NodePtyModuleLoadError extends Schema.TaggedError<NodePtyModuleLoad
2425
2526type NodePtyModuleLoader = () => Promise<typeof import("node-pty")>;
2627
28// node-pty stays external to the CLI bundle because it dlopens a native
29// addon. Inside a Node single-executable, `import()` cannot load files from
30// disk (only built-ins resolve), while `require` always reads the real
31// filesystem, so both the module and its spawn-helper resolve through it.
32const requireForNodePty = NodeModule.createRequire(import.meta.url);
33
34const loadNodePty: NodePtyModuleLoader = () =>
35 Promise.resolve().then(() => requireForNodePty("node-pty") as typeof import("node-pty"));
36
37/** Injectable so tests can substitute a fake module; `require` bypasses module mocks. */
38export const NodePtyModuleLoaderRef = Context.Reference<NodePtyModuleLoader>(
39 "server/terminal/NodePtyModuleLoader",
40 { defaultValue: () => loadNodePty },
41);
42
2743let didEnsureSpawnHelperExecutable = false;
2844
2945const resolveNodePtySpawnHelperPath = Effect.gen(function* () {
30 const requireForNodePty = NodeModule.createRequire(import.meta.url);
3146 const path = yield* Path.Path;
3247 const fs = yield* FileSystem.FileSystem;
3348 const platform = yield* HostProcessPlatform;
......@@ -113,9 +128,8 @@ class NodePtyProcess implements PtyAdapter.PtyProcess {
113128 }
114129}
115130
116export const make = Effect.fn("NodePtyAdapter.make")(function* (
117 loadNodePtyModule: NodePtyModuleLoader = () => import("node-pty"),
118) {
131export const make = Effect.fn("NodePtyAdapter.make")(function* () {
132 const loadNodePtyModule = yield* NodePtyModuleLoaderRef;
119133 const fs = yield* FileSystem.FileSystem;
120134 const path = yield* Path.Path;
121135 const platform = yield* HostProcessPlatform;
apps/server/src/workspace/WorkspaceSearchIndex.ts+20-11
......@@ -1,13 +1,15 @@
1import {
2 type DirItem,
3 type DirSearchResult,
4 type FileItem,
5 FileFinder,
6 type GrepCursor,
7 type MixedItem,
8 type MixedSearchResult,
9 type Result,
10 type SearchResult,
1import * as NodeModule from "node:module";
2
3import type {
4 DirItem,
5 DirSearchResult,
6 FileItem,
7 FileFinder as FileFinderType,
8 GrepCursor,
9 MixedItem,
10 MixedSearchResult,
11 Result,
12 SearchResult,
1113} from "@ff-labs/fff-node";
1214import * as Context from "effect/Context";
1315import * as Effect from "effect/Effect";
......@@ -25,6 +27,13 @@ import type {
2527} from "@t3tools/contracts";
2628import { isWorkspaceImagePreviewPath } from "@t3tools/shared/filePreview";
2729
30// fff-node stays external to the CLI bundle because it dlopens a native
31// library. A static `import` of an external package is a hard error inside a
32// Node single-executable (only built-ins resolve there), so load it through
33// `require`, which reads from the real filesystem in every runtime.
34const requireForFff = NodeModule.createRequire(import.meta.url);
35const { FileFinder } = requireForFff("@ff-labs/fff-node") as typeof import("@ff-labs/fff-node");
36
2837const WORKSPACE_INDEX_MAX_ENTRIES = 25_000;
2938const WORKSPACE_INDEX_PAGE_SIZE = WORKSPACE_INDEX_MAX_ENTRIES + 2;
3039const WORKSPACE_INDEX_SCAN_TIMEOUT = "15 seconds";
......@@ -330,7 +339,7 @@ const createFinder = Effect.fn("WorkspaceSearchIndex.createFinder")(function* (
330339
331340const waitForIndexReady = Effect.fn("WorkspaceSearchIndex.waitForIndexReady")(function* <E>(
332341 cwd: string,
333 finder: FileFinder,
342 finder: FileFinderType,
334343 onFailure: (input: { readonly reason: string; readonly cause?: unknown }) => E,
335344): Effect.fn.Return<void, E | WorkspaceSearchIndexScanTimedOut> {
336345 const result = yield* Effect.tryPromise({
apps/server/vite.config.ts+24-3
......@@ -23,6 +23,12 @@ export { shouldBundleCliDependency };
2323const repoEnv = loadRepoEnv();
2424const cliBuildChannel = packageJson.version.includes("-nightly.") ? "nightly" : "latest";
2525
26// `build:exe` wraps the same bundle in a Node single-executable. tsdown's exe
27// step refuses multi-chunk output and counts the sourcemap as a chunk, and the
28// executable needs a host Node that supports `--build-sea` (25.7+), so this is
29// a separate mode rather than a second entry in the default build.
30const packExecutable = process.env.T3CODE_PACK_EXE === "1";
31
2632export default mergeConfig(
2733 baseConfig,
2834 defineConfig({
......@@ -36,10 +42,25 @@ export default mergeConfig(
3642 },
3743 },
3844 pack: {
39 entry: ["src/bin.ts", "src/claudeHistoryWorker.ts"],
40 outDir: "dist",
41 sourcemap: true,
45 // The executable embeds one entry; the history worker becomes a hidden
46 // subcommand there instead of a sibling script.
47 entry: packExecutable ? ["src/bin.ts"] : ["src/bin.ts", "src/claudeHistoryWorker.ts"],
48 outDir: packExecutable ? "dist-exe" : "dist",
49 sourcemap: !packExecutable,
4250 clean: true,
51 ...(packExecutable
52 ? {
53 exe: {
54 fileName: "t3",
55 outDir: "dist-exe",
56 // Node's SEA docs: `import()` does not work when useCodeCache is
57 // true, and the server reaches several modules that way. The
58 // cache is also platform-bound, so leaving it off keeps the
59 // build correct on any host.
60 seaConfig: { useCodeCache: false },
61 },
62 }
63 : {}),
4364 deps: {
4465 // Both halves are required. `alwaysBundle` forces the JS dependencies in
4566 // (declared deps are external by default, which is what this change is
packages/shared/src/nodeSqliteClient.ts-12
......@@ -7,7 +7,6 @@
77import * as NodeSqlite from "node:sqlite";
88
99import * as Cache from "effect/Cache";
10import * as Config from "effect/Config";
1110import * as Duration from "effect/Duration";
1211import * as Effect from "effect/Effect";
1312import * as Fiber from "effect/Fiber";
......@@ -26,10 +25,6 @@ import * as Statement from "effect/unstable/sql/Statement";
2625
2726const ATTR_DB_SYSTEM_NAME = "db.system.name";
2827
29export const TypeId: TypeId = "~local/sqlite-node/SqliteClient";
30
31export type TypeId = "~local/sqlite-node/SqliteClient";
32
3328export interface SqliteClientConfig {
3429 readonly filename: string;
3530 readonly readonly?: boolean | undefined;
......@@ -315,13 +310,6 @@ const makeMemory = (
315310 },
316311 );
317312
318export const layerConfig = (
319 config: Config.Wrap<SqliteClientConfig>,
320): Layer.Layer<Client.SqlClient, Config.ConfigError | SqlError> =>
321 Layer.effect(Client.SqlClient, Config.unwrap(config).pipe(Effect.flatMap(make))).pipe(
322 Layer.provide(Reactivity.layer),
323 );
324
325313export const layer = (config: SqliteClientConfig): Layer.Layer<Client.SqlClient, SqlError> =>
326314 Layer.effect(Client.SqlClient, make(config)).pipe(Layer.provide(Reactivity.layer));
327315
patches/@ff-labs__fff-node@0.9.4.patch+14
......@@ -37,3 +37,17 @@ index ee181aef5007e4bf34a49479c089ca30f73a320b..327e2c55c83cc4c50d396a3109190ef1
3737 }
3838 }
3939 catch {
40diff --git a/package.json b/package.json
41--- a/package.json
42+++ b/package.json
43@@ -8,8 +8,9 @@
44 "types": "dist/src/index.d.ts",
45 "exports": {
46 ".": {
47+ "types": "./dist/src/index.d.ts",
48 "import": "./dist/src/index.js",
49- "types": "./dist/src/index.d.ts"
50+ "require": "./dist/src/index.js"
51 }
52 },
53 "files": [
pnpm-lock.yaml+9-24
......@@ -59,11 +59,9 @@ overrides:
5959 '@clerk/react': 6.14.7
6060 '@clerk/shared': 4.30.1
6161 '@effect/atom-react': 4.0.0-rc.112
62 '@effect/platform-bun': 4.0.0-rc.112
6362 '@effect/platform-node': 4.0.0-rc.112
6463 '@effect/platform-node-shared': 4.0.0-rc.112
6564 '@effect/sql-pg': 4.0.0-rc.112
66 '@effect/sql-sqlite-bun': 4.0.0-rc.112
6765 '@effect/vitest': 4.0.0-rc.112
6866 '@effect/vitest>vitest': '-'
6967 '@expo/dom-webview': 57.0.1
......@@ -90,7 +88,7 @@ patchedDependencies:
9088 '@clerk/expo@4.2.0': 72e426f44fc1cde16fc2cbba3d1e96cdca7c6d957faa73d0fe6b43948608a6c1
9189 '@effect/vitest@4.0.0-rc.112': a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b
9290 '@expo/metro-config@57.0.12': 96f1a75347e6ea02dc4b7034ace815d8ee39e18b8166ebfb573d9e58328f0dc2
93 '@ff-labs/fff-node@0.9.4': ab9ff544009e1891cfe3930105862d3699007f38922a79f3c98d90018deca368
91 '@ff-labs/fff-node@0.9.4': c4e3cc2420ceb9dc650f9d189e9c24baf7e83f342998bacda5f459a4ce7927a8
9492 '@legendapp/list@3.3.5': 680cc6a5c5b4a4032e467e7b3fde22f89a84c0ee2e6eac6fda737d6277cc0806
9593 '@pierre/diffs@1.3.0-beta.10': 0ccee155b93b63d810e2c1a40c1fd676fb6fbcfa72cf6430dcedf1a3ae475ab4
9694 '@react-native-ai/apple@0.12.0': 2d09870c2848d185cb05b53ed823a46e12dba519324d8dd8e584e28731990f9d
......@@ -499,21 +497,15 @@ importers:
499497 '@anthropic-ai/claude-agent-sdk':
500498 specifier: ^0.3.260
501499 version: 0.3.260(@anthropic-ai/sdk@0.93.0(zod@4.4.3))(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(zod@4.4.3)
502 '@effect/platform-bun':
503 specifier: 4.0.0-rc.112
504 version: 4.0.0-rc.112(bufferutil@4.1.0)(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2))(utf-8-validate@6.0.6)
505500 '@effect/platform-node':
506501 specifier: 4.0.0-rc.112
507502 version: 4.0.0-rc.112(bufferutil@4.1.0)(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2))(redis@6.2.1)(utf-8-validate@6.0.6)
508503 '@effect/platform-node-shared':
509504 specifier: 4.0.0-rc.112
510505 version: 4.0.0-rc.112(bufferutil@4.1.0)(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2))(utf-8-validate@6.0.6)
511 '@effect/sql-sqlite-bun':
512 specifier: 4.0.0-rc.112
513 version: 4.0.0-rc.112(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2))
514506 '@ff-labs/fff-node':
515507 specifier: 0.9.4
516 version: 0.9.4(patch_hash=ab9ff544009e1891cfe3930105862d3699007f38922a79f3c98d90018deca368)
508 version: 0.9.4(patch_hash=c4e3cc2420ceb9dc650f9d189e9c24baf7e83f342998bacda5f459a4ce7927a8)
517509 '@opencode-ai/sdk':
518510 specifier: ^1.3.15
519511 version: 1.15.13
......@@ -557,9 +549,6 @@ importers:
557549 '@t3tools/web':
558550 specifier: workspace:*
559551 version: link:../web
560 '@types/bun':
561 specifier: 1.3.14
562 version: 1.3.14
563552 '@types/node':
564553 specifier: 24.12.4
565554 version: 24.12.4
......@@ -1069,7 +1058,7 @@ packages:
10691058 resolution: {integrity: sha512-rH5BFh0Xq7aExpSpITyQbC+Jqfc9XgwJXidHVHTi1tNRC6/KAWg4tOzBfwiFIxljFLLYcApzlYFvLa/fTYcEjw==}
10701059 peerDependencies:
10711060 '@distilled.cloud/cloudflare': 1.0.0-rc.8
1072 '@effect/platform-bun': 4.0.0-rc.112
1061 '@effect/platform-bun': '>=4.0.0-rc.112 || >=4.0.0'
10731062 '@effect/platform-node': 4.0.0-rc.112
10741063 effect: 4.0.0-rc.112
10751064 rolldown: 1.2.5
......@@ -5353,9 +5342,6 @@ packages:
53535342 '@types/body-parser@1.19.6':
53545343 resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==}
53555344
5356 '@types/bun@1.3.14':
5357 resolution: {integrity: sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw==}
5358
53595345 '@types/cacheable-request@6.0.3':
53605346 resolution: {integrity: sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==}
53615347
......@@ -6093,7 +6079,7 @@ packages:
60936079 peerDependencies:
60946080 '@alchemy.run/frontend-frameworks': 2.0.0-beta.76
60956081 '@aws/durable-execution-sdk-js': ^2.1.0
6096 '@effect/platform-bun': 4.0.0-rc.112
6082 '@effect/platform-bun': '>=4.0.0-rc.112 || >=4.0.0'
60976083 '@effect/platform-node': 4.0.0-rc.112
60986084 '@effect/sql-mysql2': '>=4.0.0-rc.112 || >=4.0.0'
60996085 '@effect/sql-pg': 4.0.0-rc.112
......@@ -6973,7 +6959,7 @@ packages:
69736959 '@effect/sql-mysql2': '>=4.0.0-beta.105 || >=4.0.0'
69746960 '@effect/sql-pg': 4.0.0-rc.112
69756961 '@effect/sql-pglite': '>=4.0.0-beta.105 || >=4.0.0'
6976 '@effect/sql-sqlite-bun': 4.0.0-rc.112
6962 '@effect/sql-sqlite-bun': '>=4.0.0-beta.105 || >=4.0.0'
69776963 '@effect/sql-sqlite-do': '>=4.0.0-beta.105 || >=4.0.0'
69786964 '@effect/sql-sqlite-node': '>=4.0.0-beta.105 || >=4.0.0'
69796965 '@effect/sql-sqlite-wasm': '>=4.0.0-beta.105 || >=4.0.0'
......@@ -12675,6 +12661,7 @@ snapshots:
1267512661 transitivePeerDependencies:
1267612662 - bufferutil
1267712663 - utf-8-validate
12664 optional: true
1267812665
1267912666 '@effect/platform-node-shared@4.0.0-rc.112(bufferutil@4.1.0)(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2))(utf-8-validate@6.0.6)':
1268012667 dependencies:
......@@ -12715,6 +12702,7 @@ snapshots:
1271512702 '@effect/sql-sqlite-bun@4.0.0-rc.112(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2))':
1271612703 dependencies:
1271712704 effect: 4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2)
12705 optional: true
1271812706
1271912707 '@effect/sql-sqlite-do@4.0.0-rc.112(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2))':
1272012708 dependencies:
......@@ -13664,7 +13652,7 @@ snapshots:
1366413652 '@ff-labs/fff-bin-win32-x64@0.9.4':
1366513653 optional: true
1366613654
13667 '@ff-labs/fff-node@0.9.4(patch_hash=ab9ff544009e1891cfe3930105862d3699007f38922a79f3c98d90018deca368)':
13655 '@ff-labs/fff-node@0.9.4(patch_hash=c4e3cc2420ceb9dc650f9d189e9c24baf7e83f342998bacda5f459a4ce7927a8)':
1366813656 dependencies:
1366913657 ffi-rs: 1.3.2
1367013658 optionalDependencies:
......@@ -15848,10 +15836,6 @@ snapshots:
1584815836 '@types/connect': 3.4.38
1584915837 '@types/node': 24.12.4
1585015838
15851 '@types/bun@1.3.14':
15852 dependencies:
15853 bun-types: 1.3.14
15854
1585515839 '@types/cacheable-request@6.0.3':
1585615840 dependencies:
1585715841 '@types/http-cache-semantics': 4.2.0
......@@ -17009,6 +16993,7 @@ snapshots:
1700916993 bun-types@1.3.14:
1701016994 dependencies:
1701116995 '@types/node': 24.12.4
16996 optional: true
1701216997
1701316998 bytes@3.1.2: {}
1701416999
pnpm-workspace.yaml-6
......@@ -32,11 +32,9 @@ catalog:
3232 "@clerk/shared": 4.30.1
3333 "@effect/atom-react": 4.0.0-rc.112
3434 "@effect/openapi-generator": 4.0.0-rc.112
35 "@effect/platform-bun": 4.0.0-rc.112
3635 "@effect/platform-node": 4.0.0-rc.112
3736 "@effect/platform-node-shared": 4.0.0-rc.112
3837 "@effect/sql-pg": 4.0.0-rc.112
39 "@effect/sql-sqlite-bun": 4.0.0-rc.112
4038 "@effect/tsgo": 0.41.0
4139 "@effect/vitest": 4.0.0-rc.112
4240 "@legendapp/list": 3.3.5
......@@ -71,11 +69,9 @@ minimumReleaseAgeExclude:
7169 - "@distilled.cloud/planetscale@0.30.2"
7270 - "@effect/atom-react@4.0.0-rc.112"
7371 - "@effect/openapi-generator@4.0.0-rc.112"
74 - "@effect/platform-bun@4.0.0-rc.112"
7572 - "@effect/platform-node-shared@4.0.0-rc.112"
7673 - "@effect/platform-node@4.0.0-rc.112"
7774 - "@effect/sql-pg@4.0.0-rc.112"
78 - "@effect/sql-sqlite-bun@4.0.0-rc.112"
7975 - "@effect/vitest@4.0.0-rc.112"
8076 - alchemy@2.0.0-beta.76
8177 - effect@4.0.0-rc.112
......@@ -121,11 +117,9 @@ overrides:
121117 "@clerk/react": "catalog:"
122118 "@clerk/shared": "catalog:"
123119 "@effect/atom-react": "catalog:"
124 "@effect/platform-bun": "catalog:"
125120 "@effect/platform-node": "catalog:"
126121 "@effect/platform-node-shared": "catalog:"
127122 "@effect/sql-pg": "catalog:"
128 "@effect/sql-sqlite-bun": "catalog:"
129123 "@effect/vitest": "catalog:"
130124 "@effect/vitest>vitest": "-"
131125 "@expo/dom-webview": 57.0.1
scripts/lib/cli-external-packages.test.ts+39-6
......@@ -11,6 +11,7 @@ import serverPackageJson from "../../apps/server/package.json" with { type: "jso
1111
1212import {
1313 CLI_RUNTIME_EXTERNAL_PREFIXES,
14 findEsmImportsOfExternalPackages,
1415 findInlinedExternalPackages,
1516 selectCliRuntimeExternalDependencies,
1617 shouldBundleCliDependency,
......@@ -55,11 +56,6 @@ describe("shouldBundleCliDependency", () => {
5556 }
5657 });
5758
58 it("leaves bun-only entry points external", () => {
59 assert.strictEqual(shouldBundleCliDependency("@effect/platform-bun"), false);
60 assert.strictEqual(shouldBundleCliDependency("@effect/sql-sqlite-bun"), false);
61 });
62
6359 // The real package is `node-gyp-build-optional-packages`, reached by prefix.
6460 // It is transitive to a selected dependency root, so the runtime closure test
6561 // below ensures it follows that root into the sidecar.
......@@ -72,7 +68,6 @@ describe("selectCliRuntimeExternalDependencies", () => {
7268 it("keeps only runtime-external dependency roots for the Windows sidecar", () => {
7369 assert.deepStrictEqual(
7470 selectCliRuntimeExternalDependencies({
75 "@effect/platform-bun": "1.0.0",
7671 "@ff-labs/fff-node": "2.0.0",
7772 effect: "3.0.0",
7873 "node-pty": "4.0.0",
......@@ -281,3 +276,41 @@ var x = 1;
281276 assert.deepStrictEqual(result.inlined, []);
282277 });
283278});
279
280// The single-executable build can only `import` built-ins. A file-backed
281// import of an external package passes every bundler check and the regular
282// `node dist/bin.mjs` path, then fails inside the executable, so the scan
283// reads the emitted module graph instead.
284describe("findEsmImportsOfExternalPackages", () => {
285 it("flags static and dynamic imports of file-backed packages", () => {
286 const source = [
287 'import { FileFinder } from "@ff-labs/fff-node";',
288 'import * as fs from "fs";',
289 'import { createRequire } from "node:module";',
290 'const pty = () => import("node-pty");',
291 'const data = () => import("@ff-labs/fff-bin-linux-x64-gnu", { with: { type: "json" } });',
292 'const lazy = () => import(/* @vite-ignore */ "ffi-rs");',
293 'const local = () => import("./chunk-abc.mjs");',
294 ].join("\n");
295
296 assert.deepStrictEqual(findEsmImportsOfExternalPackages(source), [
297 "@ff-labs/fff-bin-linux-x64-gnu",
298 "@ff-labs/fff-node",
299 "ffi-rs",
300 "node-pty",
301 ]);
302 });
303
304 it("flags side-effect imports and re-exports too", () => {
305 const source = ['import "msgpackr-extract";', 'export { load } from "ffi-rs";'].join("\n");
306 assert.deepStrictEqual(findEsmImportsOfExternalPackages(source), [
307 "ffi-rs",
308 "msgpackr-extract",
309 ]);
310 });
311
312 it("does not mistake createRequire calls for imports", () => {
313 const source = 'const { FileFinder } = createRequire(import.meta.url)("@ff-labs/fff-node");';
314 assert.deepStrictEqual(findEsmImportsOfExternalPackages(source), []);
315 });
316});
scripts/lib/cli-external-packages.ts+38-19
......@@ -1,3 +1,6 @@
1// @effect-diagnostics nodeBuiltinImport:off
2import * as NodeModule from "node:module";
3
14/**
25 * The single source of truth for packages the server CLI bundle must NOT inline.
36 *
......@@ -50,24 +53,6 @@ export const CLI_RUNTIME_EXTERNAL_PREFIXES = [
5053 "utf-8-validate",
5154] as const;
5255
53/**
54 * External only so the bundler never has to resolve them.
55 *
56 * These are reached through a runtime-conditional dynamic import that Node
57 * never takes, and they resolve `bun:*` specifiers that do not exist when
58 * bundling for Node. Because Node never loads them, their dependency closure
59 * does not need to be external — only the entry point must stay unbundled.
60 */
61export const CLI_BUILD_ONLY_EXTERNAL_PREFIXES = [
62 "@effect/platform-bun",
63 "@effect/sql-sqlite-bun",
64] as const;
65
66export const CLI_EXTERNAL_PACKAGE_PREFIXES = [
67 ...CLI_RUNTIME_EXTERNAL_PREFIXES,
68 ...CLI_BUILD_ONLY_EXTERNAL_PREFIXES,
69] as const;
70
7156export function isRuntimeExternalCliDependency(id: string): boolean {
7257 return CLI_RUNTIME_EXTERNAL_PREFIXES.some((prefix) => id.startsWith(prefix));
7358}
......@@ -83,7 +68,7 @@ export function isRuntimeExternalCliDependency(id: string): boolean {
8368 * inlined while node-pty (a declared dependency) stayed external.
8469 */
8570export function isExternalCliDependency(id: string): boolean {
86 return CLI_EXTERNAL_PACKAGE_PREFIXES.some((prefix) => id.startsWith(prefix));
71 return isRuntimeExternalCliDependency(id);
8772}
8873
8974/** True when the CLI bundle should inline `id` rather than leave it external. */
......@@ -101,6 +86,40 @@ export function selectCliRuntimeExternalDependencies(
10186 );
10287}
10388
89/**
90 * Scan an emitted bundle chunk for ESM imports of packages that are not Node
91 * built-ins.
92 *
93 * Inside a Node single-executable, `import` statements and `import()` can only
94 * resolve built-in modules; any file-backed specifier throws at module
95 * evaluation (static) or at first use (dynamic). External packages therefore
96 * have to be reached through `createRequire`, which reads the real filesystem
97 * in every runtime. The bundler cannot enforce this, so the check reads what it
98 * produced.
99 */
100export function findEsmImportsOfExternalPackages(source: string): ReadonlyArray<string> {
101 const specifiers = new Set<string>();
102 // `import x from`, `import "side-effect"`, `export ... from`, and `import()`
103 // all resolve through the module loader.
104 const patterns = [
105 /^import\s[^;]*?\sfrom\s+["']([^"']+)["']/gm,
106 /^import\s+["']([^"']+)["']/gm,
107 /^export\s[^;]*?\sfrom\s+["']([^"']+)["']/gm,
108 // Rolldown may leave a `/* @vite-ignore */` style comment before the specifier.
109 /\bimport\(\s*(?:\/\*[\s\S]*?\*\/\s*)*["']([^"']+)["']\s*[,)]/g,
110 ];
111 for (const pattern of patterns) {
112 for (const match of source.matchAll(pattern)) {
113 const specifier = match[1];
114 if (specifier === undefined) continue;
115 if (NodeModule.isBuiltin(specifier)) continue;
116 if (specifier.startsWith("./") || specifier.startsWith("../")) continue;
117 specifiers.add(specifier);
118 }
119 }
120 return [...specifiers].sort();
121}
122
104123/**
105124 * Scan an emitted bundle chunk for runtime-external packages that were inlined.
106125 *