Skip to content
Merged
100 changes: 100 additions & 0 deletions packages/compiler/src/frontend/npm-static-bundled-esm.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -365,3 +365,103 @@ test("a minified initializer that ends with its class still hoists it", () => {
);
expect(lowered).toContain("class Counter{");
});

test("an empty namespace and names a module does not export read undefined", () => {
const lowered = expectSameBehavior(`"use strict";
${helpers}${lazyModule}
var manifest_exports = {};
var init_manifest = __esm({ "src/manifest.ts"() { console.log("manifest init"); } });
function read() {
const manifest = (init_manifest(), __toCommonJS(manifest_exports));
const lazy = (init_lazy(), __toCommonJS(lazy_exports));
return [manifest.version, lazy.missing, lazy.__esModule, lazy.VALUE].join(" ");
}
console.log(read());
`);
expect(lowered).not.toContain("manifest_exports");
});

test("a star re-export of a types-only module into the entry runs its initializer", () => {
const lowered = expectSameBehavior(`"use strict";
${helpers}
var __reExport = (target, mod, secondTarget) => (__copyProps(target, mod, "default"), secondTarget && __copyProps(secondTarget, mod, "default"));
var index_exports = {};
__export(index_exports, { run: () => run });
module.exports = __toCommonJS(index_exports);
var manifest_exports = {};
var init_manifest = __esm({ "src/manifest.ts"() { console.log("manifest init"); } });
__reExport(index_exports, (init_manifest(), __toCommonJS(manifest_exports)), module.exports);
function run() { return "ran"; }
console.log(Object.keys(module.exports).join(","), module.exports.run());
`);
expect(lowered).not.toContain("module.exports);");
});

test("the bundle's own entry namespace answers member reads", () => {
expectSameBehavior(`"use strict";
${helpers}
var index_exports = {};
__export(index_exports, { KIND: () => KIND, late: () => late });
module.exports = __toCommonJS(index_exports);
var KIND;
var init_index = __esm({ "src/index.ts"() { KIND = "lambda"; } });
function late() {
const index = (init_index(), __toCommonJS(index_exports));
return index.KIND + " " + index.missing;
}
init_index();
console.log(late());
`);
});

const reExportingEntry = (use: string): string => `"use strict";
${helpers}
var __reExport = (target, mod, secondTarget) => (__copyProps(target, mod, "default"), secondTarget && __copyProps(secondTarget, mod, "default"));
var other_exports = {};
__export(other_exports, { EXTRA: () => EXTRA });
var EXTRA;
var init_other = __esm({ "src/other.ts"() { EXTRA = "extra"; } });
var index_exports = {};
__export(index_exports, { KIND: () => KIND, late: () => late });
module.exports = __toCommonJS(index_exports);
__reExport(index_exports, (init_other(), __toCommonJS(other_exports)), module.exports);
var KIND;
var init_index = __esm({ "src/index.ts"() { KIND = "lambda"; } });
function late() {
const index = (init_index(), __toCommonJS(index_exports));
return ${use};
}
init_index();
console.log(late());
`;

test("an entry that star re-exports a module still answers its own names", () => {
expectSameBehavior(reExportingEntry("index.KIND"));
});

test("a read through the entry namespace of a name a star re-export adds keeps the package refused", () => {
expect(lower(reExportingEntry("index.KIND + ' ' + index.EXTRA"))).toEqual({
degrade: expect.stringContaining(
"reads the namespace of a bundled ES module ('index_exports')",
) as string,
});
});

test.each([
["constructor", "lazy.constructor === Object"],
["toString", "typeof lazy.toString"],
["__proto__", "lazy.__proto__ === Object.prototype"],
["hasOwnProperty", "typeof lazy.hasOwnProperty"],
])("a read of the inherited %s through a namespace keeps the package refused", (_, use) => {
expect(
lower(`"use strict";
${helpers}${lazyModule}
const lazy = (init_lazy(), __toCommonJS(lazy_exports));
console.log(${use});
`),
).toEqual({
degrade: expect.stringContaining(
"reads the namespace of a bundled ES module ('lazy_exports')",
) as string,
});
});
132 changes: 112 additions & 20 deletions packages/compiler/src/frontend/npm-static-bundled-esm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -503,15 +503,64 @@ export function lowerBundledEsmInits(
)
mainTarget = (rhs.arguments.nodes[0] as ts.Identifier).text;
}
const tables = new Map<string, { stmt: ts.Statement; entries: [string, ts.Expression][] }>();
const tables = new Map<
string,
{ stmt: ts.Statement | null; entries: [string, ts.Expression][] }
>();
// The bundle's own entry namespace keeps its statements (the CommonJS
// rewrite builds the export table from them); its table only answers
// member reads through `__toCommonJS(main)` in the bundle's own code.
let mainTable: { stmt: ts.Statement | null; entries: [string, ts.Expression][] } | null = null;
for (const stmt of sf.statements.nodes) {
if (!ts.isExpressionStatement(stmt) || !ts.isCallExpression(stmt.expression)) continue;
const table = exportTableOf(stmt.expression);
if (table !== null && table.target !== mainTarget) {
if (table !== null && table.target === mainTarget) {
mainTable = mainTable === null ? { stmt, entries: table.entries } : null;
} else if (table !== null) {
if (tables.has(table.target)) return { degrade: ESM_SHAPE_DEGRADE };
tables.set(table.target, { stmt, entries: table.entries });
}
}
// A module that exports only types keeps an empty namespace object
// (`var x_exports = {}`) with no __export call: an empty table, read
// only through `__toCommonJS(x_exports)`.
for (const [name, t] of top) {
if (tables.has(name) || name === mainTarget || writes.has(name)) continue;
const d = t.node;
if (!ts.isVariableDeclaration(d) || d.initializer === undefined) continue;
const init = unparen(d.initializer);
if (!ts.isObjectLiteralExpression(init) || init.properties.nodes.length !== 0) continue;
const uses = (refsByName.get(name) ?? []).filter((x) => x !== d.name);
if (
uses.length > 0 &&
uses.every((x) => {
const p = x.parent;
return (
p !== undefined &&
ts.isCallExpression(p) &&
ts.isIdentifier(p.expression) &&
helperBase(p.expression.text) === "__toCommonJS" &&
p.arguments.nodes.length === 1
);
})
)
tables.set(name, { stmt: null, entries: [] });
}
// A star re-export into the entry (`__reExport(main, ns, module.exports)`)
// adds names its export table does not list, so the entry namespace's
// missing names are unknown.
const entryReExports =
mainTarget !== null &&
(refsByName.get(mainTarget) ?? []).some((x) => {
const p = x.parent;
return (
p !== undefined &&
ts.isCallExpression(p) &&
ts.isIdentifier(p.expression) &&
helperBase(p.expression.text) === "__reExport" &&
p.arguments.nodes[0] === x
);
});
const lowered = new Set<string>();
/** Some binding between the use site and the top level rebinds a name
* the table's getters read. */
Expand Down Expand Up @@ -673,11 +722,25 @@ export function lowerBundledEsmInits(
}

for (const r of allRefs) {
const table = tables.get(r.text);
const p = r.parent;
const isMain =
r.text === mainTarget &&
mainTable !== null &&
p !== undefined &&
ts.isCallExpression(p) &&
ts.isIdentifier(p.expression) &&
helperBase(p.expression.text) === "__toCommonJS" &&
p.arguments.nodes.length === 1 &&
!(
p.parent !== undefined &&
ts.isBinaryExpression(p.parent) &&
p.parent.right === p &&
ts.isPropertyAccessExpression(p.parent.left)
);
const table = isMain ? mainTable! : tables.get(r.text);
if (table === undefined) continue;
if (loaderSpans.some((span) => r.getStart(sf) >= span.start && r.getEnd() <= span.end))
continue;
const p = r.parent;
// The table's own declaration and __export call are handled below.
if (p !== undefined && ts.isVariableDeclaration(p) && p.name === r) continue;
if (
Expand Down Expand Up @@ -712,13 +775,33 @@ export function lowerBundledEsmInits(
else break;
}
const values = new Map(table.entries);
const memberRead = (access: ts.Node | undefined, of: ts.Node): boolean =>
access !== undefined &&
ts.isPropertyAccessExpression(access) &&
access.expression === of &&
values.has(access.name.text) &&
plainRead(access) &&
(!calledWithReceiver(access) || receiverHidden(values, access.name.text));
// A name the module does not export reads undefined on the namespace
// object (`__esModule` reads true); plain reads only, never calls. The
// namespace is an ordinary object, so a name Object.prototype answers
// (constructor, toString, __proto__) is no missing name, and neither
// is any name of an entry that star re-exports other modules.
const missing = new Set<string>();
const readNames = new Set<string>();
const memberRead = (access: ts.Node | undefined, of: ts.Node): boolean => {
if (
access === undefined ||
!ts.isPropertyAccessExpression(access) ||
access.expression !== of ||
!plainRead(access)
)
return false;
const name = access.name.text;
if (!values.has(name)) {
const parent = access.parent;
if (parent !== undefined && ts.isCallExpression(parent) && parent.expression === access)
return false;
if (name in Object.prototype || (isMain && entryReExports)) return false;
missing.add(name);
Comment thread
vercel[bot] marked this conversation as resolved.
Comment thread
vercel[bot] marked this conversation as resolved.
return true;
}
readNames.add(name);
return !calledWithReceiver(access) || receiverHidden(values, name);
};
const holder = ns.parent;
// `__reExport(main, ns, module.exports)`: a star re-export of the
// module into the bundle's own exports (`export * from` in its entry).
Expand Down Expand Up @@ -752,7 +835,7 @@ export function lowerBundledEsmInits(
end: holder.parent.getEnd(),
text: `${init}__export(${mainTarget}, { ${entries} });`,
});
lowered.add(r.text);
if (!isMain) lowered.add(r.text);
continue;
}
let readable = memberRead(holder, ns);
Expand Down Expand Up @@ -793,17 +876,22 @@ export function lowerBundledEsmInits(
};
return { degrade: `its shipped JS ${why}` };
}
// The entry namespace answers only the names read here.
const entries = isMain ? table.entries.filter(([n]) => readNames.has(n)) : table.entries;
// The getters read top-level bindings: nothing between the use site
// and the top level may rebind one of their names.
if (shadowed(p, table.entries)) return { degrade: `its shipped JS ${why}` };
const getters = table.entries
.map(
if (shadowed(p, entries)) return { degrade: `its shipped JS ${why}` };
const getters = [
...entries.map(
([n, value]) =>
`get ${/^[A-Za-z_$][\w$]*$/.test(n) ? n : JSON.stringify(n)}() { return ${getterText(value)}; }`,
)
.join(", ");
),
...[...missing].map(
(n) => `get ${n}() { return ${n === "__esModule" ? "true" : "void 0"}; }`,
),
].join(", ");
edits.push({ start: p.getStart(sf), end: p.getEnd(), text: `{ ${getters} }` });
lowered.add(r.text);
if (!isMain) lowered.add(r.text);
}
// A table nothing reads (its module is never required) goes too: its
// names are no exports of the bundle.
Expand All @@ -812,7 +900,10 @@ export function lowerBundledEsmInits(
const live = (refsByName.get(target) ?? []).some((x) => {
const p = x.parent;
if (p !== undefined && ts.isVariableDeclaration(p) && p.name === x) return false;
return !(x.getStart(sf) >= table.stmt.getStart(sf) && x.getEnd() <= table.stmt.getEnd());
return (
table.stmt === null ||
!(x.getStart(sf) >= table.stmt.getStart(sf) && x.getEnd() <= table.stmt.getEnd())
);
});
if (!live) lowered.add(target);
}
Expand All @@ -828,7 +919,8 @@ export function lowerBundledEsmInits(
writes.has(target)
)
return { degrade: ESM_SHAPE_DEGRADE };
edits.push({ start: table.stmt.getStart(sf), end: table.stmt.getEnd(), text: "" });
if (table.stmt !== null)
edits.push({ start: table.stmt.getStart(sf), end: table.stmt.getEnd(), text: "" });
deadDeclarators.add(decl);
}
// Every removed declarator in one pass: declarators of one statement
Expand Down
71 changes: 71 additions & 0 deletions packages/compiler/src/frontend/npm-static-tsc-helpers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
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().tscBuiltinImports(source, "/pkg/index.js");
}

const helpers = `"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
var __importStar = (this && this.__importStar) || function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) result[k] = mod[k];
result["default"] = mod;
return result;
};
`;

function expectSameBehavior(source: string): string {
const result = rewrite(source);
expect(typeof result).toBe("string");
const dir = mkdtempSync(join(tmpdir(), "scriptc-tsc-builtin-imports-"));
dirs.push(dir);
writeFileSync(join(dir, "original.cjs"), source);
writeFileSync(join(dir, "rewritten.cjs"), result!);
const run = (file: string) =>
spawnSync(process.execPath, [join(dir, file)], { encoding: "utf8" });
const oracle = run("original.cjs");
expect(oracle.status).toBe(0);
expect(run("rewritten.cjs").stdout).toBe(oracle.stdout);
return result!;
}

test("builtin modules behind tsc's interop helpers become plain requires", () => {
const out = expectSameBehavior(`${helpers}const events_1 = __importDefault(require("events"));
const path = __importStar(require("node:path"));
class Bus extends events_1.default.EventEmitter {}
console.log(new Bus() instanceof events_1.default, path.basename("/a/b.txt"), typeof path.join);
`);
expect(out).toContain('const events_1 = require("events");');
expect(out).toContain("class Bus extends events_1.EventEmitter {}");
expect(out).toContain('const path = require("node:path");');
});

test("other references, program modules and default reads of a namespace keep their text", () => {
// The binding itself as a value.
expect(
rewrite(
`${helpers}const events_1 = __importDefault(require("events"));\nconsole.log(events_1);\n`,
),
).toBeNull();
// A program module (cjs-tsc-interop.ts owns it).
expect(
rewrite(`${helpers}const m = __importDefault(require("./m"));\nm.default();\n`),
).toBeNull();
// `ns.default` reads the namespace copy's own default.
expect(
rewrite(`${helpers}const p = __importStar(require("path"));\nconsole.log(p.default === p);\n`),
).toBeNull();
});
Loading
Loading