Skip to content
Merged
116 changes: 116 additions & 0 deletions packages/compiler/src/frontend/npm-static-exports-this.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
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().exportsThis(source, "/pkg/index.js");
}

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

test("exported functions reach sibling exports through `this`", () => {
const out = expectSameBehavior(
`'use strict';
exports.operation = function (o) { return exports.timeouts(o).length; };
exports.timeouts = function (o) {
var t = [];
for (var i = 0; i < o.retries; i++) t.push(this.createTimeout(i));
[1].forEach(() => t.push(this.createTimeout(9)));
return t;
};
exports.createTimeout = function (i) { return 10 * Math.pow(2, i); };
`,
`const lib = require("LIB"); console.log(lib.timeouts({ retries: 3 }).join(","), lib.operation({ retries: 2 }));`,
);
expect(out).toContain("exports.createTimeout(i)");
});

test("other uses of `this`, replaced exports and detached functions keep their text", () => {
// `this` as a value.
expect(rewrite(`exports.f = function () { return this; };\nexports.g = 1;\n`)).toBeNull();
// A member the module does not export.
expect(rewrite(`exports.f = function () { return this.missing; };\n`)).toBeNull();
// The exports object replaced.
expect(
rewrite(
`exports.f = function () { return this.g(); };\nexports.g = function () { return 1; };\nmodule.exports = { f: exports.f };\n`,
),
).toBeNull();
// The function handed elsewhere inside the module.
expect(
rewrite(
`exports.f = function () { return this.g(); };\nexports.g = function () { return 1; };\nsetTimeout(exports.f, 0);\n`,
),
).toBeNull();
});

test("a function another export detaches through `this` keeps its text", () => {
// `a` reads `this.b` as a value and calls it without a receiver, so `b`
// runs with the global `this`, where `c` is missing.
const out = expectSameBehavior(
`exports.a = function () {
var g = this.b;
try { return g(); } catch (e) { return e.constructor.name; }
};
exports.b = function () { return this.c(); };
exports.c = function () { return "c"; };
`,
`const lib = require("LIB"); console.log(lib.a(), lib.b());`,
);
expect(out).toContain("var g = exports.b;");
expect(out).toContain("return this.c();");
});

test("a function that declares its own `exports` binding keeps its text", () => {
// A parameter, a var, an arrow parameter, the function name and a catch
// binding each shadow the module's exports object.
for (const fn of [
`function (exports) { return this.g(); }`,
`function () { var exports = { g: () => "local" }; return this.g(); }`,
`function () { return [1].map((exports) => this.g())[0]; }`,
`function exports() { return this.g(); }`,
`function () { try { throw 0; } catch (exports) { return this.g(); } }`,
]) {
expect(
rewrite(`exports.f = ${fn};\nexports.g = function () { return "g"; };\n`),
fn,
).toBeNull();
}
// A module-scope binding named exports is not the exports object.
expect(
rewrite(
`var exports = module.exports;\nexports.f = function () { return this.g(); };\nexports.g = function () { return "g"; };\n`,
),
).toBeNull();
// The shadowed shapes run as Node runs them.
expectSameBehavior(
`exports.f = function (exports) { return this.g(); };
exports.g = function () { return "g"; };
exports.h = function () { return this.g(); };
`,
`const lib = require("LIB"); console.log(lib.f({ g: () => "shadow" }), lib.h());`,
);
});
172 changes: 172 additions & 0 deletions packages/compiler/src/frontend/npm-static-exports-this.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
/* A CommonJS module whose exported functions call each other through
* `this` (retry 0.13: `exports.timeouts = function (options) { …
* this.createTimeout(i, opts) … }`). A call `exports.timeouts()` or
* `retry.timeouts()` passes the exports object as `this`, which compiles to
* a static table with no runtime object to pass. When every `this` in such
* a function names another export the module writes (`this.X` where the
* module has `exports.X = …`), the exports object is never replaced, and
* the module itself calls the function only through `exports.f(…)` or
* `this.f(…)`, `this.X` reads `exports.X`: the same member Node reads for
* those calls. A function that declares its own `exports` binding keeps
* its `this`. Only a caller outside the module that detaches the function
* or rebinds its `this` would observe the difference. */
import * as ts from "./ts7/syntax.js";
import { applyEdits, unparen, type Edit } from "./npm-static-bundled-chunks.js";

/** True when the text may hold such a function (a cheap probe). */
export function mayHoldExportsThis(source: string): boolean {
return source.includes("this.") && source.includes("exports.");
}

/** Whether `id` names the binding a declaration introduces. */
function isDeclaredName(id: ts.Identifier): boolean {
const parent = id.parent;
if (parent === undefined) return false;
if (ts.isBindingElement(parent)) return parent.name === id;
return (
(ts.isVariableDeclaration(parent) ||
ts.isParameterDeclaration(parent) ||
ts.isFunctionDeclaration(parent) ||
ts.isFunctionExpression(parent) ||
ts.isClassDeclaration(parent) ||
ts.isClassExpression(parent)) &&
parent.name === id
);
}

/** Whether `root` declares a binding named `exports` (at any depth). */
function declaresExports(root: ts.Node): boolean {
let found = false;
ts.walkPreorder(root, (n) => {
if (found) return "stop";
if (ts.isIdentifier(n) && n.text === "exports" && isDeclaredName(n)) found = true;
return undefined;
});
return found;
}

function exportsMember(e: ts.Expression): string | null {
const u = unparen(e);
if (!ts.isPropertyAccessExpression(u) || !ts.isIdentifier(u.name)) return null;
const obj = u.expression;
if (ts.isIdentifier(obj) && obj.text === "exports") return u.name.text;
if (
ts.isPropertyAccessExpression(obj) &&
ts.isIdentifier(obj.expression) &&
obj.expression.text === "module" &&
obj.name.text === "exports"
)
return u.name.text;
return null;
}

/** The module text with `this.X` respelled `exports.X` in qualifying
* exported functions, or null. */
export function rewriteExportsThis(sf: ts.SourceFile): string | null {
if (ts.isExternalModule(sf) && !sf.fileName.endsWith(".cjs")) return null;
// Names the module exports by member assignment, and whether it ever
// replaces or rebinds the exports object.
const exported = new Set<string>();
const functions = new Map<string, ts.FunctionExpression>();
let replaced = false;
for (const stmt of sf.statements.nodes) {
if (!ts.isExpressionStatement(stmt)) continue;
const e = unparen(stmt.expression);
if (!ts.isBinaryExpression(e) || e.operatorToken.kind !== ts.SyntaxKind.EqualsToken) continue;
const name = exportsMember(e.left);
if (name !== null) {
if (exported.has(name)) functions.delete(name);
exported.add(name);
const value = unparen(e.right);
if (ts.isFunctionExpression(value) && !functions.has(name)) functions.set(name, value);
}
}
ts.walkPreorder(sf, (n) => {
if (replaced) return "stop";
if (
ts.isBinaryExpression(n) &&
n.operatorToken.kind >= ts.SyntaxKind.FirstAssignment &&
n.operatorToken.kind <= ts.SyntaxKind.LastAssignment
) {
const left = unparen(n.left);
if (ts.isIdentifier(left) && left.text === "exports") replaced = true;
if (
ts.isPropertyAccessExpression(left) &&
ts.isIdentifier(left.expression) &&
left.expression.text === "module" &&
left.name.text === "exports"
)
replaced = true;
}
return undefined;
});
if (replaced) return null;
// A module-scope `exports` binding is not the module's exports object.
if (
sf.statements.nodes.some(
(stmt) =>
(ts.isVariableStatement(stmt) &&
stmt.declarationList.declarations.nodes.some(
(d) => ts.isIdentifier(d.name) && d.name.text === "exports",
)) ||
((ts.isFunctionDeclaration(stmt) || ts.isClassDeclaration(stmt)) &&
stmt.name?.text === "exports"),
)
)
return null;
const edits: Edit[] = [];
for (const [name, fn] of functions) {
// `this` uses directly in the body (arrow functions share it).
const uses: ts.ThisExpression[] = [];
let ok = true;
const walk = (n: ts.Node): void => {
if (!ok) return;
if (n !== fn && ts.isFunctionLike(n) && !ts.isArrowFunction(n)) return;
if (ts.isClassDeclaration(n) || ts.isClassExpression(n)) return;
if (n.kind === ts.SyntaxKind.ThisKeyword) {
const parent = n.parent;
if (
parent === undefined ||
!ts.isPropertyAccessExpression(parent) ||
parent.expression !== n ||
!ts.isIdentifier(parent.name) ||
!exported.has(parent.name.text)
) {
ok = false;
return;
}
uses.push(n as ts.ThisExpression);
return;
}
n.forEachChild(walk);
};
fn.forEachChild(walk);
if (!ok || uses.length === 0) continue;
// `exports` in the respelled reads must name the module's object.
if (declaresExports(fn)) continue;
// Inside the module the function is only called through the exports,
// or through `this` in an exported function (respelled `exports`).
let detached = false;
ts.walkPreorder(sf, (n) => {
if (detached) return "stop";
if (!ts.isPropertyAccessExpression(n) || !ts.isIdentifier(n.name) || n.name.text !== name)
return undefined;
if (exportsMember(n) !== name && unparen(n.expression).kind !== ts.SyntaxKind.ThisKeyword)
return undefined;
const parent = n.parent;
const isStore =
parent !== undefined &&
ts.isBinaryExpression(parent) &&
parent.left === n &&
parent.operatorToken.kind === ts.SyntaxKind.EqualsToken;
const isCall = parent !== undefined && ts.isCallExpression(parent) && parent.expression === n;
if (!isStore && !isCall) detached = true;
return undefined;
});
if (detached) continue;
for (const use of uses)
edits.push({ start: use.getStart(sf), end: use.getEnd(), text: "exports" });
Comment thread
vercel[bot] marked this conversation as resolved.
}
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;
// Exported functions that reach sibling exports through `this`.
const exportsThis = original === null ? null : services.exportsThis(original, path);
if (exportsThis !== null) original = exportsThis;
// tsc's interop helpers around builtin modules.
const tscHelpers = original === null ? null : services.tscBuiltinImports(original, path);
if (tscHelpers !== null) original = tscHelpers;
Expand All @@ -479,6 +482,7 @@ export function npmStaticFsShadow(services: FrontendServices): NpmStaticFsShadow
split !== null ||
declSplit !== null ||
tscHelpers !== null ||
exportsThis !== 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 @@ -14,6 +14,7 @@ import {
} 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 { mayHoldExportsThis, rewriteExportsThis } from "./npm-static-exports-this.js";
import {
mayMergeRequireDeclarations,
splitRequireDeclarations,
Expand Down Expand Up @@ -118,6 +119,13 @@ export class FrontendServices {
if (!mayMergeRequireDeclarations(source)) return null;
return splitRequireDeclarations(this.parse(path, source, "js"));
}
/** A package module with `this.X` respelled `exports.X` in exported
* functions that only call other exports through `this` (null: none). */
exportsThis(source: string, path: string): string | null {
this.ensureOpen();
if (!mayHoldExportsThis(source)) return null;
return rewriteExportsThis(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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"order": [
"<repo>/tests/fixtures/npm/cases/2597-exports-this/main.ts"
],
"diags": [
{
"code": "SC0001",
"message": "Could not find a declaration file for module 'gtexportsthis'. '<repo>/tests/fixtures/npm/node_modules/gtexportsthis/index.js' implicitly has an 'any' type.\n Try `npm i --save-dev @types/gtexportsthis` if it exists or add a new declaration (.d.ts) file containing `declare module 'gtexportsthis';`",
"loc": {
"file": "<repo>/tests/fixtures/npm/cases/2597-exports-this/main.ts",
"start": 203,
"end": 218
}
}
]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"order": [
"<repo>/tests/fixtures/npm/cases/2598-retry-timeouts/main.ts"
],
"diags": [
{
"code": "SC0001",
"message": "Could not find a declaration file for module 'retry'. '<repo>/tests/fixtures/npm/node_modules/retry/index.js' implicitly has an 'any' type.\n Try `npm i --save-dev @types/retry` if it exists or add a new declaration (.d.ts) file containing `declare module 'retry';`",
"loc": {
"file": "<repo>/tests/fixtures/npm/cases/2598-retry-timeouts/main.ts",
"start": 230,
"end": 237
}
}
]
}
6 changes: 6 additions & 0 deletions tests/fixtures/npm/cases/2597-exports-this/main.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
// Exported functions that call sibling exports through `this`: a call
// through the exports object (`lib.total()`, `exports.scale()` inside the
// module) passes that object as `this`.
import lib from "gtexportsthis";

console.log(lib.scale(2), lib.total([1, 2, 3]));
12 changes: 12 additions & 0 deletions tests/fixtures/npm/cases/2598-retry-timeouts/main.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// retry 0.13.1 (vendored unmodified, MIT): `exports.timeouts` reads
// `this.createTimeout`, and `exports.operation` calls it through the exports
// object. async-retry wraps every request in such an operation.
import retry from "retry";

console.log(retry.timeouts({ retries: 4, minTimeout: 10, maxTimeout: 100, factor: 2 }).join(","));
const op = retry.operation({ retries: 2, minTimeout: 1, maxTimeout: 2 });
op.attempt((attempt: number) => {
console.log("attempt", attempt);
if (op.retry(attempt < 3 ? new Error(`fail ${attempt}`) : null)) return;
console.log("done", op.attempts(), op.errors().length);
});
14 changes: 14 additions & 0 deletions tests/fixtures/npm/node_modules/gtexportsthis/index.js

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

Loading
Loading