| author | |
| committer | |
| log | dcdf5e6a013607b42836a270165286181da48296 |
| tree | ef12fef925cde0ab83b8870e7751227cafe8b94b |
| parent | ed6fe339d71863f39a1d5405554a3bac892e55b6 |
| signature | Signed by PGP key B5690EEEBB952194 |
4 files changed, 73 insertions(+), 39 deletions(-)
apps/server/scripts/cli.ts+1-1| ... | @@ -10,7 +10,7 @@ import { Command, Flag } from "effect/unstable/cli"; | ... | @@ -10,7 +10,7 @@ import { Command, Flag } from "effect/unstable/cli"; |
| 10 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; | 10 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; |
| 11 | 11 | ||
| 12 | import { DEVELOPMENT_ICON_OVERRIDES } from "../../../scripts/lib/brand-assets.ts"; | 12 | import { DEVELOPMENT_ICON_OVERRIDES } from "../../../scripts/lib/brand-assets.ts"; |
| 13 | import { findEsmImportsOfExternalPackages } from "../../../scripts/lib/cli-external-packages.ts"; | 13 | import { findEsmImportsOfExternalPackages } from "../../../scripts/lib/cli-executable-imports.ts"; |
| 14 | import { resolveSpawnCommand } from "@t3tools/shared/shell"; | 14 | import { resolveSpawnCommand } from "@t3tools/shared/shell"; |
| 15 | import { | 15 | import { |
| 16 | ServerCliBuildAssetMissingError, | 16 | ServerCliBuildAssetMissingError, |
scripts/lib/cli-executable-imports.ts created+49| ... | @@ -0,0 +1,49 @@ | ||
| 1 | // @effect-diagnostics nodeBuiltinImport:off | ||
| 2 | import * as NodeModule from "node:module"; | ||
| 3 | import ts from "typescript-legacy"; | ||
| 4 | |||
| 5 | /** | ||
| 6 | * Scan an emitted bundle chunk for ESM imports of packages that are not Node | ||
| 7 | * built-ins. | ||
| 8 | * | ||
| 9 | * Inside a Node single-executable, `import` statements and `import()` can only | ||
| 10 | * resolve built-in modules; any file-backed specifier throws at module | ||
| 11 | * evaluation (static) or at first use (dynamic). External packages therefore | ||
| 12 | * have to be reached through `createRequire`, which reads the real filesystem | ||
| 13 | * in every runtime. The bundler cannot enforce this, so the check reads what it | ||
| 14 | * produced. | ||
| 15 | */ | ||
| 16 | export function findEsmImportsOfExternalPackages(source: string): ReadonlyArray<string> { | ||
| 17 | const specifiers = new Set<string>(); | ||
| 18 | const module = ts.createSourceFile( | ||
| 19 | "bundle.mjs", | ||
| 20 | source, | ||
| 21 | ts.ScriptTarget.Latest, | ||
| 22 | false, | ||
| 23 | ts.ScriptKind.JS, | ||
| 24 | ); | ||
| 25 | const visit = (node: ts.Node): void => { | ||
| 26 | const dynamic = | ||
| 27 | ts.isCallExpression(node) && node.expression.kind === ts.SyntaxKind.ImportKeyword; | ||
| 28 | const specifierNode = | ||
| 29 | ts.isImportDeclaration(node) || ts.isExportDeclaration(node) | ||
| 30 | ? node.moduleSpecifier | ||
| 31 | : dynamic | ||
| 32 | ? node.arguments[0] | ||
| 33 | : undefined; | ||
| 34 | if (specifierNode && ts.isStringLiteralLike(specifierNode)) { | ||
| 35 | const specifier = specifierNode.text; | ||
| 36 | // Multi-runtime SDKs can retain optional Bun imports. Those are runtime | ||
| 37 | // built-ins, not packages to stage beside the executable. Static imports | ||
| 38 | // still fail here because Node would evaluate them unconditionally. | ||
| 39 | const runtimeBuiltin = | ||
| 40 | NodeModule.isBuiltin(specifier) || (dynamic && specifier.startsWith("bun:")); | ||
| 41 | if (!runtimeBuiltin && !specifier.startsWith("./") && !specifier.startsWith("../")) { | ||
| 42 | specifiers.add(specifier); | ||
| 43 | } | ||
| 44 | } | ||
| 45 | ts.forEachChild(node, visit); | ||
| 46 | }; | ||
| 47 | visit(module); | ||
| 48 | return [...specifiers].sort(); | ||
| 49 | } | ||
scripts/lib/cli-external-packages.test.ts+23-1| ... | @@ -9,9 +9,10 @@ import * as Schema from "effect/Schema"; | ... | @@ -9,9 +9,10 @@ import * as Schema from "effect/Schema"; |
| 9 | 9 | ||
| 10 | import serverPackageJson from "../../apps/server/package.json" with { type: "json" }; | 10 | import serverPackageJson from "../../apps/server/package.json" with { type: "json" }; |
| 11 | 11 | ||
| 12 | import { findEsmImportsOfExternalPackages } from "./cli-executable-imports.ts"; | ||
| 13 | |||
| 12 | import { | 14 | import { |
| 13 | CLI_RUNTIME_EXTERNAL_PREFIXES, | 15 | CLI_RUNTIME_EXTERNAL_PREFIXES, |
| 14 | findEsmImportsOfExternalPackages, | ||
| 15 | findInlinedExternalPackages, | 16 | findInlinedExternalPackages, |
| 16 | selectCliRuntimeExternalDependencies, | 17 | selectCliRuntimeExternalDependencies, |
| 17 | shouldBundleCliDependency, | 18 | shouldBundleCliDependency, |
| ... | @@ -310,6 +311,27 @@ describe("findEsmImportsOfExternalPackages", () => { | ... | @@ -310,6 +311,27 @@ describe("findEsmImportsOfExternalPackages", () => { |
| 310 | ]); | 311 | ]); |
| 311 | }); | 312 | }); |
| 312 | 313 | ||
| 314 | it("ignores imports inside generated extension source and comments", () => { | ||
| 315 | const source = [ | ||
| 316 | 'const extension = `import { Type } from "typebox";\nimport type { ExtensionAPI } from "@earendil-works/pi-coding-agent";`;', | ||
| 317 | '// import "comment-only";', | ||
| 318 | "const example = 'import(\"string-only\")';", | ||
| 319 | 'const interpolated = `source ${import("real-package")}`;', | ||
| 320 | ].join("\n"); | ||
| 321 | assert.deepStrictEqual(findEsmImportsOfExternalPackages(source), ["real-package"]); | ||
| 322 | }); | ||
| 323 | |||
| 324 | it("allows optional dynamic Bun built-ins but rejects static imports", () => { | ||
| 325 | assert.deepStrictEqual( | ||
| 326 | findEsmImportsOfExternalPackages('const load = () => import("bun:sqlite");'), | ||
| 327 | [], | ||
| 328 | ); | ||
| 329 | assert.deepStrictEqual( | ||
| 330 | findEsmImportsOfExternalPackages('import { Database } from "bun:sqlite";'), | ||
| 331 | ["bun:sqlite"], | ||
| 332 | ); | ||
| 333 | }); | ||
| 334 | |||
| 313 | it("does not mistake createRequire calls for imports", () => { | 335 | it("does not mistake createRequire calls for imports", () => { |
| 314 | const source = 'const { FileFinder } = createRequire(import.meta.url)("@ff-labs/fff-node");'; | 336 | const source = 'const { FileFinder } = createRequire(import.meta.url)("@ff-labs/fff-node");'; |
| 315 | assert.deepStrictEqual(findEsmImportsOfExternalPackages(source), []); | 337 | assert.deepStrictEqual(findEsmImportsOfExternalPackages(source), []); |
scripts/lib/cli-external-packages.ts-37| ... | @@ -1,6 +1,3 @@ | ... | @@ -1,6 +1,3 @@ |
| 1 | // @effect-diagnostics nodeBuiltinImport:off | ||
| 2 | import * as NodeModule from "node:module"; | ||
| 3 | |||
| 4 | /** | 1 | /** |
| 5 | * The single source of truth for packages the server CLI bundle must NOT inline. | 2 | * The single source of truth for packages the server CLI bundle must NOT inline. |
| 6 | * | 3 | * |
| ... | @@ -81,40 +78,6 @@ export function selectCliRuntimeExternalDependencies( | ... | @@ -81,40 +78,6 @@ export function selectCliRuntimeExternalDependencies( |
| 81 | ); | 78 | ); |
| 82 | } | 79 | } |
| 83 | 80 | ||
| 84 | /** | ||
| 85 | * Scan an emitted bundle chunk for ESM imports of packages that are not Node | ||
| 86 | * built-ins. | ||
| 87 | * | ||
| 88 | * Inside a Node single-executable, `import` statements and `import()` can only | ||
| 89 | * resolve built-in modules; any file-backed specifier throws at module | ||
| 90 | * evaluation (static) or at first use (dynamic). External packages therefore | ||
| 91 | * have to be reached through `createRequire`, which reads the real filesystem | ||
| 92 | * in every runtime. The bundler cannot enforce this, so the check reads what it | ||
| 93 | * produced. | ||
| 94 | */ | ||
| 95 | export function findEsmImportsOfExternalPackages(source: string): ReadonlyArray<string> { | ||
| 96 | const specifiers = new Set<string>(); | ||
| 97 | // `import x from`, `import "side-effect"`, `export ... from`, and `import()` | ||
| 98 | // all resolve through the module loader. | ||
| 99 | const patterns = [ | ||
| 100 | /^import\s[^;]*?\sfrom\s+["']([^"']+)["']/gm, | ||
| 101 | /^import\s+["']([^"']+)["']/gm, | ||
| 102 | /^export\s[^;]*?\sfrom\s+["']([^"']+)["']/gm, | ||
| 103 | // Rolldown may leave a `/* @vite-ignore */` style comment before the specifier. | ||
| 104 | /\bimport\(\s*(?:\/\*[\s\S]*?\*\/\s*)*["']([^"']+)["']\s*[,)]/g, | ||
| 105 | ]; | ||
| 106 | for (const pattern of patterns) { | ||
| 107 | for (const match of source.matchAll(pattern)) { | ||
| 108 | const specifier = match[1]; | ||
| 109 | if (specifier === undefined) continue; | ||
| 110 | if (NodeModule.isBuiltin(specifier)) continue; | ||
| 111 | if (specifier.startsWith("./") || specifier.startsWith("../")) continue; | ||
| 112 | specifiers.add(specifier); | ||
| 113 | } | ||
| 114 | } | ||
| 115 | return [...specifiers].sort(); | ||
| 116 | } | ||
| 117 | |||
| 118 | /** | 81 | /** |
| 119 | * Scan an emitted bundle chunk for runtime-external packages that were inlined. | 82 | * Scan an emitted bundle chunk for runtime-external packages that were inlined. |
| 120 | * | 83 | * |