Compile the packages an import()-loaded module requires; compile TypeScript enums in packages (#1517)

This commit is contained in:
Chris Tate
2026-10-20 08:55:04 -05:00
committed by GitHub
21 changed files with 641 additions and 40 deletions
@@ -156,6 +156,49 @@ export interface FileParts {
topStmts: ts.Statement[];
}
/** Preflight's module graph (LoadResult.moduleEdges), for the walks that
* append modules outside the startup order, and the set collecting the
* inline require() calls that close a cycle there. */
export interface AppendGraph {
edges?: ReadonlyMap<ts.SourceFile, readonly CycleEdge[]> | undefined;
inlineCycleCalls?: Set<ts.Node>;
}
/** An appended module's edges: its import and export declarations, then
* the require() edges preflight resolved — a CommonJS file's only edges
* (the compiled CommonJS package a workspace module loaded on demand
* imports, and the sibling packages and files IT requires). */
function appendedEdgesOf(
program: ts.Program,
graph: AppendGraph | undefined,
): (sf: ts.SourceFile) => CycleEdge[] {
const cache = new Map<ts.SourceFile, CycleEdge[]>();
return (sf) => {
let out = cache.get(sf);
if (out !== undefined) return out;
out = orderedImportsOf(program, sf).flatMap(({ stmt, dep }) =>
dep !== null && dep !== sf
? [{ dep, stmt: stmt as ts.ImportDeclaration | ts.ExportDeclaration }]
: [],
);
for (const e of graph?.edges?.get(sf) ?? [])
if (e.stmt === undefined && e.dep !== sf) out.push(e);
cache.set(sf, out);
return out;
};
}
/** Lazy inline requires run after their module's top level: their modules
* join the order afterwards, as in preflight's walk. */
function visitLazyEdges(
sf: ts.SourceFile,
edgesOf: (sf: ts.SourceFile) => CycleEdge[],
state: ReadonlyMap<ts.SourceFile, "visiting" | "done">,
visit: (sf: ts.SourceFile) => void,
): void {
for (const e of edgesOf(sf)) if (e.lazy === true && state.get(e.dep) === undefined) visit(e.dep);
}
/** Program modules selected by statically resolved child_process.fork()
* calls join the compiled graph without becoming startup dependencies of
* the parent entry. Their guarded init functions are selected by %main only
@@ -166,8 +209,9 @@ export function appendForkModules(
order: ts.SourceFile[],
targets: ts.SourceFile[],
onCycle: (cycle: string, reason: string) => void,
graph?: AppendGraph,
): void {
appendEntryModules(program, order, targets, forkTargetPaths(program, order), onCycle);
appendEntryModules(program, order, targets, forkTargetPaths(program, order), onCycle, graph);
}
export function appendWorkerModules(
@@ -175,8 +219,9 @@ export function appendWorkerModules(
order: ts.SourceFile[],
targets: ts.SourceFile[],
onCycle: (cycle: string, reason: string) => void,
graph?: AppendGraph,
): void {
appendEntryModules(program, order, targets, workerTargetPaths(program, order), onCycle);
appendEntryModules(program, order, targets, workerTargetPaths(program, order), onCycle, graph);
}
function appendEntryModules(
@@ -185,6 +230,7 @@ function appendEntryModules(
targets: ts.SourceFile[],
paths: readonly string[],
onCycle: (cycle: string, reason: string) => void,
graph?: AppendGraph,
): void {
if (order.length === 0) return;
const byPath = new Map(
@@ -194,22 +240,24 @@ function appendEntryModules(
for (const sf of order) state.set(sf, "done");
const added: ts.SourceFile[] = [];
const stack: string[] = [];
const staticEdgesOf = (sf: ts.SourceFile): CycleEdge[] =>
orderedImportsOf(program, sf).flatMap(({ stmt, dep }) =>
dep !== null && dep !== sf
? [{ dep, stmt: stmt as ts.ImportDeclaration | ts.ExportDeclaration }]
: [],
);
const cycleAdmissionReason = makeCycleAdmission(program, staticEdgesOf);
const staticEdgesOf = appendedEdgesOf(program, graph);
const cycleAdmissionReason = makeCycleAdmission(program, (sf) =>
staticEdgesOf(sf).filter((e) => e.call === undefined),
);
const visit = (sf: ts.SourceFile): void => {
const prior = state.get(sf);
if (prior === "done") return;
state.set(sf, "visiting");
stack.push(sf.fileName);
for (const edge of staticEdgesOf(sf)) {
if (edge.lazy === true) continue; // visited after this module completes
const edgeState = state.get(edge.dep);
if (edgeState === "done") continue;
if (edgeState === "visiting") {
if (edge.call !== undefined) {
graph?.inlineCycleCalls?.add(edge.call);
continue;
}
const reason = cycleAdmissionReason(sf, edge);
if (reason !== null) {
const cycleStart = stack.indexOf(edge.dep.fileName);
@@ -222,6 +270,7 @@ function appendEntryModules(
stack.pop();
state.set(sf, "done");
added.push(sf);
visitLazyEdges(sf, staticEdgesOf, state, visit);
};
const knownTargets = new Set(targets);
@@ -313,27 +362,30 @@ export function appendDynamicImportModules(
program: ts.Program,
order: ts.SourceFile[],
onCycle: (cycle: string, reason: string) => void,
graph?: AppendGraph,
): void {
if (order.length === 0) return; // no preflight order — sites keep their fences
const state = new Map<ts.SourceFile, "visiting" | "done">();
for (const sf of order) state.set(sf, "done");
const added: ts.SourceFile[] = [];
const stack: string[] = [];
const staticEdgesOf = (sf: ts.SourceFile): CycleEdge[] =>
orderedImportsOf(program, sf).flatMap(({ stmt, dep }) =>
dep !== null && dep !== sf
? [{ dep, stmt: stmt as ts.ImportDeclaration | ts.ExportDeclaration }]
: [],
);
const cycleAdmissionReason = makeCycleAdmission(program, staticEdgesOf);
const staticEdgesOf = appendedEdgesOf(program, graph);
const cycleAdmissionReason = makeCycleAdmission(program, (sf) =>
staticEdgesOf(sf).filter((e) => e.call === undefined),
);
const visit = (sf: ts.SourceFile): void => {
if (state.get(sf) !== undefined) return;
state.set(sf, "visiting");
stack.push(sf.fileName);
for (const e of staticEdgesOf(sf)) {
if (e.lazy === true) continue; // visited after this module completes
const s = state.get(e.dep);
if (s === "done") continue;
if (s === "visiting") {
if (e.call !== undefined) {
graph?.inlineCycleCalls?.add(e.call);
continue;
}
// A back edge: Node answers it from the cache. Same admission
// question as preflight's static walk, same fence when refused.
const reason = cycleAdmissionReason(sf, e);
@@ -348,6 +400,7 @@ export function appendDynamicImportModules(
stack.pop();
state.set(sf, "done");
added.push(sf);
visitLazyEdges(sf, staticEdgesOf, state, visit);
};
const scanned = new Set<ts.SourceFile>();
for (;;) {
@@ -233,6 +233,7 @@ import {
requireSpecOf,
resolveImport,
workspacePackageOfPath,
type CycleEdge,
} from "../program.js";
import {
containsRecord,
@@ -784,6 +785,8 @@ export interface LowerOptions {
startupCrash?: StartupCrash | null;
/** LoadResult.inlineRequireCycleCalls. */
inlineRequireCycleCalls?: ReadonlySet<ts.Node> | undefined;
/** LoadResult.moduleEdges. */
moduleEdges?: ReadonlyMap<ts.SourceFile, readonly CycleEdge[]> | undefined;
/** LIBRARY mode's reachability roots: the profile-mapped exports of the
* entry module. Executable builds root at the entry's top level alone
* (an unreferenced export dead-strips); a library's exports are called from
@@ -972,11 +975,15 @@ export function lowerToIr(
),
);
};
// Inline require() calls closing a cycle inside the appended subgraphs
// join preflight's set (the same run-time refusal by name).
const inlineRequireCycleCalls = new Set<ts.Node>(options.inlineRequireCycleCalls ?? []);
const graph = { edges: options.moduleEdges, inlineCycleCalls: inlineRequireCycleCalls };
for (let pass = 0; pass < 32; pass++) {
const before = moduleOrder.length;
appendDynamicImportModules(program, moduleOrder, onExtraCycle);
appendForkModules(program, moduleOrder, forkTargets, onExtraCycle);
appendWorkerModules(program, moduleOrder, workerTargets, onExtraCycle);
appendDynamicImportModules(program, moduleOrder, onExtraCycle, graph);
appendForkModules(program, moduleOrder, forkTargets, onExtraCycle, graph);
appendWorkerModules(program, moduleOrder, workerTargets, onExtraCycle, graph);
if (moduleOrder.length === before) break;
if (pass === 31) throw new Error("dynamic import and fork module discovery did not converge");
}
@@ -991,7 +998,7 @@ export function lowerToIr(
projectCheckJs: options.projectCheckJs ?? false,
targetPlatform,
startupCrash,
inlineRequireCycleCalls: options.inlineRequireCycleCalls,
inlineRequireCycleCalls,
ffiImports,
libraryCallbacks,
forkTargets,
@@ -1015,7 +1022,7 @@ export function lowerToIr(
projectCheckJs: options.projectCheckJs ?? false,
targetPlatform,
startupCrash,
inlineRequireCycleCalls: options.inlineRequireCycleCalls,
inlineRequireCycleCalls,
ffiImports,
libraryCallbacks,
forkTargets,
@@ -1046,7 +1053,7 @@ export function lowerToIr(
reachable,
targetPlatform,
startupCrash,
inlineRequireCycleCalls: options.inlineRequireCycleCalls,
inlineRequireCycleCalls,
ffiImports,
libraryCallbacks,
forkTargets,
@@ -1105,7 +1112,7 @@ export function lowerToIr(
reachable,
remainder: true,
alreadyFlushed: resultLowerer.flushedSymbols,
inlineRequireCycleCalls: options.inlineRequireCycleCalls,
inlineRequireCycleCalls,
targetPlatform,
ffiImports,
libraryCallbacks,
@@ -0,0 +1,135 @@
import { spawnSync } from "node:child_process";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterAll, expect, test } from "vitest";
import { nodeFrontendServices } from "./services-node.js";
const dirs: string[] = [];
afterAll(() => {
for (const dir of dirs) rmSync(dir, { recursive: true, force: true });
});
function rewrite(source: string): string | null {
return nodeFrontendServices().enumIifes(source, "/pkg/index.js");
}
function run(file: string): string {
const result = spawnSync(process.execPath, [file], { encoding: "utf8" });
expect(result.status).toBe(0);
return result.stdout;
}
const probe = `
for (const name of Object.keys(module.exports)) {
const v = module.exports[name];
console.log(name, JSON.stringify(v), Object.keys(v ?? {}).join(","));
}
`;
/** The rewritten module exports what the original exports, keys in the
* same order. */
function expectSameExports(source: string): string {
const result = rewrite(source);
expect(typeof result).toBe("string");
const dir = mkdtempSync(join(tmpdir(), "scriptc-enum-iife-"));
dirs.push(dir);
const original = join(dir, "original.cjs");
const rewritten = join(dir, "rewritten.cjs");
writeFileSync(original, source + probe);
writeFileSync(rewritten, result! + probe);
expect(run(rewritten)).toBe(run(original));
// Line positions survive the respelling.
expect(result!.split("\n").length).toBe(source.split("\n").length);
return result!;
}
test("a TypeScript 5 exported string enum becomes its object literal", () => {
const out = expectSameExports(`"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.Kind = void 0;
var Kind;
(function (Kind) {
Kind["Identifier"] = "Identifier";
Kind["Literal"] = "Literal";
})(Kind || (exports.Kind = Kind = {}));
`);
expect(out).toContain(`var Kind = { "Identifier": "Identifier", "Literal": "Literal" };`);
expect(out).toContain("exports.Kind = Kind;");
});
test("numeric enums keep their reverse mapping and Node's key order", () => {
const out = expectSameExports(`"use strict";
var Level;
(function (Level) {
Level[Level["High"] = 2] = "High";
Level[Level["Low"] = 0] = "Low";
Level[Level["Below"] = -1] = "Below";
Level[Level["Also"] = 2] = "Also";
})(Level = exports.Level || (exports.Level = {}));
`);
expect(out).toContain(
`var Level = { 0: "Low", 2: "Also", "High": 2, "Low": 0, "Below": -1, "-1": "Below", "Also": 2 };`,
);
});
test("an ES module's exported enum keeps its export", () => {
const source = `export var Kind;
(function (Kind) {
Kind["Identifier"] = "Identifier";
Kind[Kind["Zero"] = 0] = "Zero";
})(Kind || (Kind = {}));
`;
const out = rewrite(source);
expect(out).toContain(`export var Kind = { 0: "Zero", "Identifier": "Identifier", "Zero": 0 };`);
const dir = mkdtempSync(join(tmpdir(), "scriptc-enum-iife-"));
dirs.push(dir);
writeFileSync(join(dir, "original.mjs"), source);
writeFileSync(join(dir, "rewritten.mjs"), out!);
const reader = (file: string) =>
`import { Kind } from "./${file}"; console.log(JSON.stringify(Kind), Object.keys(Kind).join(","));`;
writeFileSync(join(dir, "read-original.mjs"), reader("original.mjs"));
writeFileSync(join(dir, "read-rewritten.mjs"), reader("rewritten.mjs"));
expect(run(join(dir, "read-rewritten.mjs"))).toBe(run(join(dir, "read-original.mjs")));
expect(out!.split("\n").length).toBe(source.split("\n").length);
});
test("an enum the module does not export becomes a local object", () => {
const out = expectSameExports(`"use strict";
var Mode;
(function (Mode) {
Mode.Fast = "fast";
})(Mode || (Mode = {}));
exports.mode = Mode.Fast;
`);
expect(out).toContain(`var Mode = { "Fast": "fast" };`);
});
test("merged, computed and otherwise written enums keep their text", () => {
// A second declaration merges into the first.
expect(
rewrite(`var E;
(function (E) { E["A"] = "a"; })(E || (E = {}));
(function (E) { E["B"] = "b"; })(E || (E = {}));
`),
).toBeNull();
// A member referencing another member.
expect(
rewrite(`var F;
(function (F) { F[F["A"] = 1] = "A"; F[F["B"] = F.A << 1] = "B"; })(F || (exports.F = F = {}));
`),
).toBeNull();
// A statement other than a member write.
expect(
rewrite(`var G;
(function (G) { G["A"] = "a"; console.log("side effect"); })(G || (exports.G = G = {}));
`),
).toBeNull();
// The variable is reassigned later.
expect(
rewrite(`var H;
(function (H) { H["A"] = "a"; })(H || (exports.H = H = {}));
H = null;
`),
).toBeNull();
});
@@ -0,0 +1,284 @@
/* TypeScript's enum emit in a compiled package: a bare `var` filled by an
* immediately invoked function that writes each member onto its argument.
*
* var Kind;
* (function (Kind) {
* Kind["Identifier"] = "Identifier";
* Kind[Kind["Zero"] = 0] = "Zero";
* })(Kind || (exports.Kind = Kind = {}));
*
* Inference types the variable from the empty object the argument creates,
* so every member read lands on `{}`. When every member value is a literal,
* the enum is that object literal: the declaration becomes
* `var Kind = { ... };` and the call becomes the export assignment it
* performs. Keys keep Node's own-property order (integer keys ascending,
* then the rest in insertion order, a later write replacing an earlier
* value in place), so enumeration observes the same object.
*
* Accepted argument spellings: `Kind || (exports.Kind = Kind = {})`
* (TypeScript 5), `Kind = exports.Kind || (exports.Kind = {})` (earlier
* releases) and `Kind || (Kind = {})` (an enum the module does not
* export). Merged declarations, computed or referencing members, and any
* other write to the variable keep the original text. */
import * as ts from "./ts7/syntax.js";
import { applyEdits, unparen, type Edit } from "./npm-static-bundled-chunks.js";
/** True when the text may hold an enum IIFE (a cheap probe). */
export function mayHoldEnumIife(source: string): boolean {
return source.includes("(function (") && source.includes("|| (");
}
/** The module text with each literal-valued enum IIFE respelled as an
* object literal, or null when there is none. */
export function rewriteEnumIifes(sf: ts.SourceFile): string | null {
const stmts = sf.statements.nodes;
const edits: Edit[] = [];
for (let i = 1; i < stmts.length; i++) {
const decl = bareVarName(stmts[i - 1]!);
if (decl === null) continue;
const iife = enumIife(stmts[i]!, decl);
if (iife === null || writtenElsewhere(sf, decl, stmts[i]!)) continue;
// Modifiers before `var` stay: `export var Kind;` (an ES module's
// enum) keeps exporting the binding.
const declaration = stmts[i - 1] as ts.VariableStatement;
const modifiers = sf.text.slice(
declaration.getStart(sf),
declaration.declarationList.getStart(sf),
);
edits.push({
start: declaration.getStart(sf),
end: declaration.getEnd(),
text: `${modifiers}var ${decl} = ${objectLiteral(iife.members)};`,
});
edits.push({
start: stmts[i]!.getStart(sf),
end: stmts[i]!.getEnd(),
text: iife.exportName === null ? "" : `exports.${iife.exportName} = ${decl};`,
});
}
if (edits.length === 0) return null;
return applyEdits(sf.text, 0, sf.text.length, edits);
}
/** `var Name;`: one declarator, no initializer. */
function bareVarName(stmt: ts.Statement): string | null {
if (!ts.isVariableStatement(stmt)) return null;
const list = stmt.declarationList;
if ((list.flags & (ts.NodeFlags.Let | ts.NodeFlags.Const)) !== 0) return null;
const decls = list.declarations.nodes;
if (decls.length !== 1) return null;
const d = decls[0]!;
if (d.initializer !== undefined || !ts.isIdentifier(d.name)) return null;
return d.name.text;
}
type Members = Map<string, string | number>;
function enumIife(
stmt: ts.Statement,
name: string,
): { members: Members; exportName: string | null } | null {
if (!ts.isExpressionStatement(stmt)) return null;
const call = unparen(stmt.expression);
if (!ts.isCallExpression(call) || call.arguments.nodes.length !== 1) return null;
const fn = unparen(call.expression);
if (!ts.isFunctionExpression(fn) || fn.name !== undefined || fn.asteriskToken !== undefined)
return null;
if (fn.modifiers !== undefined && fn.modifiers.nodes.length > 0) return null;
const params = fn.parameters.nodes;
if (params.length !== 1) return null;
const param = params[0]!;
if (!ts.isIdentifier(param.name) || param.initializer !== undefined) return null;
if (param.dotDotDotToken !== undefined) return null;
const exportName = enumArgument(call.arguments.nodes[0]!, name);
if (exportName === undefined) return null;
const members = enumMembers(fn.body.statements.nodes, param.name.text);
return members === null ? null : { members, exportName };
}
/** The export name the argument assigns (null: not exported), or
* undefined when it is not one of the enum spellings. */
function enumArgument(arg: ts.Expression, name: string): string | null | undefined {
const e = unparen(arg);
if (!ts.isBinaryExpression(e)) return undefined;
// `Kind = exports.Kind || (exports.Kind = {})`
if (e.operatorToken.kind === ts.SyntaxKind.EqualsToken) {
if (!isName(e.left, name)) return undefined;
const or = unparen(e.right);
if (!ts.isBinaryExpression(or) || or.operatorToken.kind !== ts.SyntaxKind.BarBarToken)
return undefined;
const exported = exportsMember(or.left);
if (exported === null) return undefined;
const init = unparen(or.right);
if (
!ts.isBinaryExpression(init) ||
init.operatorToken.kind !== ts.SyntaxKind.EqualsToken ||
exportsMember(init.left) !== exported ||
!isEmptyObject(init.right)
)
return undefined;
return exported;
}
if (e.operatorToken.kind !== ts.SyntaxKind.BarBarToken || !isName(e.left, name)) return undefined;
const init = unparen(e.right);
if (!ts.isBinaryExpression(init) || init.operatorToken.kind !== ts.SyntaxKind.EqualsToken)
return undefined;
// `Kind || (Kind = {})`
if (isName(init.left, name)) return isEmptyObject(init.right) ? null : undefined;
// `Kind || (exports.Kind = Kind = {})`
const exported = exportsMember(init.left);
const inner = unparen(init.right);
if (
exported === null ||
!ts.isBinaryExpression(inner) ||
inner.operatorToken.kind !== ts.SyntaxKind.EqualsToken ||
!isName(inner.left, name) ||
!isEmptyObject(inner.right)
)
return undefined;
return exported;
}
/** The members the body writes, or null when a statement is anything but
* a literal member write (`E["K"] = v`, `E.K = v`, or the reverse-mapped
* `E[E["K"] = v] = "K"`). */
function enumMembers(body: readonly ts.Statement[], param: string): Members | null {
const members: Members = new Map();
for (const stmt of body) {
if (!ts.isExpressionStatement(stmt)) return null;
const e = stmt.expression;
if (!ts.isBinaryExpression(e) || e.operatorToken.kind !== ts.SyntaxKind.EqualsToken)
return null;
const value = literalValue(e.right);
if (value === null) return null;
const key = memberKey(e.left, param);
if (key !== null) {
members.set(key, value);
continue;
}
// E[E["K"] = v] = "K"
const target = e.left;
if (!ts.isElementAccessExpression(target) || !isName(target.expression, param)) return null;
const inner = unparen(target.argumentExpression);
if (!ts.isBinaryExpression(inner) || inner.operatorToken.kind !== ts.SyntaxKind.EqualsToken)
return null;
const innerKey = memberKey(inner.left, param);
const innerValue = literalValue(inner.right);
if (innerKey === null || innerValue === null) return null;
members.set(innerKey, innerValue);
members.set(propertyKey(innerValue), value);
}
return members.size > 0 ? members : null;
}
function memberKey(target: ts.Expression, param: string): string | null {
if (ts.isElementAccessExpression(target) && isName(target.expression, param)) {
const k = unparen(target.argumentExpression);
return ts.isStringLiteral(k) || ts.isNoSubstitutionTemplateLiteral(k) ? k.text : null;
}
if (
ts.isPropertyAccessExpression(target) &&
isName(target.expression, param) &&
ts.isIdentifier(target.name)
)
return target.name.text;
return null;
}
function literalValue(expr: ts.Expression): string | number | null {
const e = unparen(expr);
if (ts.isStringLiteral(e) || ts.isNoSubstitutionTemplateLiteral(e)) return e.text;
if (ts.isNumericLiteral(e)) return numberOf(e.text);
if (
ts.isPrefixUnaryExpression(e) &&
e.operator === ts.SyntaxKind.MinusToken &&
ts.isNumericLiteral(e.operand)
) {
const n = numberOf(e.operand.text);
return n === null ? null : -n;
}
return null;
}
function numberOf(text: string): number | null {
if (!/^(?:\d+(?:\.\d+)?(?:[eE][+-]?\d+)?|0[xX][0-9a-fA-F]+)$/.test(text)) return null;
const n = Number(text);
return Number.isFinite(n) ? n : null;
}
/** ToPropertyKey of a member value. */
function propertyKey(value: string | number): string {
return typeof value === "string" ? value : String(value);
}
/** The canonical array-index keys JavaScript enumerates first. */
function isIndexKey(key: string): boolean {
return /^(?:0|[1-9]\d*)$/.test(key) && Number(key) < 4294967295;
}
function objectLiteral(members: Members): string {
const keys = [...members.keys()];
const ordered = [
...keys.filter(isIndexKey).sort((a, b) => Number(a) - Number(b)),
...keys.filter((k) => !isIndexKey(k)),
];
const parts = ordered.map((k) => {
const v = members.get(k)!;
const value = typeof v === "string" ? JSON.stringify(v) : Object.is(v, -0) ? "-0" : String(v);
return `${isIndexKey(k) ? k : JSON.stringify(k)}: ${value}`;
});
return `{ ${parts.join(", ")} }`;
}
/** True when the module writes `name` anywhere outside the enum call. */
function writtenElsewhere(sf: ts.SourceFile, name: string, iife: ts.Statement): boolean {
const start = iife.getStart(sf);
const end = iife.getEnd();
let written = false;
ts.walkPreorder(sf, (n) => {
if (written) return "stop";
if (n.pos >= start && n.end <= end) return "skip";
if (ts.isBinaryExpression(n) && isAssignmentOperator(n.operatorToken.kind)) {
if (isName(n.left, name)) written = true;
} else if (
(ts.isPrefixUnaryExpression(n) || ts.isPostfixUnaryExpression(n)) &&
(n.operator === ts.SyntaxKind.PlusPlusToken ||
n.operator === ts.SyntaxKind.MinusMinusToken) &&
isName(n.operand, name)
) {
written = true;
} else if (
(ts.isFunctionDeclaration(n) || ts.isClassDeclaration(n)) &&
n.name !== undefined &&
n.name.text === name
) {
written = true;
}
return undefined;
});
return written;
}
function isAssignmentOperator(kind: ts.SyntaxKind): boolean {
return kind >= ts.SyntaxKind.FirstAssignment && kind <= ts.SyntaxKind.LastAssignment;
}
function isName(e: ts.Expression, name: string): boolean {
const u = unparen(e);
return ts.isIdentifier(u) && u.text === name;
}
function exportsMember(e: ts.Expression): string | null {
const u = unparen(e);
return ts.isPropertyAccessExpression(u) &&
ts.isIdentifier(u.expression) &&
u.expression.text === "exports" &&
ts.isIdentifier(u.name)
? u.name.text
: null;
}
function isEmptyObject(e: ts.Expression): boolean {
const u = unparen(e);
return ts.isObjectLiteralExpression(u) && u.properties.nodes.length === 0;
}
+9 -1
View File
@@ -455,8 +455,16 @@ export function npmStaticFsShadow(services: FrontendServices): NpmStaticFsShadow
// through that function.
const fnExports = original === null ? null : services.cjsFunctionExports(original, path);
if (fnExports !== null) original = fnExports;
// TypeScript's enum IIFEs with literal members become the object
// literals they build.
const enums = original === null ? null : services.enumIifes(original, path);
if (enums !== null) original = enums;
const respelled =
split !== null || esm !== null || aliased !== null || fnExports !== null;
split !== null ||
esm !== null ||
aliased !== null ||
fnExports !== null ||
enums !== null;
if (original !== null) {
const namepaths = services.jsDocNamepaths(path, original);
const source = namepaths ?? original;
+29 -15
View File
@@ -10,6 +10,7 @@ import {
checkPreflight,
isNodeTypesPath,
locOf,
npmStaticDepSf7,
npmStaticLoadableFiles,
requiresOf,
resolveImport,
@@ -119,26 +120,32 @@ export type FrontendFactory = (
* with their own static import graphs: they load at the call, outside the
* startup module order, and their npm imports are dependencies all the
* same (a workspace package loaded on demand, the vercel.ts config parser
* shape). Package files are the package machinery's business. */
* shape). An opted-in package's JavaScript reached that way joins too,
* with every file its literal require() and import edges load: those
* files are outside the module order as well, and the packages THEY
* require (a compiled CommonJS dist requiring a sibling package) are
* dependencies the selection must see. */
function addDynamicImportGraph(load: LoadResult, scanned: Set<SourceFile>): void {
const queue = [...scanned];
const added = new Set<SourceFile>();
while (queue.length > 0) {
const sf = queue.pop()!;
if (sf.fileName.includes("/node_modules/")) continue;
const specs: string[] = [];
const packageJs = sf.fileName.includes("/node_modules/");
if (packageJs && !added.has(sf)) continue;
const specs: { spec: string; kind: "import" | "require" }[] = [];
// Every literal import() in the file, read from the syntax: a comment
// can stand between the keyword and its parenthesis. A file without
// the keyword has none.
const calls = sf.text.includes("import") ? moduleSourceCandidates(load.program, sf).calls : [];
// the keyword has none. Package JavaScript loads its literal require()
// calls the same way, nested ones included.
const calls =
packageJs || sf.text.includes("import") ? moduleSourceCandidates(load.program, sf).calls : [];
for (const call of calls) {
const arg = call.arguments.nodes[0];
if (
call.expression.kind === SyntaxKind.ImportKeyword &&
arg !== undefined &&
isStringLiteralLike(arg)
)
specs.push(arg.text);
if (arg === undefined || !isStringLiteralLike(arg)) continue;
if (call.expression.kind === SyntaxKind.ImportKeyword)
specs.push({ spec: arg.text, kind: "import" });
else if (packageJs && isIdentifier(call.expression) && call.expression.text === "require")
specs.push({ spec: arg.text, kind: "require" });
}
// The startup graph's static edges are already in the module order.
if (added.has(sf)) {
@@ -149,12 +156,18 @@ function addDynamicImportGraph(load: LoadResult, scanned: Set<SourceFile>): void
isStringLiteral(stmt.moduleSpecifier) &&
!(isImportDeclaration(stmt) ? erasedTypeOnlyImport(stmt) : stmt.isTypeOnly)
)
specs.push(stmt.moduleSpecifier.text);
specs.push({ spec: stmt.moduleSpecifier.text, kind: "import" });
}
}
for (const spec of specs) {
const dep = resolveImport(load.program, sf, spec);
if (dep === null || scanned.has(dep) || dep.fileName.includes("/node_modules/")) continue;
for (const { spec, kind } of specs) {
if (canonicalBuiltinModule(spec) !== null) continue;
// Only the JavaScript of a package the selection already opted in:
// a package's declarations load nothing.
const dep =
resolveImport(load.program, sf, spec, kind) ??
npmStaticDepSf7(load.program, sf, spec, kind);
if (dep === null || scanned.has(dep)) continue;
if (dep.fileName.includes("/node_modules/") && !isJsSourceFileName(dep.fileName)) continue;
scanned.add(dep);
added.add(dep);
queue.push(dep);
@@ -674,6 +687,7 @@ function loadFrontend(
frontendServices: finalLoad.services,
startupCrash: finalLoad.startupCrash ?? null,
inlineRequireCycleCalls: finalLoad.inlineRequireCycleCalls,
moduleEdges: finalLoad.moduleEdges,
projectCheckJs: finalLoad.typeWorld?.projectCheckJs ?? false,
externalTypes: finalLoad.externalTypes,
externalTypeSpecifiersByFile: finalLoad.externalTypeSpecifiersByFile,
+10 -1
View File
@@ -354,6 +354,11 @@ export interface LoadResult {
* at run time instead of refusing the whole program. Filled by
* checkPreflight. */
inlineRequireCycleCalls?: ReadonlySet<ts.Node>;
/** Every program file's module edges as preflight resolved them,
* require() edges included: the lowering orders the modules only a
* literal import(), fork() or Worker reaches with them. Filled by
* checkPreflight. */
moduleEdges?: ReadonlyMap<ts.SourceFile, readonly CycleEdge[]> | undefined;
/** Node's startup refusal, when the graph carries an import edge Node
* rejects before ANY module evaluates — a RESOLUTION refusal (a bare
* specifier nothing installed resolves, an exports/imports map that
@@ -1052,10 +1057,12 @@ export function loadProgram(
* preflight diagnostics. Preserve the caller's complete load shape when
* mutating it, including its owned services and disposal callback. */
export function checkPreflight<T extends LoadResult>(load: T): ScrDiagnostic[] {
const { diags, moduleOrder, startupCrash, inlineRequireCycleCalls } = preflight7(load);
const { diags, moduleOrder, startupCrash, inlineRequireCycleCalls, moduleEdges } =
preflight7(load);
load.moduleOrder = moduleOrder;
load.startupCrash = startupCrash;
load.inlineRequireCycleCalls = inlineRequireCycleCalls;
load.moduleEdges = moduleEdges;
return diags;
}
@@ -4393,6 +4400,7 @@ function preflight7(load: LoadResult): {
moduleOrder: ts.SourceFile[];
startupCrash: StartupCrash | null;
inlineRequireCycleCalls: ReadonlySet<ts.Node>;
moduleEdges?: ReadonlyMap<ts.SourceFile, readonly CycleEdge[]>;
} {
const { program, entry } = load;
const diags: ScrDiagnostic[] = [...load.configDiags];
@@ -5501,6 +5509,7 @@ function preflight7(load: LoadResult): {
moduleOrder: order,
startupCrash: resolveCrash ?? linkCrash,
inlineRequireCycleCalls,
moduleEdges: edges,
};
}
@@ -12,6 +12,7 @@ import {
mayRequireBuiltinAlias,
rewriteBuiltinAliasRequires,
} from "./npm-static-builtin-aliases.js";
import { mayHoldEnumIife, rewriteEnumIifes } from "./npm-static-enum-iife.js";
import { builtinPackageAliasFor } from "./package-shims.js";
import * as syntax from "./npm-static-declaration-syntax.js";
import type { SourceFile } from "./ts7/ast-types.js";
@@ -104,6 +105,13 @@ export class FrontendServices {
if (!source.includes("module.exports")) return null;
return normalizeCjsFunctionExports(this.parse(path, source, "js"));
}
/** A package module with its literal-valued TypeScript enum IIFEs
* respelled as object literals (null: none). */
enumIifes(source: string, path: string): string | null {
this.ensureOpen();
if (!mayHoldEnumIife(source)) return null;
return rewriteEnumIifes(this.parse(path, source, "js"));
}
/** A package module with its requires of builtin-aliased polyfill
* packages respelled as the builtins (null: none). */
builtinAliasRequires(source: string, path: string): string | null {
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/fixtures/npm/cases/workspace-dynamic-import-deps/main.ts"
],
"diags": []
}
@@ -0,0 +1,12 @@
// A workspace package the program loads on demand with a dynamic import.
// Its npm dependency is compiled CommonJS that requires a sibling scoped
// package (shipping JavaScript and declarations) from a nested file. Both
// packages compile with the workspace package, although none of them is
// part of the startup module graph.
async function main(): Promise<void> {
console.log("before");
const m = await import("wsdynamicdeps");
console.log(m.describe("42"));
console.log(m.describe("name"));
}
main();
@@ -0,0 +1 @@
../../../workspace/wsdynamicdeps
+6
View File
@@ -0,0 +1,6 @@
import { classify, NodeKind } from "@gtdyn/estree";
export function describe(text: string): string {
const kind = classify(text);
return kind === NodeKind.Literal ? `literal ${text}` : `${kind} ${text}`;
}
@@ -0,0 +1,2 @@
import { NodeKind } from "@gtdyn/types";
export declare function classify(text: string): NodeKind;
@@ -0,0 +1,8 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.classify = void 0;
const types_1 = require("@gtdyn/types");
function classify(text) {
return /^[0-9]+$/.test(text) ? types_1.NodeKind.Literal : types_1.NodeKind.Identifier;
}
exports.classify = classify;
@@ -0,0 +1,2 @@
export { classify } from "./classify";
export { NodeKind } from "@gtdyn/types";
@@ -0,0 +1,7 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.NodeKind = exports.classify = void 0;
var classify_1 = require("./classify");
Object.defineProperty(exports, "classify", { enumerable: true, get: function () { return classify_1.classify; } });
var types_1 = require("@gtdyn/types");
Object.defineProperty(exports, "NodeKind", { enumerable: true, get: function () { return types_1.NodeKind; } });
@@ -0,0 +1,10 @@
{
"name": "@gtdyn/estree",
"version": "1.0.0",
"description": "a compiled CommonJS package that requires its sibling scoped package from a nested file",
"main": "lib/index.js",
"types": "lib/index.d.ts",
"dependencies": {
"@gtdyn/types": "1.0.0"
}
}
@@ -0,0 +1,4 @@
export declare enum NodeKind {
Identifier = "Identifier",
Literal = "Literal"
}
@@ -0,0 +1,8 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.NodeKind = void 0;
var NodeKind;
(function (NodeKind) {
NodeKind["Identifier"] = "Identifier";
NodeKind["Literal"] = "Literal";
})(NodeKind || (exports.NodeKind = NodeKind = {}));
@@ -0,0 +1,7 @@
{
"name": "@gtdyn/types",
"version": "1.0.0",
"description": "a scoped package shipping compiled JavaScript and declarations",
"main": "lib/index.js",
"types": "lib/index.d.ts"
}
+10
View File
@@ -0,0 +1,10 @@
{
"name": "wsdynamicdeps",
"version": "1.0.0",
"type": "module",
"main": "./index.ts",
"types": "./index.ts",
"dependencies": {
"@gtdyn/estree": "1.0.0"
}
}