Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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();
});
106 changes: 106 additions & 0 deletions packages/compiler/src/frontend/npm-static-tsc-helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
/* tsc wraps every CommonJS import in its interop helpers:
*
* const events_1 = __importDefault(require("events")); // events_1.default.X
* const path = __importStar(require("path")); // path.basename
*
* For program modules cjs-tsc-interop.ts reads such a binding as a plain
* require binding, and the helper declarations (`var __importDefault =
* (this && this.__importDefault) || …`) go dead. A builtin module is not a
* program module, so its binding kept the helpers live, and their probe of
* the module's top-level `this` has no lowering. A builtin carries no
* `__esModule`: `events_1.default` is the module and the namespace copy
* reads the module's members. When every reference has that form
* (`x.default…` for the default helper, `x.<member>` other than `default`
* for the namespace helper), the binding becomes the plain `require()` of
* the builtin and `x.default` becomes `x`. */
import * as ts from "./ts7/syntax.js";
import { applyEdits, unparen, type Edit } from "./npm-static-bundled-chunks.js";
import { canonicalBuiltinModule } from "./builtin-modules.js";

/** True when the text may hold such a binding (a cheap probe). */
export function mayHoldTscBuiltinImport(source: string): boolean {
return /__import(?:Default|Star)\(require\(/.test(source);
}

/** `__importDefault(require("<builtin>"))` / `__importStar(…)`: the helper
* and builtin spec, or null. */
function builtinInterop(init: ts.Expression): { helper: string; spec: string } | null {
const e = unparen(init);
if (!ts.isCallExpression(e) || !ts.isIdentifier(e.expression) || e.arguments.nodes.length !== 1)
return null;
const helper = e.expression.text;
if (helper !== "__importDefault" && helper !== "__importStar") return null;
const req = unparen(e.arguments.nodes[0]!);
if (
!ts.isCallExpression(req) ||
!ts.isIdentifier(req.expression) ||
req.expression.text !== "require" ||
req.arguments.nodes.length !== 1 ||
!ts.isStringLiteral(req.arguments.nodes[0]!)
)
return null;
const spec = (req.arguments.nodes[0] as ts.StringLiteral).text;
return canonicalBuiltinModule(spec) === null ? null : { helper, spec };
}

/** The `.default` accesses to remove when every reference to `name` (other
* than its declaration) has the helper's form, or null. */
function interopReferences(
sf: ts.SourceFile,
decl: ts.VariableDeclaration,
name: string,
helper: string,
): ts.PropertyAccessExpression[] | null {
const defaults: ts.PropertyAccessExpression[] = [];
let ok = true;
ts.walkPreorder(sf, (n) => {
if (!ok) return "stop";
if (!ts.isIdentifier(n) || n.text !== name || n === decl.name) return undefined;
const parent = n.parent;
if (
parent === undefined ||
!ts.isPropertyAccessExpression(parent) ||
parent.expression !== n ||
parent.questionDotToken !== undefined
) {
ok = false;
return "stop";
}
const member = parent.name.text;
if (helper === "__importDefault") {
if (member !== "default") ok = false;
else defaults.push(parent);
} else if (member === "default") {
ok = false;
}
return undefined;
});
return ok ? defaults : null;
}

/** The module text with its builtin interop bindings unwrapped, or null. */
export function unwrapTscBuiltinImports(sf: ts.SourceFile): string | null {
if (ts.isExternalModule(sf) && !sf.fileName.endsWith(".cjs")) return null;
const edits: Edit[] = [];
const stmts = sf.statements.nodes;
// Builtin modules through the interop helpers.
for (const stmt of stmts) {
if (!ts.isVariableStatement(stmt)) continue;
for (const d of stmt.declarationList.declarations.nodes) {
if (!ts.isIdentifier(d.name) || d.initializer === undefined) continue;
const interop = builtinInterop(d.initializer);
if (interop === null) continue;
const defaults = interopReferences(sf, d, d.name.text, interop.helper);
if (defaults === null) continue;
edits.push({
start: d.initializer.getStart(sf),
end: d.initializer.getEnd(),
text: `require(${JSON.stringify(interop.spec)})`,
});
for (const access of defaults)
edits.push({ start: access.getStart(sf), end: access.getEnd(), text: d.name.text });
}
}
if (edits.length === 0) return null;
return applyEdits(sf.text, 0, sf.text.length, edits);
}
4 changes: 4 additions & 0 deletions packages/compiler/src/frontend/npm-static.ts
Original file line number Diff line number Diff line change
Expand Up @@ -455,6 +455,9 @@ export function npmStaticFsShadow(services: FrontendServices): NpmStaticFsShadow
// 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;
// tsc's interop helpers around builtin modules.
const tscHelpers = original === null ? null : services.tscBuiltinImports(original, path);
if (tscHelpers !== null) original = tscHelpers;
// A module exporting one of its functions reaches its members
// through that function.
const fnExports = original === null ? null : services.cjsFunctionExports(original, path);
Expand All @@ -469,6 +472,7 @@ export function npmStaticFsShadow(services: FrontendServices): NpmStaticFsShadow
if (exportsVar !== null) original = exportsVar;
const respelled =
split !== null ||
tscHelpers !== null ||
esm !== null ||
aliased !== null ||
fnExports !== null ||
Expand Down
8 changes: 8 additions & 0 deletions packages/compiler/src/frontend/services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
rewriteBuiltinAliasRequires,
} from "./npm-static-builtin-aliases.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";
import * as syntax from "./npm-static-declaration-syntax.js";
import { normalizeCjsExportsVar } from "./npm-static-cjs-exports-var.js";
Expand Down Expand Up @@ -106,6 +107,13 @@ export class FrontendServices {
if (!source.includes("module.exports")) return null;
return normalizeCjsFunctionExports(this.parse(path, source, "js"));
}
/** A package module with tsc's interop bindings of builtin modules
* unwrapped to plain requires (null: none). */
tscBuiltinImports(source: string, path: string): string | null {
this.ensureOpen();
if (!mayHoldTscBuiltinImport(source)) return null;
return unwrapTscBuiltinImports(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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/fixtures/npm/cases/tsc-builtin-interop/main.ts"
],
"diags": []
}
9 changes: 9 additions & 0 deletions tests/fixtures/npm/cases/tsc-builtin-interop/main.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
// A tsc-compiled dependency wraps builtin modules in its interop helpers
// (`const events_1 = __importDefault(require("events"))`, then
// `class Bus extends events_1.default.EventEmitter`; `const path =
// __importStar(require("path"))`).
import { Bus, where } from "gttscinterop";

const bus = new Bus();
bus.on("ping", (name: string) => console.log("got", name));
console.log(bus.ping("a"), where("/x/y.txt"));

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading