diff --git a/packages/compiler/src/frontend/npm-static-umd.test.ts b/packages/compiler/src/frontend/npm-static-umd.test.ts new file mode 100644 index 000000000..5280ecb17 --- /dev/null +++ b/packages/compiler/src/frontend/npm-static-umd.test.ts @@ -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(); +}); diff --git a/packages/compiler/src/frontend/npm-static-umd.ts b/packages/compiler/src/frontend/npm-static-umd.ts new file mode 100644 index 000000000..88400e046 --- /dev/null +++ b/packages/compiler/src/frontend/npm-static-umd.ts @@ -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("__") + ); +} diff --git a/packages/compiler/src/frontend/npm-static.ts b/packages/compiler/src/frontend/npm-static.ts index bc9fc2abd..56edafc48 100644 --- a/packages/compiler/src/frontend/npm-static.ts +++ b/packages/compiler/src/frontend/npm-static.ts @@ -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 || diff --git a/packages/compiler/src/frontend/services.ts b/packages/compiler/src/frontend/services.ts index 10931ab29..19728d6ce 100644 --- a/packages/compiler/src/frontend/services.ts +++ b/packages/compiler/src/frontend/services.ts @@ -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 { diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7000-umd-package-entry/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7000-umd-package-entry/main.ts.json new file mode 100644 index 000000000..0c6cbd9d1 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7000-umd-package-entry/main.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7000-umd-package-entry/main.ts" + ], + "diags": [] +} diff --git a/tests/corpus/7000-umd-package-entry/main.ts b/tests/corpus/7000-umd-package-entry/main.ts new file mode 100644 index 000000000..4b8b3e246 --- /dev/null +++ b/tests/corpus/7000-umd-package-entry/main.ts @@ -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]); diff --git a/tests/corpus/7000-umd-package-entry/node_modules/umd-shapes/lib/impl/measure.js b/tests/corpus/7000-umd-package-entry/node_modules/umd-shapes/lib/impl/measure.js new file mode 100644 index 000000000..0ea89b34e --- /dev/null +++ b/tests/corpus/7000-umd-package-entry/node_modules/umd-shapes/lib/impl/measure.js @@ -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; + } +}); diff --git a/tests/corpus/7000-umd-package-entry/node_modules/umd-shapes/lib/main.d.ts b/tests/corpus/7000-umd-package-entry/node_modules/umd-shapes/lib/main.d.ts new file mode 100644 index 000000000..0982da9ef --- /dev/null +++ b/tests/corpus/7000-umd-package-entry/node_modules/umd-shapes/lib/main.d.ts @@ -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; diff --git a/tests/corpus/7000-umd-package-entry/node_modules/umd-shapes/lib/main.js b/tests/corpus/7000-umd-package-entry/node_modules/umd-shapes/lib/main.js new file mode 100644 index 000000000..26162fbd9 --- /dev/null +++ b/tests/corpus/7000-umd-package-entry/node_modules/umd-shapes/lib/main.js @@ -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)}`; + } +}); diff --git a/tests/corpus/7000-umd-package-entry/node_modules/umd-shapes/package.json b/tests/corpus/7000-umd-package-entry/node_modules/umd-shapes/package.json new file mode 100644 index 000000000..e827ecc41 --- /dev/null +++ b/tests/corpus/7000-umd-package-entry/node_modules/umd-shapes/package.json @@ -0,0 +1 @@ +{ "name": "umd-shapes", "version": "1.0.0", "main": "./lib/main.js", "types": "./lib/main.d.ts" } diff --git a/tests/harness/corpus-costs/7000-umd-package-entry/main.ts.json b/tests/harness/corpus-costs/7000-umd-package-entry/main.ts.json new file mode 100644 index 000000000..782707a3a --- /dev/null +++ b/tests/harness/corpus-costs/7000-umd-package-entry/main.ts.json @@ -0,0 +1 @@ +{ "plain": 3911, "san": 3835 }