From 739f15094d4053f3d1b98a923d7160de572e8e45 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Tue, 20 Oct 2026 17:00:56 -0700 Subject: [PATCH 1/4] Pass the exports object as `this` to exported functions that call sibling exports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit retry 0.13.1's `exports.timeouts` reads `this.createTimeout`, and `exports.operation` calls it through the exports object; async-retry wraps every request in an operation. A call through the exports object passes that object as `this`, which compiles to a static table, so the call was fenced ("calling 'exports.timeouts' through the module's exports object when the function may read 'this'"). When every `this` in an exported function expression names another export the module writes (`this.X` with `exports.X = …`), the exports object is never replaced, and the module calls the function only through `exports.f(…)`, a package-file stage now respells `this.X` as `exports.X`: the member Node reads for those calls. Only a caller that detaches the function or rebinds its `this` would observe a difference. Co-Authored-By: Claude Opus 5.5 --- .../frontend/npm-static-exports-this.test.ts | 64 +++++ .../src/frontend/npm-static-exports-this.ts | 121 ++++++++++ packages/compiler/src/frontend/npm-static.ts | 5 + packages/compiler/src/frontend/services.ts | 8 + .../npm/cases/2597-exports-this/main.ts | 6 + .../npm/cases/2598-retry-timeouts/main.ts | 12 + .../npm/node_modules/gtexportsthis/index.js | 14 ++ .../node_modules/gtexportsthis/package.json | 1 + tests/fixtures/npm/node_modules/retry/License | 21 ++ .../fixtures/npm/node_modules/retry/README.md | 227 ++++++++++++++++++ .../npm/node_modules/retry/example/dns.js | 31 +++ .../npm/node_modules/retry/example/stop.js | 40 +++ .../fixtures/npm/node_modules/retry/index.js | 1 + .../npm/node_modules/retry/lib/retry.js | 100 ++++++++ .../node_modules/retry/lib/retry_operation.js | 162 +++++++++++++ .../npm/node_modules/retry/package.json | 36 +++ tests/harness/npm-cases.ts | 4 +- tests/harness/npm-static.test.ts | 13 +- 18 files changed, 863 insertions(+), 3 deletions(-) create mode 100644 packages/compiler/src/frontend/npm-static-exports-this.test.ts create mode 100644 packages/compiler/src/frontend/npm-static-exports-this.ts create mode 100644 tests/fixtures/npm/cases/2597-exports-this/main.ts create mode 100644 tests/fixtures/npm/cases/2598-retry-timeouts/main.ts create mode 100644 tests/fixtures/npm/node_modules/gtexportsthis/index.js create mode 100644 tests/fixtures/npm/node_modules/gtexportsthis/package.json create mode 100644 tests/fixtures/npm/node_modules/retry/License create mode 100644 tests/fixtures/npm/node_modules/retry/README.md create mode 100644 tests/fixtures/npm/node_modules/retry/example/dns.js create mode 100644 tests/fixtures/npm/node_modules/retry/example/stop.js create mode 100644 tests/fixtures/npm/node_modules/retry/index.js create mode 100644 tests/fixtures/npm/node_modules/retry/lib/retry.js create mode 100644 tests/fixtures/npm/node_modules/retry/lib/retry_operation.js create mode 100644 tests/fixtures/npm/node_modules/retry/package.json diff --git a/packages/compiler/src/frontend/npm-static-exports-this.test.ts b/packages/compiler/src/frontend/npm-static-exports-this.test.ts new file mode 100644 index 0000000000..462ee6a197 --- /dev/null +++ b/packages/compiler/src/frontend/npm-static-exports-this.test.ts @@ -0,0 +1,64 @@ +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(); +}); diff --git a/packages/compiler/src/frontend/npm-static-exports-this.ts b/packages/compiler/src/frontend/npm-static-exports-this.ts new file mode 100644 index 0000000000..dc74d39661 --- /dev/null +++ b/packages/compiler/src/frontend/npm-static-exports-this.ts @@ -0,0 +1,121 @@ +/* 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(…)`, `this.X` + * reads `exports.X`: the same member Node reads for those calls. Only a + * caller 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."); +} + +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(); + const functions = new Map(); + 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; + 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; + // Inside the module the function is only called through the 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) 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" }); + } + if (edits.length === 0) return null; + return applyEdits(sf.text, 0, sf.text.length, edits); +} diff --git a/packages/compiler/src/frontend/npm-static.ts b/packages/compiler/src/frontend/npm-static.ts index 2454b35469..5975f3679b 100644 --- a/packages/compiler/src/frontend/npm-static.ts +++ b/packages/compiler/src/frontend/npm-static.ts @@ -455,6 +455,10 @@ 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; @@ -479,6 +483,7 @@ export function npmStaticFsShadow(services: FrontendServices): NpmStaticFsShadow split !== null || declSplit !== null || tscHelpers !== null || + exportsThis !== null || esm !== null || aliased !== null || fnExports !== null || diff --git a/packages/compiler/src/frontend/services.ts b/packages/compiler/src/frontend/services.ts index 5fe85f48c9..ad0d108a0e 100644 --- a/packages/compiler/src/frontend/services.ts +++ b/packages/compiler/src/frontend/services.ts @@ -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, @@ -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 { diff --git a/tests/fixtures/npm/cases/2597-exports-this/main.ts b/tests/fixtures/npm/cases/2597-exports-this/main.ts new file mode 100644 index 0000000000..2134e01f6b --- /dev/null +++ b/tests/fixtures/npm/cases/2597-exports-this/main.ts @@ -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])); diff --git a/tests/fixtures/npm/cases/2598-retry-timeouts/main.ts b/tests/fixtures/npm/cases/2598-retry-timeouts/main.ts new file mode 100644 index 0000000000..6621bff68e --- /dev/null +++ b/tests/fixtures/npm/cases/2598-retry-timeouts/main.ts @@ -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); +}); diff --git a/tests/fixtures/npm/node_modules/gtexportsthis/index.js b/tests/fixtures/npm/node_modules/gtexportsthis/index.js new file mode 100644 index 0000000000..e9a10cfe09 --- /dev/null +++ b/tests/fixtures/npm/node_modules/gtexportsthis/index.js @@ -0,0 +1,14 @@ +'use strict'; +exports.scale = function (n) { + return n * this.factor(); +}; +exports.factor = function () { + return 3; +}; +exports.total = function (xs) { + var sum = 0; + xs.forEach((x) => { + sum += this.scale(x); + }); + return exports.scale(0) + sum; +}; diff --git a/tests/fixtures/npm/node_modules/gtexportsthis/package.json b/tests/fixtures/npm/node_modules/gtexportsthis/package.json new file mode 100644 index 0000000000..aeb53858da --- /dev/null +++ b/tests/fixtures/npm/node_modules/gtexportsthis/package.json @@ -0,0 +1 @@ +{"name":"gtexportsthis","version":"1.0.0","main":"index.js"} diff --git a/tests/fixtures/npm/node_modules/retry/License b/tests/fixtures/npm/node_modules/retry/License new file mode 100644 index 0000000000..0b58de379f --- /dev/null +++ b/tests/fixtures/npm/node_modules/retry/License @@ -0,0 +1,21 @@ +Copyright (c) 2011: +Tim Koschützki (tim@debuggable.com) +Felix Geisendörfer (felix@debuggable.com) + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. diff --git a/tests/fixtures/npm/node_modules/retry/README.md b/tests/fixtures/npm/node_modules/retry/README.md new file mode 100644 index 0000000000..6d5415300f --- /dev/null +++ b/tests/fixtures/npm/node_modules/retry/README.md @@ -0,0 +1,227 @@ + +[![Build Status](https://secure.travis-ci.org/tim-kos/node-retry.svg?branch=master)](http://travis-ci.org/tim-kos/node-retry "Check this project's build status on TravisCI") +[![codecov](https://codecov.io/gh/tim-kos/node-retry/branch/master/graph/badge.svg)](https://codecov.io/gh/tim-kos/node-retry) + + +# retry + +Abstraction for exponential and custom retry strategies for failed operations. + +## Installation + + npm install retry + +## Current Status + +This module has been tested and is ready to be used. + +## Tutorial + +The example below will retry a potentially failing `dns.resolve` operation +`10` times using an exponential backoff strategy. With the default settings, this +means the last attempt is made after `17 minutes and 3 seconds`. + +``` javascript +var dns = require('dns'); +var retry = require('retry'); + +function faultTolerantResolve(address, cb) { + var operation = retry.operation(); + + operation.attempt(function(currentAttempt) { + dns.resolve(address, function(err, addresses) { + if (operation.retry(err)) { + return; + } + + cb(err ? operation.mainError() : null, addresses); + }); + }); +} + +faultTolerantResolve('nodejs.org', function(err, addresses) { + console.log(err, addresses); +}); +``` + +Of course you can also configure the factors that go into the exponential +backoff. See the API documentation below for all available settings. +currentAttempt is an int representing the number of attempts so far. + +``` javascript +var operation = retry.operation({ + retries: 5, + factor: 3, + minTimeout: 1 * 1000, + maxTimeout: 60 * 1000, + randomize: true, +}); +``` + +## API + +### retry.operation([options]) + +Creates a new `RetryOperation` object. `options` is the same as `retry.timeouts()`'s `options`, with three additions: + +* `forever`: Whether to retry forever, defaults to `false`. +* `unref`: Whether to [unref](https://nodejs.org/api/timers.html#timers_unref) the setTimeout's, defaults to `false`. +* `maxRetryTime`: The maximum time (in milliseconds) that the retried operation is allowed to run. Default is `Infinity`. + +### retry.timeouts([options]) + +Returns an array of timeouts. All time `options` and return values are in +milliseconds. If `options` is an array, a copy of that array is returned. + +`options` is a JS object that can contain any of the following keys: + +* `retries`: The maximum amount of times to retry the operation. Default is `10`. Seting this to `1` means `do it once, then retry it once`. +* `factor`: The exponential factor to use. Default is `2`. +* `minTimeout`: The number of milliseconds before starting the first retry. Default is `1000`. +* `maxTimeout`: The maximum number of milliseconds between two retries. Default is `Infinity`. +* `randomize`: Randomizes the timeouts by multiplying with a factor between `1` to `2`. Default is `false`. + +The formula used to calculate the individual timeouts is: + +``` +Math.min(random * minTimeout * Math.pow(factor, attempt), maxTimeout) +``` + +Have a look at [this article][article] for a better explanation of approach. + +If you want to tune your `factor` / `times` settings to attempt the last retry +after a certain amount of time, you can use wolfram alpha. For example in order +to tune for `10` attempts in `5 minutes`, you can use this equation: + +![screenshot](https://github.com/tim-kos/node-retry/raw/master/equation.gif) + +Explaining the various values from left to right: + +* `k = 0 ... 9`: The `retries` value (10) +* `1000`: The `minTimeout` value in ms (1000) +* `x^k`: No need to change this, `x` will be your resulting factor +* `5 * 60 * 1000`: The desired total amount of time for retrying in ms (5 minutes) + +To make this a little easier for you, use wolfram alpha to do the calculations: + + + +[article]: http://dthain.blogspot.com/2009/02/exponential-backoff-in-distributed.html + +### retry.createTimeout(attempt, opts) + +Returns a new `timeout` (integer in milliseconds) based on the given parameters. + +`attempt` is an integer representing for which retry the timeout should be calculated. If your retry operation was executed 4 times you had one attempt and 3 retries. If you then want to calculate a new timeout, you should set `attempt` to 4 (attempts are zero-indexed). + +`opts` can include `factor`, `minTimeout`, `randomize` (boolean) and `maxTimeout`. They are documented above. + +`retry.createTimeout()` is used internally by `retry.timeouts()` and is public for you to be able to create your own timeouts for reinserting an item, see [issue #13](https://github.com/tim-kos/node-retry/issues/13). + +### retry.wrap(obj, [options], [methodNames]) + +Wrap all functions of the `obj` with retry. Optionally you can pass operation options and +an array of method names which need to be wrapped. + +``` +retry.wrap(obj) + +retry.wrap(obj, ['method1', 'method2']) + +retry.wrap(obj, {retries: 3}) + +retry.wrap(obj, {retries: 3}, ['method1', 'method2']) +``` +The `options` object can take any options that the usual call to `retry.operation` can take. + +### new RetryOperation(timeouts, [options]) + +Creates a new `RetryOperation` where `timeouts` is an array where each value is +a timeout given in milliseconds. + +Available options: +* `forever`: Whether to retry forever, defaults to `false`. +* `unref`: Wether to [unref](https://nodejs.org/api/timers.html#timers_unref) the setTimeout's, defaults to `false`. + +If `forever` is true, the following changes happen: +* `RetryOperation.errors()` will only output an array of one item: the last error. +* `RetryOperation` will repeatedly use the `timeouts` array. Once all of its timeouts have been used up, it restarts with the first timeout, then uses the second and so on. + +#### retryOperation.errors() + +Returns an array of all errors that have been passed to `retryOperation.retry()` so far. The +returning array has the errors ordered chronologically based on when they were passed to +`retryOperation.retry()`, which means the first passed error is at index zero and the last is +at the last index. + +#### retryOperation.mainError() + +A reference to the error object that occured most frequently. Errors are +compared using the `error.message` property. + +If multiple error messages occured the same amount of time, the last error +object with that message is returned. + +If no errors occured so far, the value is `null`. + +#### retryOperation.attempt(fn, timeoutOps) + +Defines the function `fn` that is to be retried and executes it for the first +time right away. The `fn` function can receive an optional `currentAttempt` callback that represents the number of attempts to execute `fn` so far. + +Optionally defines `timeoutOps` which is an object having a property `timeout` in miliseconds and a property `cb` callback function. +Whenever your retry operation takes longer than `timeout` to execute, the timeout callback function `cb` is called. + + +#### retryOperation.try(fn) + +This is an alias for `retryOperation.attempt(fn)`. This is deprecated. Please use `retryOperation.attempt(fn)` instead. + +#### retryOperation.start(fn) + +This is an alias for `retryOperation.attempt(fn)`. This is deprecated. Please use `retryOperation.attempt(fn)` instead. + +#### retryOperation.retry(error) + +Returns `false` when no `error` value is given, or the maximum amount of retries +has been reached. + +Otherwise it returns `true`, and retries the operation after the timeout for +the current attempt number. + +#### retryOperation.stop() + +Allows you to stop the operation being retried. Useful for aborting the operation on a fatal error etc. + +#### retryOperation.reset() + +Resets the internal state of the operation object, so that you can call `attempt()` again as if this was a new operation object. + +#### retryOperation.attempts() + +Returns an int representing the number of attempts it took to call `fn` before it was successful. + +## License + +retry is licensed under the MIT license. + + +# Changelog + +0.10.0 Adding `stop` functionality, thanks to @maxnachlinger. + +0.9.0 Adding `unref` functionality, thanks to @satazor. + +0.8.0 Implementing retry.wrap. + +0.7.0 Some bug fixes and made retry.createTimeout() public. Fixed issues [#10](https://github.com/tim-kos/node-retry/issues/10), [#12](https://github.com/tim-kos/node-retry/issues/12), and [#13](https://github.com/tim-kos/node-retry/issues/13). + +0.6.0 Introduced optional timeOps parameter for the attempt() function which is an object having a property timeout in milliseconds and a property cb callback function. Whenever your retry operation takes longer than timeout to execute, the timeout callback function cb is called. + +0.5.0 Some minor refactoring. + +0.4.0 Changed retryOperation.try() to retryOperation.attempt(). Deprecated the aliases start() and try() for it. + +0.3.0 Added retryOperation.start() which is an alias for retryOperation.try(). + +0.2.0 Added attempts() function and parameter to retryOperation.try() representing the number of attempts it took to call fn(). diff --git a/tests/fixtures/npm/node_modules/retry/example/dns.js b/tests/fixtures/npm/node_modules/retry/example/dns.js new file mode 100644 index 0000000000..446729b6f9 --- /dev/null +++ b/tests/fixtures/npm/node_modules/retry/example/dns.js @@ -0,0 +1,31 @@ +var dns = require('dns'); +var retry = require('../lib/retry'); + +function faultTolerantResolve(address, cb) { + var opts = { + retries: 2, + factor: 2, + minTimeout: 1 * 1000, + maxTimeout: 2 * 1000, + randomize: true + }; + var operation = retry.operation(opts); + + operation.attempt(function(currentAttempt) { + dns.resolve(address, function(err, addresses) { + if (operation.retry(err)) { + return; + } + + cb(operation.mainError(), operation.errors(), addresses); + }); + }); +} + +faultTolerantResolve('nodejs.org', function(err, errors, addresses) { + console.warn('err:'); + console.log(err); + + console.warn('addresses:'); + console.log(addresses); +}); \ No newline at end of file diff --git a/tests/fixtures/npm/node_modules/retry/example/stop.js b/tests/fixtures/npm/node_modules/retry/example/stop.js new file mode 100644 index 0000000000..e1ceafeeba --- /dev/null +++ b/tests/fixtures/npm/node_modules/retry/example/stop.js @@ -0,0 +1,40 @@ +var retry = require('../lib/retry'); + +function attemptAsyncOperation(someInput, cb) { + var opts = { + retries: 2, + factor: 2, + minTimeout: 1 * 1000, + maxTimeout: 2 * 1000, + randomize: true + }; + var operation = retry.operation(opts); + + operation.attempt(function(currentAttempt) { + failingAsyncOperation(someInput, function(err, result) { + + if (err && err.message === 'A fatal error') { + operation.stop(); + return cb(err); + } + + if (operation.retry(err)) { + return; + } + + cb(operation.mainError(), operation.errors(), result); + }); + }); +} + +attemptAsyncOperation('test input', function(err, errors, result) { + console.warn('err:'); + console.log(err); + + console.warn('result:'); + console.log(result); +}); + +function failingAsyncOperation(input, cb) { + return setImmediate(cb.bind(null, new Error('A fatal error'))); +} diff --git a/tests/fixtures/npm/node_modules/retry/index.js b/tests/fixtures/npm/node_modules/retry/index.js new file mode 100644 index 0000000000..ee62f3a112 --- /dev/null +++ b/tests/fixtures/npm/node_modules/retry/index.js @@ -0,0 +1 @@ +module.exports = require('./lib/retry'); \ No newline at end of file diff --git a/tests/fixtures/npm/node_modules/retry/lib/retry.js b/tests/fixtures/npm/node_modules/retry/lib/retry.js new file mode 100644 index 0000000000..5e85e79197 --- /dev/null +++ b/tests/fixtures/npm/node_modules/retry/lib/retry.js @@ -0,0 +1,100 @@ +var RetryOperation = require('./retry_operation'); + +exports.operation = function(options) { + var timeouts = exports.timeouts(options); + return new RetryOperation(timeouts, { + forever: options && (options.forever || options.retries === Infinity), + unref: options && options.unref, + maxRetryTime: options && options.maxRetryTime + }); +}; + +exports.timeouts = function(options) { + if (options instanceof Array) { + return [].concat(options); + } + + var opts = { + retries: 10, + factor: 2, + minTimeout: 1 * 1000, + maxTimeout: Infinity, + randomize: false + }; + for (var key in options) { + opts[key] = options[key]; + } + + if (opts.minTimeout > opts.maxTimeout) { + throw new Error('minTimeout is greater than maxTimeout'); + } + + var timeouts = []; + for (var i = 0; i < opts.retries; i++) { + timeouts.push(this.createTimeout(i, opts)); + } + + if (options && options.forever && !timeouts.length) { + timeouts.push(this.createTimeout(i, opts)); + } + + // sort the array numerically ascending + timeouts.sort(function(a,b) { + return a - b; + }); + + return timeouts; +}; + +exports.createTimeout = function(attempt, opts) { + var random = (opts.randomize) + ? (Math.random() + 1) + : 1; + + var timeout = Math.round(random * Math.max(opts.minTimeout, 1) * Math.pow(opts.factor, attempt)); + timeout = Math.min(timeout, opts.maxTimeout); + + return timeout; +}; + +exports.wrap = function(obj, options, methods) { + if (options instanceof Array) { + methods = options; + options = null; + } + + if (!methods) { + methods = []; + for (var key in obj) { + if (typeof obj[key] === 'function') { + methods.push(key); + } + } + } + + for (var i = 0; i < methods.length; i++) { + var method = methods[i]; + var original = obj[method]; + + obj[method] = function retryWrapper(original) { + var op = exports.operation(options); + var args = Array.prototype.slice.call(arguments, 1); + var callback = args.pop(); + + args.push(function(err) { + if (op.retry(err)) { + return; + } + if (err) { + arguments[0] = op.mainError(); + } + callback.apply(this, arguments); + }); + + op.attempt(function() { + original.apply(obj, args); + }); + }.bind(obj, original); + obj[method].options = options; + } +}; diff --git a/tests/fixtures/npm/node_modules/retry/lib/retry_operation.js b/tests/fixtures/npm/node_modules/retry/lib/retry_operation.js new file mode 100644 index 0000000000..105ce72b2b --- /dev/null +++ b/tests/fixtures/npm/node_modules/retry/lib/retry_operation.js @@ -0,0 +1,162 @@ +function RetryOperation(timeouts, options) { + // Compatibility for the old (timeouts, retryForever) signature + if (typeof options === 'boolean') { + options = { forever: options }; + } + + this._originalTimeouts = JSON.parse(JSON.stringify(timeouts)); + this._timeouts = timeouts; + this._options = options || {}; + this._maxRetryTime = options && options.maxRetryTime || Infinity; + this._fn = null; + this._errors = []; + this._attempts = 1; + this._operationTimeout = null; + this._operationTimeoutCb = null; + this._timeout = null; + this._operationStart = null; + this._timer = null; + + if (this._options.forever) { + this._cachedTimeouts = this._timeouts.slice(0); + } +} +module.exports = RetryOperation; + +RetryOperation.prototype.reset = function() { + this._attempts = 1; + this._timeouts = this._originalTimeouts.slice(0); +} + +RetryOperation.prototype.stop = function() { + if (this._timeout) { + clearTimeout(this._timeout); + } + if (this._timer) { + clearTimeout(this._timer); + } + + this._timeouts = []; + this._cachedTimeouts = null; +}; + +RetryOperation.prototype.retry = function(err) { + if (this._timeout) { + clearTimeout(this._timeout); + } + + if (!err) { + return false; + } + var currentTime = new Date().getTime(); + if (err && currentTime - this._operationStart >= this._maxRetryTime) { + this._errors.push(err); + this._errors.unshift(new Error('RetryOperation timeout occurred')); + return false; + } + + this._errors.push(err); + + var timeout = this._timeouts.shift(); + if (timeout === undefined) { + if (this._cachedTimeouts) { + // retry forever, only keep last error + this._errors.splice(0, this._errors.length - 1); + timeout = this._cachedTimeouts.slice(-1); + } else { + return false; + } + } + + var self = this; + this._timer = setTimeout(function() { + self._attempts++; + + if (self._operationTimeoutCb) { + self._timeout = setTimeout(function() { + self._operationTimeoutCb(self._attempts); + }, self._operationTimeout); + + if (self._options.unref) { + self._timeout.unref(); + } + } + + self._fn(self._attempts); + }, timeout); + + if (this._options.unref) { + this._timer.unref(); + } + + return true; +}; + +RetryOperation.prototype.attempt = function(fn, timeoutOps) { + this._fn = fn; + + if (timeoutOps) { + if (timeoutOps.timeout) { + this._operationTimeout = timeoutOps.timeout; + } + if (timeoutOps.cb) { + this._operationTimeoutCb = timeoutOps.cb; + } + } + + var self = this; + if (this._operationTimeoutCb) { + this._timeout = setTimeout(function() { + self._operationTimeoutCb(); + }, self._operationTimeout); + } + + this._operationStart = new Date().getTime(); + + this._fn(this._attempts); +}; + +RetryOperation.prototype.try = function(fn) { + console.log('Using RetryOperation.try() is deprecated'); + this.attempt(fn); +}; + +RetryOperation.prototype.start = function(fn) { + console.log('Using RetryOperation.start() is deprecated'); + this.attempt(fn); +}; + +RetryOperation.prototype.start = RetryOperation.prototype.try; + +RetryOperation.prototype.errors = function() { + return this._errors; +}; + +RetryOperation.prototype.attempts = function() { + return this._attempts; +}; + +RetryOperation.prototype.mainError = function() { + if (this._errors.length === 0) { + return null; + } + + var counts = {}; + var mainError = null; + var mainErrorCount = 0; + + for (var i = 0; i < this._errors.length; i++) { + var error = this._errors[i]; + var message = error.message; + var count = (counts[message] || 0) + 1; + + counts[message] = count; + + if (count >= mainErrorCount) { + mainError = error; + mainErrorCount = count; + } + } + + return mainError; +}; diff --git a/tests/fixtures/npm/node_modules/retry/package.json b/tests/fixtures/npm/node_modules/retry/package.json new file mode 100644 index 0000000000..48f35e8cff --- /dev/null +++ b/tests/fixtures/npm/node_modules/retry/package.json @@ -0,0 +1,36 @@ +{ + "author": "Tim Koschützki (http://debuggable.com/)", + "name": "retry", + "description": "Abstraction for exponential and custom retry strategies for failed operations.", + "license": "MIT", + "version": "0.13.1", + "homepage": "https://github.com/tim-kos/node-retry", + "repository": { + "type": "git", + "url": "git://github.com/tim-kos/node-retry.git" + }, + "files": [ + "lib", + "example" + ], + "directories": { + "lib": "./lib" + }, + "main": "index.js", + "engines": { + "node": ">= 4" + }, + "dependencies": {}, + "devDependencies": { + "fake": "0.2.0", + "istanbul": "^0.4.5", + "tape": "^4.8.0" + }, + "scripts": { + "test": "./node_modules/.bin/istanbul cover ./node_modules/tape/bin/tape ./test/integration/*.js", + "release:major": "env SEMANTIC=major npm run release", + "release:minor": "env SEMANTIC=minor npm run release", + "release:patch": "env SEMANTIC=patch npm run release", + "release": "npm version ${SEMANTIC:-patch} -m \"Release %s\" && git push && git push --tags && npm publish" + } +} diff --git a/tests/harness/npm-cases.ts b/tests/harness/npm-cases.ts index 6dc8e81470..6270f7b5c5 100644 --- a/tests/harness/npm-cases.ts +++ b/tests/harness/npm-cases.ts @@ -19,14 +19,14 @@ export function npmCases(fixturesRoot: string): NpmCase[] { return [ ...globSync(join(fixturesRoot, "npm/cases/*/main.ts")) .sort() - // 2465-2469, 2556-2567, 2571 and 2580-2596 are the --npm-static bundler-emitted-CJS + // 2465-2469, 2556-2567, 2571 and 2580-2598 are the --npm-static bundler-emitted-CJS // cases (npm-static.test.ts drives them with the opt-in): their // consumers exercise the static rewrite's surface (lexer-visible // names the shipped .d.ts never declares, the __toESM interop // family), so they stay out of the flagless island lane by design. .filter( (entry) => - !/\/(246[5-9]|255[6-9]|256[0-7]|2571|258[0-9]|259[0-6])-[^/]+\/main\.ts$/.test(entry), + !/\/(246[5-9]|255[6-9]|256[0-7]|2571|258[0-9]|259[0-8])-[^/]+\/main\.ts$/.test(entry), ) .map((entry) => ({ name: entry.split("/").at(-2)!, diff --git a/tests/harness/npm-static.test.ts b/tests/harness/npm-static.test.ts index 1c12d184c8..7e8a173e36 100644 --- a/tests/harness/npm-static.test.ts +++ b/tests/harness/npm-static.test.ts @@ -76,7 +76,7 @@ async function buildStatic(entry: string, npmStatic: string[] | "auto"): Promise ...globSync( join(fixturesRoot, "npm/node_modules/cryptozoo/**/*.{js,mjs,cjs,json,d.ts}"), ).sort(), - // the bundler-emitted-CJS mini packages (cases 2465-2469, 2556-2567, 2571, 2580-2596) + // the bundler-emitted-CJS mini packages (cases 2465-2469, 2556-2567, 2571, 2580-2598) ...globSync(join(fixturesRoot, "npm/node_modules/gt*/**/*.{js,cjs,json}")).sort(), ...globSync(join(fixturesRoot, "npm/store/gt*/**/*.{js,cjs,json}")).sort(), ]; @@ -1673,6 +1673,17 @@ describe(`npm-static pilots${sanitize ? " (sanitized)" : ""}`, () => { ]); }, 180_000); + // 2597/2598: exported functions that reach sibling exports through + // `this`, in a generic package and in retry 0.13.1. + test("exports through this 2597-exports-this byte-match Node", async () => { + await expectNodeParity(join(fixturesRoot, "npm/cases/2597-exports-this/main.ts"), [ + "gtexportsthis", + ]); + }, 180_000); + test("retry 0.13.1 2598-retry-timeouts byte-matches Node", async () => { + await expectNodeParity(join(fixturesRoot, "npm/cases/2598-retry-timeouts/main.ts"), ["retry"]); + }, 180_000); + // 2562: a file with unverifiable interop that no compiled code can load // (the program imports a leaf data module and only a type from the // main entry) leaves its package static. From 4dd3cfef115e4f23f4c085f9cf5a6186943bba2f Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Tue, 20 Oct 2026 17:54:18 -0700 Subject: [PATCH 2/4] Format the exports-this stage Co-Authored-By: Claude Opus 5.5 --- .../src/frontend/npm-static-exports-this.test.ts | 8 ++++++-- .../src/frontend/npm-static-exports-this.ts | 14 ++++++++++---- packages/compiler/src/frontend/npm-static.ts | 3 +-- 3 files changed, 17 insertions(+), 8 deletions(-) diff --git a/packages/compiler/src/frontend/npm-static-exports-this.test.ts b/packages/compiler/src/frontend/npm-static-exports-this.test.ts index 462ee6a197..8db75b7bbb 100644 --- a/packages/compiler/src/frontend/npm-static-exports-this.test.ts +++ b/packages/compiler/src/frontend/npm-static-exports-this.test.ts @@ -55,10 +55,14 @@ test("other uses of `this`, replaced exports and detached functions keep their t 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`), + 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`), + rewrite( + `exports.f = function () { return this.g(); };\nexports.g = function () { return 1; };\nsetTimeout(exports.f, 0);\n`, + ), ).toBeNull(); }); diff --git a/packages/compiler/src/frontend/npm-static-exports-this.ts b/packages/compiler/src/frontend/npm-static-exports-this.ts index dc74d39661..c275ef53da 100644 --- a/packages/compiler/src/frontend/npm-static-exports-this.ts +++ b/packages/compiler/src/frontend/npm-static-exports-this.ts @@ -55,7 +55,11 @@ export function rewriteExportsThis(sf: ts.SourceFile): string | null { } ts.walkPreorder(sf, (n) => { if (replaced) return "stop"; - if (ts.isBinaryExpression(n) && n.operatorToken.kind >= ts.SyntaxKind.FirstAssignment && n.operatorToken.kind <= ts.SyntaxKind.LastAssignment) { + 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 ( @@ -76,7 +80,7 @@ export function rewriteExportsThis(sf: ts.SourceFile): string | null { let ok = true; const walk = (n: ts.Node): void => { if (!ok) return; - if (n !== fn && (ts.isFunctionLike(n) && !ts.isArrowFunction(n))) 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; @@ -101,7 +105,8 @@ export function rewriteExportsThis(sf: ts.SourceFile): string | null { 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 (!ts.isPropertyAccessExpression(n) || !ts.isIdentifier(n.name) || n.name.text !== name) + return undefined; if (exportsMember(n) !== name) return undefined; const parent = n.parent; const isStore = @@ -114,7 +119,8 @@ export function rewriteExportsThis(sf: ts.SourceFile): string | null { return undefined; }); if (detached) continue; - for (const use of uses) edits.push({ start: use.getStart(sf), end: use.getEnd(), text: "exports" }); + for (const use of uses) + edits.push({ start: use.getStart(sf), end: use.getEnd(), text: "exports" }); } if (edits.length === 0) return null; return applyEdits(sf.text, 0, sf.text.length, edits); diff --git a/packages/compiler/src/frontend/npm-static.ts b/packages/compiler/src/frontend/npm-static.ts index 5975f3679b..dfbf23bcd9 100644 --- a/packages/compiler/src/frontend/npm-static.ts +++ b/packages/compiler/src/frontend/npm-static.ts @@ -456,8 +456,7 @@ export function npmStaticFsShadow(services: FrontendServices): NpmStaticFsShadow 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); + 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); From 691e21877cbec9a08f707b3b8551234c04f3ee92 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Tue, 20 Oct 2026 18:42:23 -0700 Subject: [PATCH 3/4] Record order baselines for the exports-this cases Co-Authored-By: Claude Opus 5.5 --- .../npm/cases/2597-exports-this/main.ts.json | 16 ++++++++++++++++ .../npm/cases/2598-retry-timeouts/main.ts.json | 16 ++++++++++++++++ 2 files changed, 32 insertions(+) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/npm/cases/2597-exports-this/main.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/npm/cases/2598-retry-timeouts/main.ts.json diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/npm/cases/2597-exports-this/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/npm/cases/2597-exports-this/main.ts.json new file mode 100644 index 0000000000..182827e6de --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/npm/cases/2597-exports-this/main.ts.json @@ -0,0 +1,16 @@ +{ + "order": [ + "/tests/fixtures/npm/cases/2597-exports-this/main.ts" + ], + "diags": [ + { + "code": "SC0001", + "message": "Could not find a declaration file for module 'gtexportsthis'. '/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": "/tests/fixtures/npm/cases/2597-exports-this/main.ts", + "start": 203, + "end": 218 + } + } + ] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/npm/cases/2598-retry-timeouts/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/npm/cases/2598-retry-timeouts/main.ts.json new file mode 100644 index 0000000000..96882d1d33 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/npm/cases/2598-retry-timeouts/main.ts.json @@ -0,0 +1,16 @@ +{ + "order": [ + "/tests/fixtures/npm/cases/2598-retry-timeouts/main.ts" + ], + "diags": [ + { + "code": "SC0001", + "message": "Could not find a declaration file for module 'retry'. '/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": "/tests/fixtures/npm/cases/2598-retry-timeouts/main.ts", + "start": 230, + "end": 237 + } + } + ] +} From 9ddbd94fee9eb0e167862fc0af41cf5a8e0cce3f Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Tue, 20 Oct 2026 21:18:19 -0500 Subject: [PATCH 4/4] Keep `this` in exported functions that another export detaches or that shadow exports - A sibling read as a value through `this.f` inside the module (`var g = this.b; g()`) now counts as a detached use, as `exports.f` already did, so `b` keeps its `this`. - An exported function that declares its own `exports` binding (a parameter, a `var`, an arrow parameter or its own name) keeps its text, and so does a module that declares `exports` at module scope: the respelled reads would name that binding instead of the exports object. --- .../frontend/npm-static-exports-this.test.ts | 48 ++++++++++++++++ .../src/frontend/npm-static-exports-this.ts | 57 +++++++++++++++++-- 2 files changed, 99 insertions(+), 6 deletions(-) diff --git a/packages/compiler/src/frontend/npm-static-exports-this.test.ts b/packages/compiler/src/frontend/npm-static-exports-this.test.ts index 8db75b7bbb..95d61a3cfa 100644 --- a/packages/compiler/src/frontend/npm-static-exports-this.test.ts +++ b/packages/compiler/src/frontend/npm-static-exports-this.test.ts @@ -66,3 +66,51 @@ test("other uses of `this`, replaced exports and detached functions keep their t ), ).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());`, + ); +}); diff --git a/packages/compiler/src/frontend/npm-static-exports-this.ts b/packages/compiler/src/frontend/npm-static-exports-this.ts index c275ef53da..fc547fbb44 100644 --- a/packages/compiler/src/frontend/npm-static-exports-this.ts +++ b/packages/compiler/src/frontend/npm-static-exports-this.ts @@ -5,10 +5,11 @@ * 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(…)`, `this.X` - * reads `exports.X`: the same member Node reads for those calls. Only a - * caller that detaches the function or rebinds its `this` would observe - * the difference. */ + * 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"; @@ -17,6 +18,33 @@ 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; @@ -73,6 +101,19 @@ export function rewriteExportsThis(sf: ts.SourceFile): string | null { 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). @@ -101,13 +142,17 @@ export function rewriteExportsThis(sf: ts.SourceFile): string | null { }; fn.forEachChild(walk); if (!ok || uses.length === 0) continue; - // Inside the module the function is only called through the exports. + // `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) return undefined; + if (exportsMember(n) !== name && unparen(n.expression).kind !== ts.SyntaxKind.ThisKeyword) + return undefined; const parent = n.parent; const isStore = parent !== undefined &&