diff --git a/apps/server/scripts/cli.ts b/apps/server/scripts/cli.ts index 58c3ad7b3d01312d6ae536f93e2a3db58f6ee0d6..319440a7ec247cbae38b20f51c8ba071833a56cc 100644 --- a/apps/server/scripts/cli.ts +++ b/apps/server/scripts/cli.ts @@ -10,7 +10,7 @@ import { Command, Flag } from "effect/unstable/cli"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { DEVELOPMENT_ICON_OVERRIDES } from "../../../scripts/lib/brand-assets.ts"; -import { findEsmImportsOfExternalPackages } from "../../../scripts/lib/cli-external-packages.ts"; +import { findEsmImportsOfExternalPackages } from "../../../scripts/lib/cli-executable-imports.ts"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; import { ServerCliBuildAssetMissingError, diff --git a/scripts/lib/cli-executable-imports.ts b/scripts/lib/cli-executable-imports.ts new file mode 100644 index 0000000000000000000000000000000000000000..3391445beb2a5d67ebb3edce3f2307637605d3fe --- /dev/null +++ b/scripts/lib/cli-executable-imports.ts @@ -0,0 +1,49 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeModule from "node:module"; +import ts from "typescript-legacy"; + +/** + * Scan an emitted bundle chunk for ESM imports of packages that are not Node + * built-ins. + * + * Inside a Node single-executable, `import` statements and `import()` can only + * resolve built-in modules; any file-backed specifier throws at module + * evaluation (static) or at first use (dynamic). External packages therefore + * have to be reached through `createRequire`, which reads the real filesystem + * in every runtime. The bundler cannot enforce this, so the check reads what it + * produced. + */ +export function findEsmImportsOfExternalPackages(source: string): ReadonlyArray { + const specifiers = new Set(); + const module = ts.createSourceFile( + "bundle.mjs", + source, + ts.ScriptTarget.Latest, + false, + ts.ScriptKind.JS, + ); + const visit = (node: ts.Node): void => { + const dynamic = + ts.isCallExpression(node) && node.expression.kind === ts.SyntaxKind.ImportKeyword; + const specifierNode = + ts.isImportDeclaration(node) || ts.isExportDeclaration(node) + ? node.moduleSpecifier + : dynamic + ? node.arguments[0] + : undefined; + if (specifierNode && ts.isStringLiteralLike(specifierNode)) { + const specifier = specifierNode.text; + // Multi-runtime SDKs can retain optional Bun imports. Those are runtime + // built-ins, not packages to stage beside the executable. Static imports + // still fail here because Node would evaluate them unconditionally. + const runtimeBuiltin = + NodeModule.isBuiltin(specifier) || (dynamic && specifier.startsWith("bun:")); + if (!runtimeBuiltin && !specifier.startsWith("./") && !specifier.startsWith("../")) { + specifiers.add(specifier); + } + } + ts.forEachChild(node, visit); + }; + visit(module); + return [...specifiers].sort(); +} diff --git a/scripts/lib/cli-external-packages.test.ts b/scripts/lib/cli-external-packages.test.ts index d9954d0eef4a012e00c6ce19e879c9b1d92ee1f8..ebcc81020bfe62fda2ac129fdf932507d7a280ac 100644 --- a/scripts/lib/cli-external-packages.test.ts +++ b/scripts/lib/cli-external-packages.test.ts @@ -9,9 +9,10 @@ import * as Schema from "effect/Schema"; import serverPackageJson from "../../apps/server/package.json" with { type: "json" }; +import { findEsmImportsOfExternalPackages } from "./cli-executable-imports.ts"; + import { CLI_RUNTIME_EXTERNAL_PREFIXES, - findEsmImportsOfExternalPackages, findInlinedExternalPackages, selectCliRuntimeExternalDependencies, shouldBundleCliDependency, @@ -310,6 +311,27 @@ describe("findEsmImportsOfExternalPackages", () => { ]); }); + it("ignores imports inside generated extension source and comments", () => { + const source = [ + 'const extension = `import { Type } from "typebox";\nimport type { ExtensionAPI } from "@earendil-works/pi-coding-agent";`;', + '// import "comment-only";', + "const example = 'import(\"string-only\")';", + 'const interpolated = `source ${import("real-package")}`;', + ].join("\n"); + assert.deepStrictEqual(findEsmImportsOfExternalPackages(source), ["real-package"]); + }); + + it("allows optional dynamic Bun built-ins but rejects static imports", () => { + assert.deepStrictEqual( + findEsmImportsOfExternalPackages('const load = () => import("bun:sqlite");'), + [], + ); + assert.deepStrictEqual( + findEsmImportsOfExternalPackages('import { Database } from "bun:sqlite";'), + ["bun:sqlite"], + ); + }); + it("does not mistake createRequire calls for imports", () => { const source = 'const { FileFinder } = createRequire(import.meta.url)("@ff-labs/fff-node");'; assert.deepStrictEqual(findEsmImportsOfExternalPackages(source), []); diff --git a/scripts/lib/cli-external-packages.ts b/scripts/lib/cli-external-packages.ts index 5acffdfd30032d2d611d6fa4e29cf8c690469cca..5cafee96067572ba0c60e744fdd0c7fc1c5b8c58 100644 --- a/scripts/lib/cli-external-packages.ts +++ b/scripts/lib/cli-external-packages.ts @@ -1,6 +1,3 @@ -// @effect-diagnostics nodeBuiltinImport:off -import * as NodeModule from "node:module"; - /** * The single source of truth for packages the server CLI bundle must NOT inline. * @@ -81,40 +78,6 @@ export function selectCliRuntimeExternalDependencies( ); } -/** - * Scan an emitted bundle chunk for ESM imports of packages that are not Node - * built-ins. - * - * Inside a Node single-executable, `import` statements and `import()` can only - * resolve built-in modules; any file-backed specifier throws at module - * evaluation (static) or at first use (dynamic). External packages therefore - * have to be reached through `createRequire`, which reads the real filesystem - * in every runtime. The bundler cannot enforce this, so the check reads what it - * produced. - */ -export function findEsmImportsOfExternalPackages(source: string): ReadonlyArray { - const specifiers = new Set(); - // `import x from`, `import "side-effect"`, `export ... from`, and `import()` - // all resolve through the module loader. - const patterns = [ - /^import\s[^;]*?\sfrom\s+["']([^"']+)["']/gm, - /^import\s+["']([^"']+)["']/gm, - /^export\s[^;]*?\sfrom\s+["']([^"']+)["']/gm, - // Rolldown may leave a `/* @vite-ignore */` style comment before the specifier. - /\bimport\(\s*(?:\/\*[\s\S]*?\*\/\s*)*["']([^"']+)["']\s*[,)]/g, - ]; - for (const pattern of patterns) { - for (const match of source.matchAll(pattern)) { - const specifier = match[1]; - if (specifier === undefined) continue; - if (NodeModule.isBuiltin(specifier)) continue; - if (specifier.startsWith("./") || specifier.startsWith("../")) continue; - specifiers.add(specifier); - } - } - return [...specifiers].sort(); -} - /** * Scan an emitted bundle chunk for runtime-external packages that were inlined. *