Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,18 @@ jobs:
- name: Verify preload bundle output
run: node apps/desktop/scripts/verify-preload-bundle.mjs

- name: Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version: "1.4.0"

- name: Smoke the built server under Bun
timeout-minutes: 2
env:
# The standalone child must discard inherited launcher state.
T3_SERVICE_LAUNCHER_CONTEXT: '{"protocol":2,"childVersion":"0.0.0"}'
run: node scripts/server-runtime-smoke.ts

# Everything except `t3` (apps/server). `--parallel` drops the package
# dependency ordering that `vp run` applies by default: these `test` tasks
# declare no `dependsOn` and resolve workspace deps from source, so ordering
Expand Down
4 changes: 2 additions & 2 deletions apps/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,8 @@
},
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.3.260",
"@effect/platform-bun": "catalog:",
"@effect/platform-node": "catalog:",
"@effect/platform-node-shared": "catalog:",
"@effect/sql-sqlite-bun": "catalog:",
"@ff-labs/fff-node": "0.9.4",
"@opencode-ai/sdk": "^1.3.15",
"effect": "catalog:",
Expand All @@ -38,6 +36,8 @@
"yauzl": "^3.4.0"
},
"devDependencies": {
"@effect/platform-bun": "catalog:",
"@effect/sql-sqlite-bun": "catalog:",
"@effect/vitest": "catalog:",
"@t3tools/contracts": "workspace:*",
"@t3tools/shared": "workspace:*",
Expand Down
51 changes: 51 additions & 0 deletions apps/server/scripts/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
resolveWebAssetBrandForPackageVersion,
resolveWebIconOverrides,
} from "../../../scripts/lib/brand-assets.ts";
import { findEagerBunRuntimeImports } from "../../../scripts/lib/cli-external-packages.ts";
import { resolveCatalogDependencies } from "../../../scripts/lib/resolve-catalog.ts";
import { fromJsonStringPretty } from "@t3tools/shared/schemaJson";
import { fromYaml } from "@t3tools/shared/schemaYaml";
Expand All @@ -24,6 +25,7 @@ import {
ServerCliBuildAssetMissingError,
ServerCliCommandExitError,
ServerCliDevelopmentIconSourceMissingError,
ServerCliEagerBunImportError,
ServerCliDevelopmentIconTargetMissingError,
ServerCliPublishIconSourceMissingError,
ServerCliPublishIconTargetMissingError,
Expand Down Expand Up @@ -140,6 +142,53 @@ const applyDevelopmentIconOverrides = Effect.fn("applyDevelopmentIconOverrides")
// build subcommand
// ---------------------------------------------------------------------------

/**
* Fail the build if a chunk Node loads eagerly imports a Bun module.
*
* `@effect/platform-bun` and `@effect/sql-sqlite-bun` are inlined so that a
* Bun-hosted server shares the bundle's single `effect` instance — two
* instances silently broke CORS, compression and auth headers, because Effect
* keys per-request pre-response handlers off a module-level WeakMap. Inlining
* them also pulls `bun:sqlite` into the bundle, which is only safe while it
* sits in a chunk reached solely through `import()`.
*
* Rolldown merges a dynamic import into its importer's chunk with only an
* INEFFECTIVE_DYNAMIC_IMPORT warning and exit 0, so read the artifact instead
* of trusting the build to fail. This runs on every PR through
* `vp run build:desktop`, unlike the desktop self-containment probe.
*/
const assertNoEagerBunImports = Effect.fn("assertNoEagerBunImports")(function* (distDir: string) {
const path = yield* Path.Path;
const fs = yield* FileSystem.FileSystem;

const chunks = new Map<string, string>();
for (const name of yield* fs.readDirectory(distDir)) {
if (!name.endsWith(".mjs")) continue;
chunks.set(name, yield* fs.readFileString(path.join(distDir, name)));
}

// Both entries are packed separately into the same flat directory, so each
// owns a graph that has to be walked.
const entryChunks = ["bin.mjs", "service-launcher.mjs"];
for (const entry of entryChunks) {
if (!chunks.has(entry)) {
return yield* new ServerCliBuildAssetMissingError({ assetPath: path.join(distDir, entry) });
}
}

const { reachable, violations } = findEagerBunRuntimeImports(chunks, entryChunks);
if (violations.length > 0) {
return yield* new ServerCliEagerBunImportError({
imports: violations.map(({ chunk, specifier }) => `${chunk} -> ${specifier}`),
eagerChunkCount: reachable.length,
});
}

yield* Effect.log(
`[cli] Verified ${reachable.length} eagerly loaded chunks import no Bun modules`,
);
});

const buildCmd = Command.make(
"build",
{
Expand All @@ -162,6 +211,8 @@ const buildCmd = Command.make(
}),
);

yield* assertNoEagerBunImports(path.join(serverDir, "dist"));

const webDist = path.join(repoRoot, "apps/web/dist");
const clientTarget = path.join(serverDir, "dist/client");

Expand Down
12 changes: 12 additions & 0 deletions apps/server/scripts/cliErrors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,3 +68,15 @@ export class ServerCliBuildAssetMissingError extends Schema.TaggedError<ServerCl
return `Missing build asset: ${this.assetPath}. Run the build subcommand first.`;
}
}

export class ServerCliEagerBunImportError extends Schema.TaggedError<ServerCliEagerBunImportError>()(
"ServerCliEagerBunImportError",
{
imports: Schema.Array(Schema.String),
eagerChunkCount: Schema.Int,
},
) {
override get message(): string {
return `The bundle imports Bun modules from chunks Node loads eagerly (${this.eagerChunkCount} scanned): ${this.imports.join(", ")}. Node cannot resolve a \`bun:\` specifier, so every \`node bin.mjs\` would fail with ERR_UNSUPPORTED_ESM_URL_SCHEME. @effect/platform-bun and @effect/sql-sqlite-bun are inlined so a Bun-hosted server shares one effect instance with the bundle; that requires every path into them to stay behind a runtime-conditional dynamic import. Something now imports one of them statically.`;
}
}
12 changes: 6 additions & 6 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 5 additions & 3 deletions scripts/build-desktop-artifact.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2109,9 +2109,11 @@ const verifyPackagedBundleIsSelfContained = Effect.fn("verifyPackagedBundleIsSel
// --version exercises the eagerly loaded module graph, which is where a
// missing dependency shows up, without starting a server or touching disk
// state. It does not cover lazily imported externals: node-pty is checked
// by the WSL preflight probe at runtime, while ffi-rs, @ff-labs/fff-node
// and the bun adapters are covered by the shared runtime-external closure
// and emitted-bundle checks.
// by the WSL preflight probe at runtime, while ffi-rs and @ff-labs/fff-node
// are covered by the shared runtime-external closure and the emitted-bundle
// checks above. The Bun adapters are not external at all any more, and this
// probe never takes the Bun branch; `assertNoEagerBunImports` in
// apps/server/scripts/cli.ts is what keeps them off the eager graph.
yield* runCommand(
ChildProcess.make(
process.execPath,
Expand Down
81 changes: 75 additions & 6 deletions scripts/lib/cli-external-packages.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import serverPackageJson from "../../apps/server/package.json" with { type: "jso

import {
CLI_RUNTIME_EXTERNAL_PREFIXES,
findEagerBunRuntimeImports,
findInlinedExternalPackages,
selectCliRuntimeExternalDependencies,
shouldBundleCliDependency,
Expand Down Expand Up @@ -55,9 +56,20 @@ describe("shouldBundleCliDependency", () => {
}
});

it("leaves bun-only entry points external", () => {
assert.strictEqual(shouldBundleCliDependency("@effect/platform-bun"), false);
assert.strictEqual(shouldBundleCliDependency("@effect/sql-sqlite-bun"), false);
// Externalizing these packages instead of the `bun:*` specifiers they import
// gave a Bun-hosted server a second `effect` beside the bundle, and Effect
// keys its per-request pre-response handlers off a module-level WeakMap. The
// bundled copy wrote handlers the platform server's copy never read, so CORS,
// gzip and auth headers silently vanished from real responses.
it("bundles the Bun platform packages so one effect instance serves requests", () => {
assert.strictEqual(shouldBundleCliDependency("@effect/platform-bun"), true);
assert.strictEqual(shouldBundleCliDependency("@effect/sql-sqlite-bun"), true);
});

it("leaves Bun's own runtime modules external", () => {
for (const id of ["bun", "bun:sqlite", "bun:ffi"]) {
assert.strictEqual(shouldBundleCliDependency(id), false, id);
}
});

// The real package is `node-gyp-build-optional-packages`, reached by prefix.
Expand All @@ -72,7 +84,7 @@ describe("selectCliRuntimeExternalDependencies", () => {
it("keeps only runtime-external dependency roots for the Windows sidecar", () => {
assert.deepStrictEqual(
selectCliRuntimeExternalDependencies({
"@effect/platform-bun": "1.0.0",
"@effect/platform-node": "1.0.0",
"@ff-labs/fff-node": "2.0.0",
effect: "3.0.0",
"node-pty": "4.0.0",
Expand Down Expand Up @@ -148,8 +160,6 @@ it.layer(NodeServices.layer)("external package dependency closure", (it) => {
return installed;
}).pipe(Effect.cached, Effect.runSync);

// Runtime-external only. The build-only entries resolve `bun:*` and are never
// loaded by Node, so their closure genuinely does not need to be external.
const isRuntimeExternal = (name: string) =>
CLI_RUNTIME_EXTERNAL_PREFIXES.some((prefix) => name.startsWith(prefix));

Expand Down Expand Up @@ -281,3 +291,62 @@ var x = 1;
assert.deepStrictEqual(result.inlined, []);
});
});

// The bundle now carries `bun:sqlite` itself. That only works while the chunk
// holding it is reached solely through `import()`; statically reachable, it
// kills every Node run with ERR_UNSUPPORTED_ESM_URL_SCHEME.
describe("findEagerBunRuntimeImports", () => {
const chunks = (entries: Record<string, string>) => new Map(Object.entries(entries));

it("allows a bun specifier behind a dynamic import", () => {
const result = findEagerBunRuntimeImports(
chunks({
"bin.mjs": `import { a } from "./shared-abc.mjs";
const client = await import("./SqliteClient-def.mjs");`,
"shared-abc.mjs": "export const a = 1;",
"SqliteClient-def.mjs": 'import { Database } from "bun:sqlite";',
}),
["bin.mjs"],
);

assert.deepStrictEqual(result.violations, []);
// The dynamically imported chunk must stay out of the eager graph, or the
// pass above would be vacuous for the wrong reason.
assert.deepStrictEqual(result.reachable, ["bin.mjs", "shared-abc.mjs"]);
});

it("flags a bun specifier a statically reachable chunk imports", () => {
const result = findEagerBunRuntimeImports(
chunks({
"bin.mjs": 'import { a } from "./shared-abc.mjs";',
"shared-abc.mjs": `import { Database } from "bun:sqlite";
export const a = Database;`,
}),
["bin.mjs"],
);

assert.deepStrictEqual(result.violations, [
{ chunk: "shared-abc.mjs", specifier: "bun:sqlite" },
]);
});

it("follows re-exports and bare imports, and flags the bun package too", () => {
const result = findEagerBunRuntimeImports(
chunks({
"bin.mjs": 'import "./side-effect.mjs";',
"side-effect.mjs": 'export * from "./redis-ghi.mjs";',
"redis-ghi.mjs": 'import { RedisClient } from "bun";',
}),
["bin.mjs"],
);

assert.deepStrictEqual(result.violations, [{ chunk: "redis-ghi.mjs", specifier: "bun" }]);
});

it("reports nothing reachable when the entry chunk is missing", () => {
const result = findEagerBunRuntimeImports(chunks({}), ["bin.mjs"]);

assert.deepStrictEqual(result.reachable, []);
assert.deepStrictEqual(result.violations, []);
});
});
Loading
Loading