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...@@ -5,6 +5,7 @@ node_modules
5*.log5*.log
6*.tsbuildinfo6*.tsbuildinfo
7apps/*/dist7apps/*/dist
8apps/*/dist-exe
8infra/*/dist9infra/*/dist
9.astro10.astro
10packages/*/dist11packages/*/dist
apps/server/package.json+1-3
...@@ -17,16 +17,15 @@...@@ -17,16 +17,15 @@
17 "scripts": {17 "scripts": {
18 "dev": "node --watch src/bin.ts",18 "dev": "node --watch src/bin.ts",
19 "build:bundle": "vp pack && vp pack src/service-launcher.ts --out-dir dist --no-clean",19 "build:bundle": "vp pack && vp pack src/service-launcher.ts --out-dir dist --no-clean",
20 "build:exe": "node scripts/cli.ts build-exe",
20 "start": "node dist/bin.mjs",21 "start": "node dist/bin.mjs",
21 "typecheck": "tsc --noEmit",22 "typecheck": "tsc --noEmit",
22 "test": "vp test run"23 "test": "vp test run"
23 },24 },
24 "dependencies": {25 "dependencies": {
25 "@anthropic-ai/claude-agent-sdk": "^0.3.260",26 "@anthropic-ai/claude-agent-sdk": "^0.3.260",
26 "@effect/platform-bun": "catalog:",
27 "@effect/platform-node": "catalog:",27 "@effect/platform-node": "catalog:",
28 "@effect/platform-node-shared": "catalog:",28 "@effect/platform-node-shared": "catalog:",
29 "@effect/sql-sqlite-bun": "catalog:",
30 "@ff-labs/fff-node": "0.9.4",29 "@ff-labs/fff-node": "0.9.4",
31 "@opencode-ai/sdk": "^1.3.15",30 "@opencode-ai/sdk": "^1.3.15",
32 "effect": "catalog:",31 "effect": "catalog:",
...@@ -44,7 +43,6 @@...@@ -44,7 +43,6 @@
44 "@t3tools/ssh": "workspace:*",43 "@t3tools/ssh": "workspace:*",
45 "@t3tools/tailscale": "workspace:*",44 "@t3tools/tailscale": "workspace:*",
46 "@t3tools/web": "workspace:*",45 "@t3tools/web": "workspace:*",
47 "@types/bun": "1.3.14",
48 "@types/node": "catalog:",46 "@types/node": "catalog:",
49 "@types/yauzl": "^3.4.0",47 "@types/yauzl": "^3.4.0",
50 "effect-acp": "workspace:*",48 "effect-acp": "workspace:*",
apps/server/scripts/cli.ts+49-1
...@@ -15,6 +15,7 @@ import {...@@ -15,6 +15,7 @@ import {
15 resolveWebAssetBrandForPackageVersion,15 resolveWebAssetBrandForPackageVersion,
16 resolveWebIconOverrides,16 resolveWebIconOverrides,
17} from "../../../scripts/lib/brand-assets.ts";17} from "../../../scripts/lib/brand-assets.ts";
18import { findEsmImportsOfExternalPackages } from "../../../scripts/lib/cli-external-packages.ts";
18import { resolveCatalogDependencies } from "../../../scripts/lib/resolve-catalog.ts";19import { resolveCatalogDependencies } from "../../../scripts/lib/resolve-catalog.ts";
19import { fromJsonStringPretty } from "@t3tools/shared/schemaJson";20import { fromJsonStringPretty } from "@t3tools/shared/schemaJson";
20import { fromYaml } from "@t3tools/shared/schemaYaml";21import { fromYaml } from "@t3tools/shared/schemaYaml";
...@@ -25,6 +26,7 @@ import {...@@ -25,6 +26,7 @@ import {
25 ServerCliCommandExitError,26 ServerCliCommandExitError,
26 ServerCliDevelopmentIconSourceMissingError,27 ServerCliDevelopmentIconSourceMissingError,
27 ServerCliDevelopmentIconTargetMissingError,28 ServerCliDevelopmentIconTargetMissingError,
29 ServerCliExecutableImportError,
28 ServerCliPublishIconSourceMissingError,30 ServerCliPublishIconSourceMissingError,
29 ServerCliPublishIconTargetMissingError,31 ServerCliPublishIconTargetMissingError,
30} from "./cliErrors.ts";32} from "./cliErrors.ts";
...@@ -175,6 +177,52 @@ const buildCmd = Command.make(...@@ -175,6 +177,52 @@ const buildCmd = Command.make(
175 }),177 }),
176).pipe(Command.withDescription("Build the server package (tsdown + bundle web client)."));178).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
178// ---------------------------------------------------------------------------226// ---------------------------------------------------------------------------
179// publish subcommand227// publish subcommand
180// ---------------------------------------------------------------------------228// ---------------------------------------------------------------------------
...@@ -309,7 +357,7 @@ const publishCmd = Command.make(...@@ -309,7 +357,7 @@ const publishCmd = Command.make(
309357
310const cli = Command.make("cli").pipe(358const cli = Command.make("cli").pipe(
311 Command.withDescription("T3 server build & publish CLI."),359 Command.withDescription("T3 server build & publish CLI."),
312 Command.withSubcommands([buildCmd, publishCmd]),360 Command.withSubcommands([buildCmd, buildExeCmd, publishCmd]),
313);361);
314362
315Command.run(cli, { version: "0.0.0" }).pipe(363Command.run(cli, { version: "0.0.0" }).pipe(
apps/server/scripts/cliErrors.ts+12
...@@ -68,3 +68,15 @@ export class ServerCliBuildAssetMissingError extends Schema.TaggedError<ServerCl...@@ -68,3 +68,15 @@ export class ServerCliBuildAssetMissingError extends Schema.TaggedError<ServerCl
68 return `Missing build asset: ${this.assetPath}. Run the build subcommand first.`;68 return `Missing build asset: ${this.assetPath}. Run the build subcommand first.`;
69 }69 }
70}70}
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";...@@ -3,7 +3,6 @@ import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
3import * as Effect from "effect/Effect";3import * as Effect from "effect/Effect";
4import * as Schema from "effect/Schema";4import * as Schema from "effect/Schema";
5import * as SqlClient from "effect/unstable/sql/SqlClient";5import * as SqlClient from "effect/unstable/sql/SqlClient";
6import { classifySqliteError, SqlError } from "effect/unstable/sql/SqlError";
76
8import { PersistenceDecodeError, PersistenceSqlError, toPersistenceSqlError } from "./Errors.ts";7import { PersistenceDecodeError, PersistenceSqlError, toPersistenceSqlError } from "./Errors.ts";
98
...@@ -55,24 +54,6 @@ it("reads the condition through a wrapping driver error", () => {...@@ -55,24 +54,6 @@ it("reads the condition through a wrapping driver error", () => {
55 assert.equal(error.detail, "SQLITE(5) database is locked");54 assert.equal(error.detail, "SQLITE(5) database is locked");
56});55});
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
76it.each([57it.each([
77 new Error("unhelpful"),58 new Error("unhelpful"),
78 Object.assign(new Error("file not found"), { errno: -2, code: "ENOENT" }),59 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);...@@ -75,7 +75,7 @@ const isPersistenceDecodeError = Schema.is(PersistenceDecodeError);
7575
76/**76/**
77 * Read a SQLite condition through SQL error wrappers.77 * 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.
79 */79 */
80function sqliteCondition(cause: unknown): string | undefined {80function sqliteCondition(cause: unknown): string | undefined {
81 let value = cause;81 let value = cause;
...@@ -88,15 +88,6 @@ function sqliteCondition(cause: unknown): string | undefined {...@@ -88,15 +88,6 @@ function sqliteCondition(cause: unknown): string | undefined {
88 ) {88 ) {
89 return `SQLITE(${value.errcode}) ${value.errstr}`;89 return `SQLITE(${value.errcode}) ${value.errstr}`;
90 }90 }
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 }
100 value = "cause" in value ? value.cause : undefined;91 value = "cause" in value ? value.cause : undefined;
101 }92 }
102 return undefined;93 return undefined;
apps/server/src/persistence/Layers/Sqlite.ts+3-25
...@@ -3,33 +3,11 @@ import * as Layer from "effect/Layer";...@@ -3,33 +3,11 @@ import * as Layer from "effect/Layer";
3import * as FileSystem from "effect/FileSystem";3import * as FileSystem from "effect/FileSystem";
4import * as Path from "effect/Path";4import * as Path from "effect/Path";
5import * as SqlClient from "effect/unstable/sql/SqlClient";5import * as SqlClient from "effect/unstable/sql/SqlClient";
6import type { SqlError } from "effect/unstable/sql/SqlError";6import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
77
8import { runMigrations } from "../Migrations.ts";8import { runMigrations } from "../Migrations.ts";
9import { ServerConfig } from "../../config.ts";9import { 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
33const setup = Layer.effectDiscard(11const setup = Layer.effectDiscard(
34 Effect.gen(function* () {12 Effect.gen(function* () {
35 const sql = yield* SqlClient.SqlClient;13 const sql = yield* SqlClient.SqlClient;
...@@ -50,7 +28,7 @@ export const makeSqlitePersistenceLive = Effect.fn("makeSqlitePersistenceLive")(...@@ -50,7 +28,7 @@ export const makeSqlitePersistenceLive = Effect.fn("makeSqlitePersistenceLive")(
5028
51 return Layer.provideMerge(29 return Layer.provideMerge(
52 setup,30 setup,
53 makeRuntimeSqliteLayer({31 NodeSqliteClient.layer({
54 filename: dbPath,32 filename: dbPath,
55 spanAttributes: {33 spanAttributes: {
56 "db.name": path.basename(dbPath),34 "db.name": path.basename(dbPath),
...@@ -62,7 +40,7 @@ export const makeSqlitePersistenceLive = Effect.fn("makeSqlitePersistenceLive")(...@@ -62,7 +40,7 @@ export const makeSqlitePersistenceLive = Effect.fn("makeSqlitePersistenceLive")(
6240
63export const SqlitePersistenceMemory = Layer.provideMerge(41export const SqlitePersistenceMemory = Layer.provideMerge(
64 setup,42 setup,
65 makeRuntimeSqliteLayer({ filename: ":memory:" }),43 NodeSqliteClient.layer({ filename: ":memory:" }),
66);44);
6745
68export const layerConfig = Layer.unwrap(46export const layerConfig = Layer.unwrap(
apps/server/src/server.ts+20-63
...@@ -1,3 +1,8 @@...@@ -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";
1import {6import {
2 EnvironmentHttpApi,7 EnvironmentHttpApi,
3 ProviderDriverKind,8 ProviderDriverKind,
...@@ -29,6 +34,7 @@ import { guardHttpResponseWriteErrors } from "./httpResponseErrorGuard.ts";...@@ -29,6 +34,7 @@ import { guardHttpResponseWriteErrors } from "./httpResponseErrorGuard.ts";
29import { fixPath } from "./os-jank.ts";34import { fixPath } from "./os-jank.ts";
30import { websocketRpcRouteLayer } from "./ws.ts";35import { websocketRpcRouteLayer } from "./ws.ts";
31import * as ExternalLauncher from "./process/externalLauncher.ts";36import * as ExternalLauncher from "./process/externalLauncher.ts";
37import * as NodePtyAdapter from "./terminal/NodePtyAdapter.ts";
32import { pullRequestHttpApiLayer } from "./pullRequest/http.ts";38import { pullRequestHttpApiLayer } from "./pullRequest/http.ts";
33import * as PullRequestProviderRegistry from "./pullRequest/PullRequestProviderRegistry.ts";39import * as PullRequestProviderRegistry from "./pullRequest/PullRequestProviderRegistry.ts";
34import * as PullRequestService from "./pullRequest/PullRequestService.ts";40import * as PullRequestService from "./pullRequest/PullRequestService.ts";
...@@ -163,17 +169,7 @@ const ApplicationObservabilityLive = ObservabilityLive.pipe(...@@ -163,17 +169,7 @@ const ApplicationObservabilityLive = ObservabilityLive.pipe(
163 Layer.provideMerge(ResourceAttributionLayerLive),169 Layer.provideMerge(ResourceAttributionLayerLive),
164);170);
165171
166const PtyAdapterLive = Layer.unwrap(172const PtyAdapterLive = NodePtyAdapter.layer;
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);
177173
178const ServerSettingsLayerLive = ServerSettings.layer.pipe(174const ServerSettingsLayerLive = ServerSettings.layer.pipe(
179 Layer.provide(ServerSecretStore.layer),175 Layer.provide(ServerSecretStore.layer),
...@@ -226,61 +222,22 @@ const RelayClientLive = Layer.unwrap(...@@ -226,61 +222,22 @@ const RelayClientLive = Layer.unwrap(
226const HttpServerLive = Layer.unwrap(222const HttpServerLive = Layer.unwrap(
227 Effect.gen(function* () {223 Effect.gen(function* () {
228 const config = yield* ServerConfig.ServerConfig;224 const config = yield* ServerConfig.ServerConfig;
229 if (typeof Bun !== "undefined") {225 return NodeHttpServer.layer(() => guardHttpResponseWriteErrors(NodeHttp.createServer()), {
230 const BunHttpServer = yield* Effect.promise(226 host: config.host ?? "127.0.0.1",
231 () => import("@effect/platform-bun/BunHttpServer"),227 port: config.port,
232 );228 gracefulShutdownTimeout: HTTP_PREEMPTIVE_SHUTDOWN_GRACE_MS,
233 return BunHttpServer.layer({229 // Negotiate permessage-deflate with clients that offer it; clients
234 port: config.port,230 // that don't still get uncompressed frames on their connection.
235 hostname: config.host ?? "127.0.0.1",231 // Context takeover stays enabled (ws default) so the compression
236 gracefulShutdownTimeout: HTTP_PREEMPTIVE_SHUTDOWN_GRACE_MS,232 // window is shared across frames — that also makes small frames cheap
237 websocket: {233 // to compress, so no size threshold is set (ws only honors
238 // Negotiate permessage-deflate with clients that offer it; clients234 // `threshold` when context takeover is disabled).
239 // that don't still get uncompressed frames on their connection. A235 websocket: { perMessageDeflate: true },
240 // dedicated compressor keeps a per-connection sliding window236 });
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 }
270 }),237 }),
271);238);
272239
273const PlatformServicesLive = Layer.unwrap(240const PlatformServicesLive = NodeServices.layer;
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);
284241
285const ReactorLayerLive = Layer.empty.pipe(242const ReactorLayerLive = Layer.empty.pipe(
286 Layer.provideMerge(OrchestrationReactorLive),243 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(() => ({...@@ -19,7 +19,7 @@ const spawn = vi.fn(() => ({
19 onExit: vi.fn(() => ({ dispose: vi.fn() })),19 onExit: vi.fn(() => ({ dispose: vi.fn() })),
20}));20}));
2121
22vi.mock("node-pty", () => ({ spawn }));22const fakeNodePty = { spawn } as unknown as typeof import("node-pty");
2323
24const makeTestLayer = (platform: NodeJS.Platform = "win32") =>24const makeTestLayer = (platform: NodeJS.Platform = "win32") =>
25 NodePtyAdapter.layer.pipe(25 NodePtyAdapter.layer.pipe(
...@@ -28,6 +28,7 @@ const makeTestLayer = (platform: NodeJS.Platform = "win32") =>...@@ -28,6 +28,7 @@ const makeTestLayer = (platform: NodeJS.Platform = "win32") =>
28 NodeServices.layer,28 NodeServices.layer,
29 Layer.succeed(HostProcessPlatform, platform),29 Layer.succeed(HostProcessPlatform, platform),
30 Layer.succeed(HostProcessArchitecture, "x64"),30 Layer.succeed(HostProcessArchitecture, "x64"),
31 Layer.succeed(NodePtyAdapter.NodePtyModuleLoaderRef, () => Promise.resolve(fakeNodePty)),
31 ),32 ),
32 ),33 ),
33 );34 );
...@@ -125,7 +126,10 @@ it.effect("preserves a caller-provided TERM in the spawn env on win32", () =>...@@ -125,7 +126,10 @@ it.effect("preserves a caller-provided TERM in the spawn env on win32", () =>
125it.effect("reports native module load failures as structured startup defects", () =>126it.effect("reports native module load failures as structured startup defects", () =>
126 Effect.gen(function* () {127 Effect.gen(function* () {
127 const cause = new Error("native binding could not be loaded");128 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
130 assert.isTrue(Exit.isFailure(exit));134 assert.isTrue(Exit.isFailure(exit));
131 if (Exit.isFailure(exit)) {135 if (Exit.isFailure(exit)) {
apps/server/src/terminal/NodePtyAdapter.ts+18-4
...@@ -1,5 +1,6 @@...@@ -1,5 +1,6 @@
1import * as NodeModule from "node:module";1import * as NodeModule from "node:module";
22
3import * as Context from "effect/Context";
3import * as Effect from "effect/Effect";4import * as Effect from "effect/Effect";
4import * as FileSystem from "effect/FileSystem";5import * as FileSystem from "effect/FileSystem";
5import * as Layer from "effect/Layer";6import * as Layer from "effect/Layer";
...@@ -24,10 +25,24 @@ export class NodePtyModuleLoadError extends Schema.TaggedError<NodePtyModuleLoad...@@ -24,10 +25,24 @@ export class NodePtyModuleLoadError extends Schema.TaggedError<NodePtyModuleLoad
2425
25type NodePtyModuleLoader = () => Promise<typeof import("node-pty")>;26type 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
27let didEnsureSpawnHelperExecutable = false;43let didEnsureSpawnHelperExecutable = false;
2844
29const resolveNodePtySpawnHelperPath = Effect.gen(function* () {45const resolveNodePtySpawnHelperPath = Effect.gen(function* () {
30 const requireForNodePty = NodeModule.createRequire(import.meta.url);
31 const path = yield* Path.Path;46 const path = yield* Path.Path;
32 const fs = yield* FileSystem.FileSystem;47 const fs = yield* FileSystem.FileSystem;
33 const platform = yield* HostProcessPlatform;48 const platform = yield* HostProcessPlatform;
...@@ -113,9 +128,8 @@ class NodePtyProcess implements PtyAdapter.PtyProcess {...@@ -113,9 +128,8 @@ class NodePtyProcess implements PtyAdapter.PtyProcess {
113 }128 }
114}129}
115130
116export const make = Effect.fn("NodePtyAdapter.make")(function* (131export const make = Effect.fn("NodePtyAdapter.make")(function* () {
117 loadNodePtyModule: NodePtyModuleLoader = () => import("node-pty"),132 const loadNodePtyModule = yield* NodePtyModuleLoaderRef;
118) {
119 const fs = yield* FileSystem.FileSystem;133 const fs = yield* FileSystem.FileSystem;
120 const path = yield* Path.Path;134 const path = yield* Path.Path;
121 const platform = yield* HostProcessPlatform;135 const platform = yield* HostProcessPlatform;
apps/server/src/workspace/WorkspaceSearchIndex.ts+20-11
...@@ -1,13 +1,15 @@...@@ -1,13 +1,15 @@
1import {1import * as NodeModule from "node:module";
2 type DirItem,2
3 type DirSearchResult,3import type {
4 type FileItem,4 DirItem,
5 FileFinder,5 DirSearchResult,
6 type GrepCursor,6 FileItem,
7 type MixedItem,7 FileFinder as FileFinderType,
8 type MixedSearchResult,8 GrepCursor,
9 type Result,9 MixedItem,
10 type SearchResult,10 MixedSearchResult,
11 Result,
12 SearchResult,
11} from "@ff-labs/fff-node";13} from "@ff-labs/fff-node";
12import * as Context from "effect/Context";14import * as Context from "effect/Context";
13import * as Effect from "effect/Effect";15import * as Effect from "effect/Effect";
...@@ -25,6 +27,13 @@ import type {...@@ -25,6 +27,13 @@ import type {
25} from "@t3tools/contracts";27} from "@t3tools/contracts";
26import { isWorkspaceImagePreviewPath } from "@t3tools/shared/filePreview";28import { 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
28const WORKSPACE_INDEX_MAX_ENTRIES = 25_000;37const WORKSPACE_INDEX_MAX_ENTRIES = 25_000;
29const WORKSPACE_INDEX_PAGE_SIZE = WORKSPACE_INDEX_MAX_ENTRIES + 2;38const WORKSPACE_INDEX_PAGE_SIZE = WORKSPACE_INDEX_MAX_ENTRIES + 2;
30const WORKSPACE_INDEX_SCAN_TIMEOUT = "15 seconds";39const WORKSPACE_INDEX_SCAN_TIMEOUT = "15 seconds";
...@@ -330,7 +339,7 @@ const createFinder = Effect.fn("WorkspaceSearchIndex.createFinder")(function* (...@@ -330,7 +339,7 @@ const createFinder = Effect.fn("WorkspaceSearchIndex.createFinder")(function* (
330339
331const waitForIndexReady = Effect.fn("WorkspaceSearchIndex.waitForIndexReady")(function* <E>(340const waitForIndexReady = Effect.fn("WorkspaceSearchIndex.waitForIndexReady")(function* <E>(
332 cwd: string,341 cwd: string,
333 finder: FileFinder,342 finder: FileFinderType,
334 onFailure: (input: { readonly reason: string; readonly cause?: unknown }) => E,343 onFailure: (input: { readonly reason: string; readonly cause?: unknown }) => E,
335): Effect.fn.Return<void, E | WorkspaceSearchIndexScanTimedOut> {344): Effect.fn.Return<void, E | WorkspaceSearchIndexScanTimedOut> {
336 const result = yield* Effect.tryPromise({345 const result = yield* Effect.tryPromise({
apps/server/vite.config.ts+24-3
...@@ -23,6 +23,12 @@ export { shouldBundleCliDependency };...@@ -23,6 +23,12 @@ export { shouldBundleCliDependency };
23const repoEnv = loadRepoEnv();23const repoEnv = loadRepoEnv();
24const cliBuildChannel = packageJson.version.includes("-nightly.") ? "nightly" : "latest";24const 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
26export default mergeConfig(32export default mergeConfig(
27 baseConfig,33 baseConfig,
28 defineConfig({34 defineConfig({
...@@ -36,10 +42,25 @@ export default mergeConfig(...@@ -36,10 +42,25 @@ export default mergeConfig(
36 },42 },
37 },43 },
38 pack: {44 pack: {
39 entry: ["src/bin.ts", "src/claudeHistoryWorker.ts"],45 // The executable embeds one entry; the history worker becomes a hidden
40 outDir: "dist",46 // subcommand there instead of a sibling script.
41 sourcemap: true,47 entry: packExecutable ? ["src/bin.ts"] : ["src/bin.ts", "src/claudeHistoryWorker.ts"],
48 outDir: packExecutable ? "dist-exe" : "dist",
49 sourcemap: !packExecutable,
42 clean: true,50 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 : {}),
43 deps: {64 deps: {
44 // Both halves are required. `alwaysBundle` forces the JS dependencies in65 // Both halves are required. `alwaysBundle` forces the JS dependencies in
45 // (declared deps are external by default, which is what this change is66 // (declared deps are external by default, which is what this change is
packages/shared/src/nodeSqliteClient.ts-12
...@@ -7,7 +7,6 @@...@@ -7,7 +7,6 @@
7import * as NodeSqlite from "node:sqlite";7import * as NodeSqlite from "node:sqlite";
88
9import * as Cache from "effect/Cache";9import * as Cache from "effect/Cache";
10import * as Config from "effect/Config";
11import * as Duration from "effect/Duration";10import * as Duration from "effect/Duration";
12import * as Effect from "effect/Effect";11import * as Effect from "effect/Effect";
13import * as Fiber from "effect/Fiber";12import * as Fiber from "effect/Fiber";
...@@ -26,10 +25,6 @@ import * as Statement from "effect/unstable/sql/Statement";...@@ -26,10 +25,6 @@ import * as Statement from "effect/unstable/sql/Statement";
2625
27const ATTR_DB_SYSTEM_NAME = "db.system.name";26const 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
33export interface SqliteClientConfig {28export interface SqliteClientConfig {
34 readonly filename: string;29 readonly filename: string;
35 readonly readonly?: boolean | undefined;30 readonly readonly?: boolean | undefined;
...@@ -315,13 +310,6 @@ const makeMemory = (...@@ -315,13 +310,6 @@ const makeMemory = (
315 },310 },
316 );311 );
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
325export const layer = (config: SqliteClientConfig): Layer.Layer<Client.SqlClient, SqlError> =>313export const layer = (config: SqliteClientConfig): Layer.Layer<Client.SqlClient, SqlError> =>
326 Layer.effect(Client.SqlClient, make(config)).pipe(Layer.provide(Reactivity.layer));314 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...@@ -37,3 +37,17 @@ index ee181aef5007e4bf34a49479c089ca30f73a320b..327e2c55c83cc4c50d396a3109190ef1
37 }37 }
38 }38 }
39 catch {39 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:...@@ -59,11 +59,9 @@ overrides:
59 '@clerk/react': 6.14.759 '@clerk/react': 6.14.7
60 '@clerk/shared': 4.30.160 '@clerk/shared': 4.30.1
61 '@effect/atom-react': 4.0.0-rc.11261 '@effect/atom-react': 4.0.0-rc.112
62 '@effect/platform-bun': 4.0.0-rc.112
63 '@effect/platform-node': 4.0.0-rc.11262 '@effect/platform-node': 4.0.0-rc.112
64 '@effect/platform-node-shared': 4.0.0-rc.11263 '@effect/platform-node-shared': 4.0.0-rc.112
65 '@effect/sql-pg': 4.0.0-rc.11264 '@effect/sql-pg': 4.0.0-rc.112
66 '@effect/sql-sqlite-bun': 4.0.0-rc.112
67 '@effect/vitest': 4.0.0-rc.11265 '@effect/vitest': 4.0.0-rc.112
68 '@effect/vitest>vitest': '-'66 '@effect/vitest>vitest': '-'
69 '@expo/dom-webview': 57.0.167 '@expo/dom-webview': 57.0.1
...@@ -90,7 +88,7 @@ patchedDependencies:...@@ -90,7 +88,7 @@ patchedDependencies:
90 '@clerk/expo@4.2.0': 72e426f44fc1cde16fc2cbba3d1e96cdca7c6d957faa73d0fe6b43948608a6c188 '@clerk/expo@4.2.0': 72e426f44fc1cde16fc2cbba3d1e96cdca7c6d957faa73d0fe6b43948608a6c1
91 '@effect/vitest@4.0.0-rc.112': a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b89 '@effect/vitest@4.0.0-rc.112': a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b
92 '@expo/metro-config@57.0.12': 96f1a75347e6ea02dc4b7034ace815d8ee39e18b8166ebfb573d9e58328f0dc290 '@expo/metro-config@57.0.12': 96f1a75347e6ea02dc4b7034ace815d8ee39e18b8166ebfb573d9e58328f0dc2
93 '@ff-labs/fff-node@0.9.4': ab9ff544009e1891cfe3930105862d3699007f38922a79f3c98d90018deca36891 '@ff-labs/fff-node@0.9.4': c4e3cc2420ceb9dc650f9d189e9c24baf7e83f342998bacda5f459a4ce7927a8
94 '@legendapp/list@3.3.5': 680cc6a5c5b4a4032e467e7b3fde22f89a84c0ee2e6eac6fda737d6277cc080692 '@legendapp/list@3.3.5': 680cc6a5c5b4a4032e467e7b3fde22f89a84c0ee2e6eac6fda737d6277cc0806
95 '@pierre/diffs@1.3.0-beta.10': 0ccee155b93b63d810e2c1a40c1fd676fb6fbcfa72cf6430dcedf1a3ae475ab493 '@pierre/diffs@1.3.0-beta.10': 0ccee155b93b63d810e2c1a40c1fd676fb6fbcfa72cf6430dcedf1a3ae475ab4
96 '@react-native-ai/apple@0.12.0': 2d09870c2848d185cb05b53ed823a46e12dba519324d8dd8e584e28731990f9d94 '@react-native-ai/apple@0.12.0': 2d09870c2848d185cb05b53ed823a46e12dba519324d8dd8e584e28731990f9d
...@@ -499,21 +497,15 @@ importers:...@@ -499,21 +497,15 @@ importers:
499 '@anthropic-ai/claude-agent-sdk':497 '@anthropic-ai/claude-agent-sdk':
500 specifier: ^0.3.260498 specifier: ^0.3.260
501 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)499 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)
505 '@effect/platform-node':500 '@effect/platform-node':
506 specifier: 4.0.0-rc.112501 specifier: 4.0.0-rc.112
507 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)502 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)
508 '@effect/platform-node-shared':503 '@effect/platform-node-shared':
509 specifier: 4.0.0-rc.112504 specifier: 4.0.0-rc.112
510 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)505 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))
514 '@ff-labs/fff-node':506 '@ff-labs/fff-node':
515 specifier: 0.9.4507 specifier: 0.9.4
516 version: 0.9.4(patch_hash=ab9ff544009e1891cfe3930105862d3699007f38922a79f3c98d90018deca368)508 version: 0.9.4(patch_hash=c4e3cc2420ceb9dc650f9d189e9c24baf7e83f342998bacda5f459a4ce7927a8)
517 '@opencode-ai/sdk':509 '@opencode-ai/sdk':
518 specifier: ^1.3.15510 specifier: ^1.3.15
519 version: 1.15.13511 version: 1.15.13
...@@ -557,9 +549,6 @@ importers:...@@ -557,9 +549,6 @@ importers:
557 '@t3tools/web':549 '@t3tools/web':
558 specifier: workspace:*550 specifier: workspace:*
559 version: link:../web551 version: link:../web
560 '@types/bun':
561 specifier: 1.3.14
562 version: 1.3.14
563 '@types/node':552 '@types/node':
564 specifier: 24.12.4553 specifier: 24.12.4
565 version: 24.12.4554 version: 24.12.4
...@@ -1069,7 +1058,7 @@ packages:...@@ -1069,7 +1058,7 @@ packages:
1069 resolution: {integrity: sha512-rH5BFh0Xq7aExpSpITyQbC+Jqfc9XgwJXidHVHTi1tNRC6/KAWg4tOzBfwiFIxljFLLYcApzlYFvLa/fTYcEjw==}1058 resolution: {integrity: sha512-rH5BFh0Xq7aExpSpITyQbC+Jqfc9XgwJXidHVHTi1tNRC6/KAWg4tOzBfwiFIxljFLLYcApzlYFvLa/fTYcEjw==}
1070 peerDependencies:1059 peerDependencies:
1071 '@distilled.cloud/cloudflare': 1.0.0-rc.81060 '@distilled.cloud/cloudflare': 1.0.0-rc.8
1072 '@effect/platform-bun': 4.0.0-rc.1121061 '@effect/platform-bun': '>=4.0.0-rc.112 || >=4.0.0'
1073 '@effect/platform-node': 4.0.0-rc.1121062 '@effect/platform-node': 4.0.0-rc.112
1074 effect: 4.0.0-rc.1121063 effect: 4.0.0-rc.112
1075 rolldown: 1.2.51064 rolldown: 1.2.5
...@@ -5353,9 +5342,6 @@ packages:...@@ -5353,9 +5342,6 @@ packages:
5353 '@types/body-parser@1.19.6':5342 '@types/body-parser@1.19.6':
5354 resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==}5343 resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==}
53555344
5356 '@types/bun@1.3.14':
5357 resolution: {integrity: sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw==}
5358
5359 '@types/cacheable-request@6.0.3':5345 '@types/cacheable-request@6.0.3':
5360 resolution: {integrity: sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==}5346 resolution: {integrity: sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==}
53615347
...@@ -6093,7 +6079,7 @@ packages:...@@ -6093,7 +6079,7 @@ packages:
6093 peerDependencies:6079 peerDependencies:
6094 '@alchemy.run/frontend-frameworks': 2.0.0-beta.766080 '@alchemy.run/frontend-frameworks': 2.0.0-beta.76
6095 '@aws/durable-execution-sdk-js': ^2.1.06081 '@aws/durable-execution-sdk-js': ^2.1.0
6096 '@effect/platform-bun': 4.0.0-rc.1126082 '@effect/platform-bun': '>=4.0.0-rc.112 || >=4.0.0'
6097 '@effect/platform-node': 4.0.0-rc.1126083 '@effect/platform-node': 4.0.0-rc.112
6098 '@effect/sql-mysql2': '>=4.0.0-rc.112 || >=4.0.0'6084 '@effect/sql-mysql2': '>=4.0.0-rc.112 || >=4.0.0'
6099 '@effect/sql-pg': 4.0.0-rc.1126085 '@effect/sql-pg': 4.0.0-rc.112
...@@ -6973,7 +6959,7 @@ packages:...@@ -6973,7 +6959,7 @@ packages:
6973 '@effect/sql-mysql2': '>=4.0.0-beta.105 || >=4.0.0'6959 '@effect/sql-mysql2': '>=4.0.0-beta.105 || >=4.0.0'
6974 '@effect/sql-pg': 4.0.0-rc.1126960 '@effect/sql-pg': 4.0.0-rc.112
6975 '@effect/sql-pglite': '>=4.0.0-beta.105 || >=4.0.0'6961 '@effect/sql-pglite': '>=4.0.0-beta.105 || >=4.0.0'
6976 '@effect/sql-sqlite-bun': 4.0.0-rc.1126962 '@effect/sql-sqlite-bun': '>=4.0.0-beta.105 || >=4.0.0'
6977 '@effect/sql-sqlite-do': '>=4.0.0-beta.105 || >=4.0.0'6963 '@effect/sql-sqlite-do': '>=4.0.0-beta.105 || >=4.0.0'
6978 '@effect/sql-sqlite-node': '>=4.0.0-beta.105 || >=4.0.0'6964 '@effect/sql-sqlite-node': '>=4.0.0-beta.105 || >=4.0.0'
6979 '@effect/sql-sqlite-wasm': '>=4.0.0-beta.105 || >=4.0.0'6965 '@effect/sql-sqlite-wasm': '>=4.0.0-beta.105 || >=4.0.0'
...@@ -12675,6 +12661,7 @@ snapshots:...@@ -12675,6 +12661,7 @@ snapshots:
12675 transitivePeerDependencies:12661 transitivePeerDependencies:
12676 - bufferutil12662 - bufferutil
12677 - utf-8-validate12663 - utf-8-validate
12664 optional: true
1267812665
12679 '@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)':12666 '@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)':
12680 dependencies:12667 dependencies:
...@@ -12715,6 +12702,7 @@ snapshots:...@@ -12715,6 +12702,7 @@ snapshots:
12715 '@effect/sql-sqlite-bun@4.0.0-rc.112(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2))':12702 '@effect/sql-sqlite-bun@4.0.0-rc.112(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2))':
12716 dependencies:12703 dependencies:
12717 effect: 4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2)12704 effect: 4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2)
12705 optional: true
1271812706
12719 '@effect/sql-sqlite-do@4.0.0-rc.112(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2))':12707 '@effect/sql-sqlite-do@4.0.0-rc.112(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2))':
12720 dependencies:12708 dependencies:
...@@ -13664,7 +13652,7 @@ snapshots:...@@ -13664,7 +13652,7 @@ snapshots:
13664 '@ff-labs/fff-bin-win32-x64@0.9.4':13652 '@ff-labs/fff-bin-win32-x64@0.9.4':
13665 optional: true13653 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)':
13668 dependencies:13656 dependencies:
13669 ffi-rs: 1.3.213657 ffi-rs: 1.3.2
13670 optionalDependencies:13658 optionalDependencies:
...@@ -15848,10 +15836,6 @@ snapshots:...@@ -15848,10 +15836,6 @@ snapshots:
15848 '@types/connect': 3.4.3815836 '@types/connect': 3.4.38
15849 '@types/node': 24.12.415837 '@types/node': 24.12.4
1585015838
15851 '@types/bun@1.3.14':
15852 dependencies:
15853 bun-types: 1.3.14
15854
15855 '@types/cacheable-request@6.0.3':15839 '@types/cacheable-request@6.0.3':
15856 dependencies:15840 dependencies:
15857 '@types/http-cache-semantics': 4.2.015841 '@types/http-cache-semantics': 4.2.0
...@@ -17009,6 +16993,7 @@ snapshots:...@@ -17009,6 +16993,7 @@ snapshots:
17009 bun-types@1.3.14:16993 bun-types@1.3.14:
17010 dependencies:16994 dependencies:
17011 '@types/node': 24.12.416995 '@types/node': 24.12.4
16996 optional: true
1701216997
17013 bytes@3.1.2: {}16998 bytes@3.1.2: {}
1701416999
pnpm-workspace.yaml-6
...@@ -32,11 +32,9 @@ catalog:...@@ -32,11 +32,9 @@ catalog:
32 "@clerk/shared": 4.30.132 "@clerk/shared": 4.30.1
33 "@effect/atom-react": 4.0.0-rc.11233 "@effect/atom-react": 4.0.0-rc.112
34 "@effect/openapi-generator": 4.0.0-rc.11234 "@effect/openapi-generator": 4.0.0-rc.112
35 "@effect/platform-bun": 4.0.0-rc.112
36 "@effect/platform-node": 4.0.0-rc.11235 "@effect/platform-node": 4.0.0-rc.112
37 "@effect/platform-node-shared": 4.0.0-rc.11236 "@effect/platform-node-shared": 4.0.0-rc.112
38 "@effect/sql-pg": 4.0.0-rc.11237 "@effect/sql-pg": 4.0.0-rc.112
39 "@effect/sql-sqlite-bun": 4.0.0-rc.112
40 "@effect/tsgo": 0.41.038 "@effect/tsgo": 0.41.0
41 "@effect/vitest": 4.0.0-rc.11239 "@effect/vitest": 4.0.0-rc.112
42 "@legendapp/list": 3.3.540 "@legendapp/list": 3.3.5
...@@ -71,11 +69,9 @@ minimumReleaseAgeExclude:...@@ -71,11 +69,9 @@ minimumReleaseAgeExclude:
71 - "@distilled.cloud/planetscale@0.30.2"69 - "@distilled.cloud/planetscale@0.30.2"
72 - "@effect/atom-react@4.0.0-rc.112"70 - "@effect/atom-react@4.0.0-rc.112"
73 - "@effect/openapi-generator@4.0.0-rc.112"71 - "@effect/openapi-generator@4.0.0-rc.112"
74 - "@effect/platform-bun@4.0.0-rc.112"
75 - "@effect/platform-node-shared@4.0.0-rc.112"72 - "@effect/platform-node-shared@4.0.0-rc.112"
76 - "@effect/platform-node@4.0.0-rc.112"73 - "@effect/platform-node@4.0.0-rc.112"
77 - "@effect/sql-pg@4.0.0-rc.112"74 - "@effect/sql-pg@4.0.0-rc.112"
78 - "@effect/sql-sqlite-bun@4.0.0-rc.112"
79 - "@effect/vitest@4.0.0-rc.112"75 - "@effect/vitest@4.0.0-rc.112"
80 - alchemy@2.0.0-beta.7676 - alchemy@2.0.0-beta.76
81 - effect@4.0.0-rc.11277 - effect@4.0.0-rc.112
...@@ -121,11 +117,9 @@ overrides:...@@ -121,11 +117,9 @@ overrides:
121 "@clerk/react": "catalog:"117 "@clerk/react": "catalog:"
122 "@clerk/shared": "catalog:"118 "@clerk/shared": "catalog:"
123 "@effect/atom-react": "catalog:"119 "@effect/atom-react": "catalog:"
124 "@effect/platform-bun": "catalog:"
125 "@effect/platform-node": "catalog:"120 "@effect/platform-node": "catalog:"
126 "@effect/platform-node-shared": "catalog:"121 "@effect/platform-node-shared": "catalog:"
127 "@effect/sql-pg": "catalog:"122 "@effect/sql-pg": "catalog:"
128 "@effect/sql-sqlite-bun": "catalog:"
129 "@effect/vitest": "catalog:"123 "@effect/vitest": "catalog:"
130 "@effect/vitest>vitest": "-"124 "@effect/vitest>vitest": "-"
131 "@expo/dom-webview": 57.0.1125 "@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...@@ -11,6 +11,7 @@ import serverPackageJson from "../../apps/server/package.json" with { type: "jso
1111
12import {12import {
13 CLI_RUNTIME_EXTERNAL_PREFIXES,13 CLI_RUNTIME_EXTERNAL_PREFIXES,
14 findEsmImportsOfExternalPackages,
14 findInlinedExternalPackages,15 findInlinedExternalPackages,
15 selectCliRuntimeExternalDependencies,16 selectCliRuntimeExternalDependencies,
16 shouldBundleCliDependency,17 shouldBundleCliDependency,
...@@ -55,11 +56,6 @@ describe("shouldBundleCliDependency", () => {...@@ -55,11 +56,6 @@ describe("shouldBundleCliDependency", () => {
55 }56 }
56 });57 });
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
63 // The real package is `node-gyp-build-optional-packages`, reached by prefix.59 // The real package is `node-gyp-build-optional-packages`, reached by prefix.
64 // It is transitive to a selected dependency root, so the runtime closure test60 // It is transitive to a selected dependency root, so the runtime closure test
65 // below ensures it follows that root into the sidecar.61 // below ensures it follows that root into the sidecar.
...@@ -72,7 +68,6 @@ describe("selectCliRuntimeExternalDependencies", () => {...@@ -72,7 +68,6 @@ describe("selectCliRuntimeExternalDependencies", () => {
72 it("keeps only runtime-external dependency roots for the Windows sidecar", () => {68 it("keeps only runtime-external dependency roots for the Windows sidecar", () => {
73 assert.deepStrictEqual(69 assert.deepStrictEqual(
74 selectCliRuntimeExternalDependencies({70 selectCliRuntimeExternalDependencies({
75 "@effect/platform-bun": "1.0.0",
76 "@ff-labs/fff-node": "2.0.0",71 "@ff-labs/fff-node": "2.0.0",
77 effect: "3.0.0",72 effect: "3.0.0",
78 "node-pty": "4.0.0",73 "node-pty": "4.0.0",
...@@ -281,3 +276,41 @@ var x = 1;...@@ -281,3 +276,41 @@ var x = 1;
281 assert.deepStrictEqual(result.inlined, []);276 assert.deepStrictEqual(result.inlined, []);
282 });277 });
283});278});
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,3 +1,6 @@
1// @effect-diagnostics nodeBuiltinImport:off
2import * as NodeModule from "node:module";
3
1/**4/**
2 * The single source of truth for packages the server CLI bundle must NOT inline.5 * The single source of truth for packages the server CLI bundle must NOT inline.
3 *6 *
...@@ -50,24 +53,6 @@ export const CLI_RUNTIME_EXTERNAL_PREFIXES = [...@@ -50,24 +53,6 @@ export const CLI_RUNTIME_EXTERNAL_PREFIXES = [
50 "utf-8-validate",53 "utf-8-validate",
51] as const;54] 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
71export function isRuntimeExternalCliDependency(id: string): boolean {56export function isRuntimeExternalCliDependency(id: string): boolean {
72 return CLI_RUNTIME_EXTERNAL_PREFIXES.some((prefix) => id.startsWith(prefix));57 return CLI_RUNTIME_EXTERNAL_PREFIXES.some((prefix) => id.startsWith(prefix));
73}58}
...@@ -83,7 +68,7 @@ export function isRuntimeExternalCliDependency(id: string): boolean {...@@ -83,7 +68,7 @@ export function isRuntimeExternalCliDependency(id: string): boolean {
83 * inlined while node-pty (a declared dependency) stayed external.68 * inlined while node-pty (a declared dependency) stayed external.
84 */69 */
85export function isExternalCliDependency(id: string): boolean {70export function isExternalCliDependency(id: string): boolean {
86 return CLI_EXTERNAL_PACKAGE_PREFIXES.some((prefix) => id.startsWith(prefix));71 return isRuntimeExternalCliDependency(id);
87}72}
8873
89/** True when the CLI bundle should inline `id` rather than leave it external. */74/** True when the CLI bundle should inline `id` rather than leave it external. */
...@@ -101,6 +86,40 @@ export function selectCliRuntimeExternalDependencies(...@@ -101,6 +86,40 @@ export function selectCliRuntimeExternalDependencies(
101 );86 );
102}87}
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
104/**123/**
105 * Scan an emitted bundle chunk for runtime-external packages that were inlined.124 * Scan an emitted bundle chunk for runtime-external packages that were inlined.
106 *125 *