| 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 | 10 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; |
| 11 | 11 | |
| 12 | 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 | 14 | import { resolveSpawnCommand } from "@t3tools/shared/shell"; |
| 15 | 15 | import { |
| 16 | 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 | |
| 10 | 10 | import serverPackageJson from "../../apps/server/package.json" with { type: "json" }; |
| 11 | 11 | |
| 12 | import { findEsmImportsOfExternalPackages } from "./cli-executable-imports.ts"; | |
| 13 | ||
| 12 | 14 | import { |
| 13 | 15 | CLI_RUNTIME_EXTERNAL_PREFIXES, |
| 14 | findEsmImportsOfExternalPackages, | |
| 15 | 16 | findInlinedExternalPackages, |
| 16 | 17 | selectCliRuntimeExternalDependencies, |
| 17 | 18 | shouldBundleCliDependency, |
| ... | ... | @@ -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 | 335 | it("does not mistake createRequire calls for imports", () => { |
| 314 | 336 | const source = 'const { FileFinder } = createRequire(import.meta.url)("@ff-labs/fff-node");'; |
| 315 | 337 | assert.deepStrictEqual(findEsmImportsOfExternalPackages(source), []); |
scripts/lib/cli-external-packages.ts-37| ... | ... | @@ -1,6 +1,3 @@ |
| 1 | // @effect-diagnostics nodeBuiltinImport:off | |
| 2 | import * as NodeModule from "node:module"; | |
| 3 | ||
| 4 | 1 | /** |
| 5 | 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 | 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 | 82 | * Scan an emitted bundle chunk for runtime-external packages that were inlined. |
| 120 | 83 | * |