Compile TypeScript's UMD module wrapper as the module body

This commit is contained in:
Chris Tate
2026-10-21 19:02:52 -05:00
parent 7b809ee8c9
commit be23f71266
11 changed files with 381 additions and 0 deletions
@@ -0,0 +1,109 @@
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 unwrap(source: string): string | null {
return nodeFrontendServices().umdFactory(source, "/pkg/index.js");
}
/** TypeScript's UMD wrapper around `body`. */
function umd(body: string, params = "require, exports"): string {
return `(function (factory) {
if (typeof module === "object" && typeof module.exports === "object") {
var v = factory(require, exports);
if (v !== undefined) module.exports = v;
}
else if (typeof define === "function" && define.amd) {
define(["require", "exports"], factory);
}
})(function (${params}) {
${body}
});
`;
}
const probe = `
console.log(JSON.stringify(module.exports), Object.keys(module.exports).join(","));
`;
function run(file: string): string {
const result = spawnSync(process.execPath, [file], { encoding: "utf8" });
expect(result.status).toBe(0);
return result.stdout;
}
/** The unwrapped module exports what the original exports. */
function expectSameModule(source: string): string {
const result = unwrap(source);
expect(typeof result).toBe("string");
const dir = mkdtempSync(join(tmpdir(), "scriptc-umd-"));
dirs.push(dir);
const original = join(dir, "original.cjs");
const unwrapped = join(dir, "unwrapped.cjs");
writeFileSync(original, source + probe);
writeFileSync(unwrapped, result! + probe);
const answer = run(original);
expect(run(unwrapped)).toBe(answer);
return result!;
}
test("the factory body runs at the top level with its positions kept", () => {
const source = umd(` "use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.twice = twice;
const path = require("path");
exports.sep = path.sep;
function twice(n) {
return n * 2;
}`);
const result = expectSameModule(source);
expect(result.length).toBe(source.length);
expect(result.split("\n").length).toBe(source.split("\n").length);
expect(result.indexOf("exports.twice = twice;")).toBe(source.indexOf("exports.twice = twice;"));
expect(result).not.toContain("define.amd");
expect(result).not.toContain("factory");
});
test("TypeScript's helper probes of this keep their fallback", () => {
expectSameModule(
umd(` "use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
const os_1 = __importDefault(require("os"));
exports.eol = os_1.default.EOL;`),
);
});
test("nested functions keep their own return, arguments and this", () => {
expectSameModule(
umd(` "use strict";
exports.first = function () { return arguments[0]; };
exports.self = { get() { return this; } };
exports.arrow = () => { return 1; };`),
);
});
test("other shapes keep the original text", () => {
// `export =` returns the module value from the factory.
expect(unwrap(umd(` "use strict";\n return { a: 1 };`))).toBeNull();
// The body's own `this` and `arguments` differ from the module's.
expect(unwrap(umd(` exports.self = this;`))).toBeNull();
expect(unwrap(umd(` exports.count = arguments.length;`))).toBeNull();
// Other factory parameters.
expect(unwrap(umd(` exports.a = 1;`, "require, exports, module"))).toBeNull();
expect(unwrap(umd(` exports.a = 1;`, "req, exp"))).toBeNull();
// Code after the wrapper.
expect(unwrap(umd(` exports.a = 1;`) + "exports.b = 2;\n")).toBeNull();
// Not a UMD wrapper.
expect(unwrap(`exports.a = 1;\n`)).toBeNull();
});
@@ -0,0 +1,178 @@
/* TypeScript's UMD module emit in a compiled package: the module body is a
* factory function that the wrapper calls with the CommonJS module's own
* `require` and `exports`.
*
* (function (factory) {
* if (typeof module === "object" && typeof module.exports === "object") {
* var v = factory(require, exports);
* if (v !== undefined) module.exports = v;
* }
* else if (typeof define === "function" && define.amd) {
* define(["require", "exports", "./dep"], factory);
* }
* })(function (require, exports) {
* "use strict";
* const dep = require("./dep");
* exports.f = f;
* });
*
* Under Node the CommonJS branch runs: the factory body runs once with the
* module's `require` and `exports`, and a body without a `return` leaves
* `module.exports` as it was. That is the module's own body, so the wrapper
* is blanked out (line breaks kept, so positions stay) and the body runs at
* the module's top level, where its requires and export assignments are the
* module's own.
*
* Accepted: the wrapper is the file's only statement, its CommonJS branch
* calls the factory as `factory(require, exports)`, the factory's
* parameters are exactly `require` and `exports`, and the body uses no
* `return` or `arguments` of its own. `this` in the body is the factory's
* receiver, not the module's: it may appear only in TypeScript's helper
* probes (`this && this.__importDefault`), which find no helper on either
* receiver. Any other shape keeps the original text. */
import * as ts from "./ts7/syntax.js";
import { tokenStartOfNode } from "./ts7/node-functions.js";
import { applyEdits } from "./npm-static-bundled-chunks.js";
/** True when the text may hold a UMD wrapper (a cheap probe). */
export function mayHoldUmdWrapper(source: string): boolean {
return source.includes("define.amd") && source.includes("(require, exports)");
}
/** The module text with a UMD wrapper's factory body at the top level, or
* null when the file is not one. */
export function unwrapUmdFactory(sf: ts.SourceFile): string | null {
const stmts = sf.statements.nodes;
if (stmts.length !== 1) return null;
const stmt = stmts[0]!;
if (!ts.isExpressionStatement(stmt) || !ts.isCallExpression(stmt.expression)) return null;
const call = stmt.expression;
if (call.arguments.nodes.length !== 1) return null;
const wrapper = unparenthesized(call.expression);
const factory = call.arguments.nodes[0]!;
if (!ts.isFunctionExpression(wrapper) || !ts.isFunctionExpression(factory)) return null;
const wrapperParams = wrapper.parameters.nodes;
if (wrapperParams.length !== 1 || !isPlainParameter(wrapperParams[0]!)) return null;
const factoryName = (wrapperParams[0]!.name as ts.Identifier).text;
if (!callsFactoryForCommonJs(wrapper, factoryName)) return null;
const params = factory.parameters.nodes;
if (
params.length !== 2 ||
!params.every(isPlainParameter) ||
(params[0]!.name as ts.Identifier).text !== "require" ||
(params[1]!.name as ts.Identifier).text !== "exports"
)
return null;
const body = factory.body;
if (body === undefined || !ownBodyKeepsModuleSemantics(body)) return null;
const open = tokenStartOfNode(body) + 1;
const close = body.end - 1;
return applyEdits(sf.text, 0, sf.text.length, [
{ start: tokenStartOfNode(stmt), end: open, text: "" },
{ start: close, end: stmt.end, text: "" },
]);
}
function unparenthesized(e: ts.Expression): ts.Expression {
while (ts.isParenthesizedExpression(e)) e = e.expression;
return e;
}
/** A parameter that is a bare identifier: no default, rest or pattern. */
function isPlainParameter(p: ts.ParameterDeclaration): boolean {
return ts.isIdentifier(p.name) && p.initializer === undefined && p.dotDotDotToken === undefined;
}
/** True when the wrapper's first statement is TypeScript's CommonJS test,
* `if (typeof module === "object" && typeof module.exports === "object")`,
* whose branch starts by calling the factory as `factory(require, exports)`. */
function callsFactoryForCommonJs(wrapper: ts.FunctionExpression, factoryName: string): boolean {
const first = wrapper.body?.statements.nodes[0];
if (first === undefined || !ts.isIfStatement(first)) return false;
const sf = ts.getSourceFileOfNode(wrapper);
const condition = sf.text.slice(tokenStartOfNode(first.expression), first.expression.end);
if (
condition.replace(/\s+/g, " ") !==
'typeof module === "object" && typeof module.exports === "object"'
)
return false;
const branch = first.thenStatement;
const call = ts.isBlock(branch) ? branch.statements.nodes[0] : undefined;
if (call === undefined || !ts.isVariableStatement(call)) return false;
const declarations = call.declarationList.declarations.nodes;
const init = declarations.length === 1 ? declarations[0]!.initializer : undefined;
if (init === undefined || !ts.isCallExpression(init)) return false;
const args = init.arguments.nodes;
return (
ts.isIdentifier(init.expression) &&
init.expression.text === factoryName &&
args.length === 2 &&
ts.isIdentifier(args[0]!) &&
args[0].text === "require" &&
ts.isIdentifier(args[1]!) &&
args[1].text === "exports"
);
}
/** True when the factory body behaves the same as the module's top level:
* no `return` or `arguments` of its own, and `this` only in TypeScript's
* helper probes. */
function ownBodyKeepsModuleSemantics(body: ts.Block): boolean {
let keeps = true;
ts.walkPreorder(body, (n) => {
if (!keeps) return "stop";
// Nested functions have their own `return`, `arguments` and `this`;
// arrow functions only their own `return`.
if (
ts.isFunctionDeclaration(n) ||
ts.isFunctionExpression(n) ||
ts.isMethodDeclaration(n) ||
ts.isGetAccessorDeclaration(n) ||
ts.isSetAccessorDeclaration(n) ||
ts.isConstructorDeclaration(n)
)
return "skip";
if (ts.isClassDeclaration(n) || ts.isClassExpression(n)) return "skip";
if (ts.isReturnStatement(n) && !insideArrow(n, body)) keeps = false;
else if (ts.isIdentifier(n) && n.text === "arguments") keeps = false;
else if (n.kind === ts.SyntaxKind.ThisKeyword && !isHelperProbe(n)) keeps = false;
return undefined;
});
return keeps;
}
/** True when `node` sits inside an arrow function nested in `body`. */
function insideArrow(node: ts.Node, body: ts.Block): boolean {
for (let p = node.parent; p !== undefined && p !== body; p = p.parent)
if (ts.isArrowFunction(p)) return true;
return false;
}
/** `this` in TypeScript's helper probe `this && this.__name`: the left
* operand, or the receiver of the right operand's `__` member. */
function isHelperProbe(node: ts.Node): boolean {
const parent = node.parent;
if (parent === undefined) return false;
const probe =
ts.isBinaryExpression(parent) && parent.left === node
? parent
: ts.isPropertyAccessExpression(parent) &&
parent.expression === node &&
ts.isIdentifier(parent.name) &&
parent.name.text.startsWith("__") &&
parent.parent !== undefined &&
ts.isBinaryExpression(parent.parent) &&
parent.parent.right === parent
? parent.parent
: undefined;
if (probe === undefined || probe.operatorToken.kind !== ts.SyntaxKind.AmpersandAmpersandToken)
return false;
const right = probe.right;
return (
probe.left.kind === ts.SyntaxKind.ThisKeyword &&
ts.isPropertyAccessExpression(right) &&
right.expression.kind === ts.SyntaxKind.ThisKeyword &&
ts.isIdentifier(right.name) &&
right.name.text.startsWith("__")
);
}
@@ -452,6 +452,10 @@ export function npmStaticFsShadow(services: FrontendServices): NpmStaticFsShadow
} else if (esm !== null) {
original = esm;
}
// TypeScript's UMD wrapper calls its factory with the module's own
// require and exports: the factory body is the module body.
const umd = original === null ? null : services.umdFactory(original, path);
if (umd !== null) original = umd;
// Polyfill packages that ARE a Node builtin compile as the builtin.
const aliased = original === null ? null : services.builtinAliasRequires(original, path);
if (aliased !== null) original = aliased;
@@ -472,6 +476,7 @@ export function npmStaticFsShadow(services: FrontendServices): NpmStaticFsShadow
if (exportsVar !== null) original = exportsVar;
const respelled =
split !== null ||
umd !== null ||
tscHelpers !== null ||
esm !== null ||
aliased !== null ||
@@ -12,6 +12,7 @@ import {
mayRequireBuiltinAlias,
rewriteBuiltinAliasRequires,
} from "./npm-static-builtin-aliases.js";
import { mayHoldUmdWrapper, unwrapUmdFactory } from "./npm-static-umd.js";
import { mayHoldEnumIife, rewriteEnumIifes } from "./npm-static-enum-iife.js";
import { mayHoldTscBuiltinImport, unwrapTscBuiltinImports } from "./npm-static-tsc-helpers.js";
import { builtinPackageAliasFor } from "./package-shims.js";
@@ -114,6 +115,13 @@ export class FrontendServices {
if (!mayHoldTscBuiltinImport(source)) return null;
return unwrapTscBuiltinImports(this.parse(path, source, "js"));
}
/** A package module written as TypeScript's UMD wrapper with its
* factory body at the top level (null: not one). */
umdFactory(source: string, path: string): string | null {
this.ensureOpen();
if (!mayHoldUmdWrapper(source)) return null;
return unwrapUmdFactory(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 {
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/7000-umd-package-entry/main.ts"
],
"diags": []
}
@@ -0,0 +1,10 @@
// A package whose main entry is TypeScript's UMD emit: each module's body is
// a factory the wrapper calls with the module's own require and exports, so
// its requires and export assignments sit inside a function. Node runs the
// CommonJS branch, and the named imports read the factory's exports.
import { Shape, area, describe, label, unitCount } from "umd-shapes";
console.log(label, unitCount);
console.log(area({ kind: Shape.Square, size: 3 }), area({ kind: Shape.Circle, size: 1 }).toFixed(4));
console.log(describe({ kind: Shape.Square, size: 2 }));
console.log(Shape.Circle, Shape[Shape.Square]);
@@ -0,0 +1,22 @@
(function (factory) {
if (typeof module === "object" && typeof module.exports === "object") {
var v = factory(require, exports);
if (v !== undefined) module.exports = v;
}
else if (typeof define === "function" && define.amd) {
define(["require", "exports"], factory);
}
})(function (require, exports) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.units = void 0;
exports.square = square;
exports.circle = circle;
exports.units = ["cm", "m"];
function square(size) {
return size * size;
}
function circle(radius) {
return Math.PI * radius * radius;
}
});
@@ -0,0 +1,12 @@
export declare enum Shape {
Square = 0,
Circle = 1
}
export interface Figure {
kind: Shape;
size: number;
}
export declare const label: string;
export declare const unitCount: number;
export declare function area(figure: Figure): number;
export declare function describe(figure: Figure): string;
@@ -0,0 +1,29 @@
(function (factory) {
if (typeof module === "object" && typeof module.exports === "object") {
var v = factory(require, exports);
if (v !== undefined) module.exports = v;
}
else if (typeof define === "function" && define.amd) {
define(["require", "exports", "./impl/measure"], factory);
}
})(function (require, exports) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.unitCount = exports.label = exports.Shape = void 0;
exports.area = area;
exports.describe = describe;
const measure = require("./impl/measure");
var Shape;
(function (Shape) {
Shape[Shape["Square"] = 0] = "Square";
Shape[Shape["Circle"] = 1] = "Circle";
})(Shape || (exports.Shape = Shape = {}));
exports.label = "shapes";
exports.unitCount = measure.units.length;
function area(figure) {
return figure.kind === Shape.Square ? measure.square(figure.size) : measure.circle(figure.size);
}
function describe(figure) {
return `${Shape[figure.kind]} of ${figure.size} ${measure.units[0]}: ${area(figure)}`;
}
});
@@ -0,0 +1 @@
{ "name": "umd-shapes", "version": "1.0.0", "main": "./lib/main.js", "types": "./lib/main.d.ts" }
@@ -0,0 +1 @@
{ "plain": 3911, "san": 3835 }