diff --git a/.github/workflows/js.yaml b/.github/workflows/js.yaml index ca0e31b6f..a8fd9620f 100644 --- a/.github/workflows/js.yaml +++ b/.github/workflows/js.yaml @@ -135,7 +135,10 @@ jobs: # The packed examples that need a database use its default schema. - run: node cli/dist/bin.js migrate-up --database-url "$DATABASE_URL" - - run: pnpm run package:check + # The packed CLI suite launches the installed executable with NODE_OPTIONS + # cleared, exercising its own Temporal setup on Node 24. + - name: Verify package archives and standalone CLI + run: pnpm run package:check examples: name: Examples diff --git a/js/CHANGELOG.md b/js/CHANGELOG.md index 99db0142d..50069c580 100644 --- a/js/CHANGELOG.md +++ b/js/CHANGELOG.md @@ -11,7 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add `@riverqueue/driver-knex` for inserting jobs on Postgres inside Knex transactions, including batches, unique jobs, and nested savepoints. Configure its `knexTransactionAdapter` on `PgDriver` to use native Knex transactions with `completeTx` in workers. [PR #1516](https://github.com/riverqueue/river/pull/1516). - Support native Prisma interactive transactions with `completeTx` in workers and `{ tx }` on Postgres client operations. Configure `prismaTransactionAdapter` from `@riverqueue/driver-prisma` on `PgDriver` to commit application writes and job completion together. [PR #1517](https://github.com/riverqueue/river/pull/1517). -- Support Node.js 24 across all packages. River automatically installs a `Temporal` polyfill when the runtime has no native implementation, preserving native `Temporal` on Node.js 26. +- Support Node.js 24 across all packages. On runtimes without native `Temporal`, preload an application-provided polyfill before importing River, including in worker threads. River validates runtime support on library import and never installs or replaces a global polyfill. The standalone `riverqueue` CLI supplies its own polyfill when needed. Node.js 26 uses native `Temporal`. ### Changed diff --git a/js/README.md b/js/README.md index 14907287b..481b32820 100644 --- a/js/README.md +++ b/js/README.md @@ -9,10 +9,9 @@ the same jobs in the same database. - **Node.js 24 or newer.** River uses `Temporal.Instant` for timestamps and `bigint` for IDs, so no value read from the database is lost. - Importing River automatically installs - [`@js-temporal/polyfill`](https://github.com/js-temporal/temporal-polyfill) - globally when `Temporal` is absent, including in worker threads. Node.js 26 - uses its native implementation. No extra imports or runtime flags are needed. + Node.js 26 provides native `Temporal`. On runtimes without it, including + Node.js 24, preload a polyfill as described below. River checks for Temporal + on import and never installs or replaces it. - **TypeScript 6.0 or newer**, if you use TypeScript. Install `@types/node` 24 or newer (and `@types/pg` with `@riverqueue/driver-pg`) and list `"node"` in `compilerOptions.types`. JavaScript users need neither. @@ -24,6 +23,54 @@ the same jobs in the same database. Keep every `@riverqueue/*` package on the same version as `riverqueue`; the packages declare that as an exact peer dependency. +### Temporal on Node.js 24 + +Install a Temporal polyfill of your choice. For example: + +```sh +npm install @js-temporal/polyfill +``` + +Create `temporal-setup.mjs` in your application: + +```js +if (globalThis.Temporal === undefined) { + const { Temporal } = await import("@js-temporal/polyfill"); + Object.defineProperty(globalThis, "Temporal", { + configurable: true, + value: Temporal, + writable: true, + }); +} +``` + +Preload it before your application starts: + +```sh +node --import ./temporal-setup.mjs app.mjs +``` + +Node also preloads this module in worker threads, including those started by +`@riverqueue/worker-threads`. Setting a global only in your main application +module does not initialize worker threads. A plain import of +`@js-temporal/polyfill` does not install a global either. + +For package scripts, use an absolute path in `NODE_OPTIONS`: + +```sh +NODE_OPTIONS="--import=/absolute/path/temporal-setup.mjs" npm start +``` + +The standalone `riverqueue` CLI supplies its own polyfill and needs no preload. +Importing `@riverqueue/cli` programmatically uses the application's Temporal, +just like the other library packages. + +Use the same global Temporal constructors throughout your application when +passing values to River. Instances from another polyfill or a separate copy +may fail River's `instanceof` checks. Choose the implementation before importing +River and keep it in place for the lifetime of the process. Node.js 26 needs +no polyfill or preload. + ## Quickstart with Postgres Install the core package, the Postgres driver, migrations, and a validator diff --git a/js/cli/README.md b/js/cli/README.md index 12a26b92d..1598de306 100644 --- a/js/cli/README.md +++ b/js/cli/README.md @@ -142,9 +142,14 @@ process.exitCode = await run(process.argv.slice(2), { ## Requirements -Node.js 24 or newer. River automatically installs a global `Temporal` -polyfill when the runtime has no native implementation; Node.js 26 uses -native `Temporal`. +Node.js 24 or newer. The `riverqueue` executable supplies its own Temporal +polyfill when the global is absent and preserves native or application-provided +Temporal. No preload is needed to run CLI commands. + +When importing `run` from `@riverqueue/cli` programmatically, the application +must supply a global Temporal implementation, as with other River libraries. +On Node.js 24, [preload a polyfill](../README.md#temporal-on-nodejs-24) before +importing it. Programmatic imports never install a global. The CLI installs its own matching `riverqueue` and database packages. diff --git a/js/cli/package.json b/js/cli/package.json index f4402a3b2..feb782e99 100644 --- a/js/cli/package.json +++ b/js/cli/package.json @@ -49,6 +49,7 @@ "provenance": true }, "dependencies": { + "@js-temporal/polyfill": "^0.5.1", "@riverqueue/driver-pg": "workspace:0.2.0", "@riverqueue/migrate": "workspace:0.2.0", "pg": "^8.23.1", diff --git a/js/cli/src/bin.ts b/js/cli/src/bin.ts index ccfede91a..4a59a64ba 100644 --- a/js/cli/src/bin.ts +++ b/js/cli/src/bin.ts @@ -2,6 +2,24 @@ import process from "node:process"; -import { run } from "./run.js"; +try { + // The executable owns its runtime setup. Importing @riverqueue/cli as a + // library continues to use the application's Temporal implementation. + const temporal: unknown = Reflect.get(globalThis, "Temporal"); + if (temporal === undefined) { + const { Temporal } = await import("@js-temporal/polyfill"); + Object.defineProperty(globalThis, "Temporal", { + configurable: true, + value: Temporal, + writable: true, + }); + } -process.exitCode = await run(process.argv.slice(2)); + const { run } = await import("./run.js"); + process.exitCode = await run(process.argv.slice(2)); +} catch (error: unknown) { + const message = + error instanceof Error ? error.message : "could not initialize CLI"; + process.stderr.write(`riverqueue: ${message}\n`); + process.exitCode = 1; +} diff --git a/js/docs/development.md b/js/docs/development.md index 9dd9d845f..5cc952ccc 100644 --- a/js/docs/development.md +++ b/js/docs/development.md @@ -9,8 +9,17 @@ which delegate to `pnpm -C js`. Use Node.js 24 or newer. The workspace's `.node-version` selects Node 26 for version managers such as fnm, nvm (`nvm use $(cat .node-version)`), and -`actions/setup-node`. River installs a global Temporal polyfill when the -runtime has no native implementation; the tests use the same initialization. +`actions/setup-node`. River requires a global Temporal implementation. Test +configurations explicitly preload `scripts/temporal-setup.mjs`, which installs the +development polyfill when necessary in both the main process and worker +threads. The polyfill is not a runtime dependency of the published library +packages; the standalone CLI declares and initializes its own. +For example commands on Node 24, preload it yourself: + +```sh +export NODE_OPTIONS="--import=$(pwd)/scripts/temporal-setup.mjs" +``` + Use the pnpm version pinned by `packageManager` in `package.json`: pnpm install @@ -65,7 +74,9 @@ consumer with Node's built-in `node --test`, so plain JavaScript exercises the installed tarballs with no transpiler or workspace alias in between: SQLite and worker-thread jobs end to end with exact int64 args, one shared `riverqueue` instance and error class hierarchy across packages, `require(esm)`, subpath -exports, the CLI, and the test helpers. Its Postgres tests run in a throwaway +exports, the CLI, and the test helpers. CLI subprocesses clear `NODE_OPTIONS` +to verify help, version, and SQLite migrations without an application preload. +The same suite runs on Node 24 and Node 26 in CI. Its Postgres tests run in a throwaway schema when `DATABASE_URL` is set. Database-free examples also run in this gate. `package:examples` runs the same packed examples with Postgres when `DATABASE_URL` is set. Neither command publishes anything. diff --git a/js/docs/migrating-from-0.1.md b/js/docs/migrating-from-0.1.md index 7a8474646..5d6c8e357 100644 --- a/js/docs/migrating-from-0.1.md +++ b/js/docs/migrating-from-0.1.md @@ -8,8 +8,9 @@ that still needs attention. ## Runtime requirements The package needs Node.js 24 or newer and ships as ESM; see -[requirements](../README.md#requirements). River automatically installs a -global `Temporal` polyfill when the runtime has no native implementation. +[requirements](../README.md#requirements). On runtimes without native `Temporal`, +[preload an application-provided polyfill](../README.md#temporal-on-nodejs-24) +before importing River. ## Codemod diff --git a/js/driver/knex/README.md b/js/driver/knex/README.md index b6121b369..259530903 100644 --- a/js/driver/knex/README.md +++ b/js/driver/knex/README.md @@ -111,7 +111,8 @@ response when `queryContext?.river` is true. River needs the Postgres response's ## Requirements -Knex 3.1 or newer within the 3.x line, `pg` 8.x, and Node.js 24 or newer. This +Knex 3.1 or newer within the 3.x line, `pg` 8.x, and Node.js 24 or newer with +a global `Temporal` implementation ([Node.js 24 setup](../../README.md#temporal-on-nodejs-24)). This adapter supports Knex's Postgres client (`client: "pg"` or `"postgresql"`). For SQLite, use `@riverqueue/driver-sqlite`. diff --git a/js/driver/pg/README.md b/js/driver/pg/README.md index 4d2873bb5..0dfb0baee 100644 --- a/js/driver/pg/README.md +++ b/js/driver/pg/README.md @@ -107,9 +107,9 @@ driver, migrator, and CLI. Never interpolate an untrusted schema name. ## Requirements -Node.js 24 or newer. River automatically installs a global `Temporal` -polyfill when the runtime has no native implementation; Node.js 26 uses -native `Temporal`. +Node.js 24 or newer with a global `Temporal` implementation. Node.js 26 +provides it natively; on Node.js 24, [preload a polyfill](../../README.md#temporal-on-nodejs-24) +before importing River, including in worker threads. River never installs one. Install `riverqueue` at exactly this package's version. It is a peer dependency, so npm rejects a mismatched pair instead of loading two copies. diff --git a/js/driver/pg/src/driver.integration.test.ts b/js/driver/pg/src/driver.integration.test.ts index 9e1142f61..99588eebe 100644 --- a/js/driver/pg/src/driver.integration.test.ts +++ b/js/driver/pg/src/driver.integration.test.ts @@ -63,7 +63,9 @@ function insertParams( metadata: {}, priority: 1, queue: "default", - scheduledAt: Temporal.Now.instant(), + // The polyfill's clock can be fractionally ahead of Postgres. Default + // fixtures should already be due when a test immediately claims them. + scheduledAt: Temporal.Now.instant().subtract({ seconds: 1 }), state: "available", tags: [], uniqueKey: null, diff --git a/js/driver/prisma/README.md b/js/driver/prisma/README.md index 2cdabda5f..8984038c8 100644 --- a/js/driver/prisma/README.md +++ b/js/driver/prisma/README.md @@ -137,9 +137,9 @@ At shutdown, stop `run`, then disconnect Prisma and close the `pg` pool. ## Requirements -Node.js 24 or newer. River automatically installs a global `Temporal` -polyfill when the runtime has no native implementation; Node.js 26 uses -native `Temporal`. +Node.js 24 or newer with a global `Temporal` implementation. Node.js 26 +provides it natively; on Node.js 24, [preload a polyfill](../../README.md#temporal-on-nodejs-24) +before importing River, including in worker threads. River never installs one. Install `riverqueue` at exactly this package's version. It is a peer dependency, so npm rejects a mismatched pair instead of loading two copies. diff --git a/js/driver/sqlite/README.md b/js/driver/sqlite/README.md index 644ed6d4d..aa4f26493 100644 --- a/js/driver/sqlite/README.md +++ b/js/driver/sqlite/README.md @@ -240,9 +240,9 @@ A client writes at most one insert notification per queue per `fetchCooldown` ## Requirements -Node.js 24 or newer. River automatically installs a global `Temporal` -polyfill when the runtime has no native implementation; Node.js 26 uses -native `Temporal`. +Node.js 24 or newer with a global `Temporal` implementation. Node.js 26 +provides it natively; on Node.js 24, [preload a polyfill](../../README.md#temporal-on-nodejs-24) +before importing River, including in worker threads. River never installs one. Install `riverqueue` at exactly this package's version. It is a peer dependency, so npm rejects a mismatched pair instead of loading two copies. diff --git a/js/etc/riverqueue.api.md b/js/etc/riverqueue.api.md index 92f077049..43114308e 100644 --- a/js/etc/riverqueue.api.md +++ b/js/etc/riverqueue.api.md @@ -12,9 +12,9 @@ declarations, and file layout are omitted. /** * Verify the runtime features River relies on before database work begins. * - * River provides a Temporal polyfill when the runtime has no native - * implementation. This check gives unsupported runtimes a useful failure - * instead of a later `ReferenceError` or a lossy timestamp fallback. + * Runtimes without native Temporal must preload an application-provided + * polyfill before importing River, including in worker threads. River checks + * these requirements on import and when constructing a client. */ export declare function assertRuntimeSupport(): void; ``` diff --git a/js/examples/graceful-shutdown/README.md b/js/examples/graceful-shutdown/README.md index 47b61b8cb..f6bca08a2 100644 --- a/js/examples/graceful-shutdown/README.md +++ b/js/examples/graceful-shutdown/README.md @@ -1,5 +1,9 @@ # Graceful shutdown example +On Node.js 24, [set up Temporal](../../README.md#temporal-on-nodejs-24) and +set `NODE_OPTIONS` to preload it before running the commands below. Node.js 26 +provides Temporal natively. + This self-contained SQLite example starts one active handler and requests a graceful stop while it is still running. River stops fetching, allows the handler to finish, persists completion, and then resolves `RunHandle.stop`. diff --git a/js/examples/hooks-metrics/README.md b/js/examples/hooks-metrics/README.md index d357ae5e6..1b89c6168 100644 --- a/js/examples/hooks-metrics/README.md +++ b/js/examples/hooks-metrics/README.md @@ -1,5 +1,9 @@ # Hooks and metrics example +On Node.js 24, [set up Temporal](../../README.md#temporal-on-nodejs-24) and +set `NODE_OPTIONS` to preload it before running the commands below. Node.js 26 +provides Temporal natively. + This SQLite example connects River's ordered hooks, Koa-style middleware, and after-commit events to a tiny in-memory metrics collector. A production service would replace the collector with its existing metrics or tracing SDK; River diff --git a/js/examples/mixed-language/README.md b/js/examples/mixed-language/README.md index 98ef0074c..9e81b5f43 100644 --- a/js/examples/mixed-language/README.md +++ b/js/examples/mixed-language/README.md @@ -1,5 +1,9 @@ # Mixed-language producer example +On Node.js 24, [set up Temporal](../../README.md#temporal-on-nodejs-24) and +set `NODE_OPTIONS` to preload it before running the commands below. Node.js 26 +provides Temporal natively. + This Postgres example inserts a versioned payload under the stable job kind `mixed_language.generate_report`, then reads the durable row back without any JavaScript-private metadata. A Go, Rust, or JavaScript worker can register that diff --git a/js/examples/node-postgres/README.md b/js/examples/node-postgres/README.md index 8ea7cc5d1..3cefc2e39 100644 --- a/js/examples/node-postgres/README.md +++ b/js/examples/node-postgres/README.md @@ -1,5 +1,9 @@ # River TypeScript Example: node-postgres +On Node.js 24, [set up Temporal](../../README.md#temporal-on-nodejs-24) and +set `NODE_OPTIONS` to preload it before running the commands below. Node.js 26 +provides Temporal natively. + A minimal example demonstrating how to use the [River](https://github.com/riverqueue/river/tree/master/js) TypeScript client with `node-postgres` (`pg`) to insert background jobs into Postgres. The example defines typed jobs and shows single insertion, scheduling, batch diff --git a/js/examples/pg-worker/README.md b/js/examples/pg-worker/README.md index 4e4f1e4ba..f9ac5df2b 100644 --- a/js/examples/pg-worker/README.md +++ b/js/examples/pg-worker/README.md @@ -1,5 +1,9 @@ # Postgres worker example +On Node.js 24, [set up Temporal](../../README.md#temporal-on-nodejs-24) and +set `NODE_OPTIONS` to preload it before running the commands below. Node.js 26 +provides Temporal natively. + This example migrates a Postgres database, inserts a job, and works it in the same process: the handler snoozes once as if a payment provider were busy, then succeeds and inserts a follow-up job through the worker's own client. It diff --git a/js/examples/prisma/README.md b/js/examples/prisma/README.md index 39d6e5ea5..9e373dc38 100644 --- a/js/examples/prisma/README.md +++ b/js/examples/prisma/README.md @@ -1,5 +1,9 @@ # River TypeScript Example: Prisma +On Node.js 24, [set up Temporal](../../README.md#temporal-on-nodejs-24) and +set `NODE_OPTIONS` to preload it before running the commands below. Node.js 26 +provides Temporal natively. + A minimal example demonstrating how to use the [River](https://github.com/riverqueue/river/tree/master/js) TypeScript client with [Prisma](https://www.prisma.io/) to insert background jobs into Postgres. The example defines typed jobs and shows single insertion, scheduling, batch diff --git a/js/examples/sqlite-worker/README.md b/js/examples/sqlite-worker/README.md index 192d4f773..a4f67e982 100644 --- a/js/examples/sqlite-worker/README.md +++ b/js/examples/sqlite-worker/README.md @@ -1,8 +1,12 @@ # SQLite worker example +On Node.js 24, [set up Temporal](../../README.md#temporal-on-nodejs-24) and +set `NODE_OPTIONS` to preload it before running the commands below. Node.js 26 +provides Temporal natively. + This example applies River's SQLite migrations to an in-memory database, inserts a typed job, works it in-process, observes its committed completion, -and shuts down cleanly. It needs only Node.js 24 and the workspace packages. +and shuts down cleanly. It needs Node.js 24 or newer and the workspace packages. From the repository root: diff --git a/js/examples/worker-thread-cpu/README.md b/js/examples/worker-thread-cpu/README.md index 1f06a389a..424ef0510 100644 --- a/js/examples/worker-thread-cpu/README.md +++ b/js/examples/worker-thread-cpu/README.md @@ -1,5 +1,9 @@ # CPU worker-thread example +On Node.js 24, [set up Temporal](../../README.md#temporal-on-nodejs-24) and +set `NODE_OPTIONS` to preload it before running the commands below. Node.js 26 +provides Temporal natively. + This self-contained SQLite example routes a CPU-heavy prime calculation through the bounded `@riverqueue/worker-threads` executor. The main event loop remains available for River's database, completion, cancellation, and shutdown work. diff --git a/js/migrate/README.md b/js/migrate/README.md index 33f26f76a..0c5a2cc18 100644 --- a/js/migrate/README.md +++ b/js/migrate/README.md @@ -118,9 +118,9 @@ checked for drift, so don't edit it by hand. ## Requirements -Node.js 24 or newer. River automatically installs a global `Temporal` -polyfill when the runtime has no native implementation; Node.js 26 uses -native `Temporal`. +Node.js 24 or newer with a global `Temporal` implementation. Node.js 26 +provides it natively; on Node.js 24, [preload a polyfill](../README.md#temporal-on-nodejs-24) +before importing River, including in worker threads. River never installs one. Install `riverqueue` at exactly this package's version. It is a peer dependency, so npm rejects a mismatched pair instead of loading two copies. diff --git a/js/package.json b/js/package.json index 9aa9f90b7..94554531c 100644 --- a/js/package.json +++ b/js/package.json @@ -6,10 +6,10 @@ "sideEffects": [ "./dist/index.js", "./dist/unstable-driver.js", - "./dist/internal/temporal.js", + "./dist/internal/runtime-check.js", "./src/index.ts", "./src/unstable-driver.ts", - "./src/internal/temporal.ts" + "./src/internal/runtime-check.ts" ], "main": "./dist/index.js", "types": "./dist/index.d.ts", @@ -79,9 +79,6 @@ "access": "public", "provenance": true }, - "dependencies": { - "@js-temporal/polyfill": "^0.5.1" - }, "peerDependencies": { "@types/node": ">=24" }, @@ -93,6 +90,7 @@ "devDependencies": { "@arethetypeswrong/cli": "^0.18.5", "@eslint/js": "^10.0.1", + "@js-temporal/polyfill": "^0.5.1", "@types/node": "^26.6.5", "@types/pg": "^8.23.1", "@vitest/coverage-v8": "^4.1.12", diff --git a/js/pnpm-lock.yaml b/js/pnpm-lock.yaml index dd8709859..b3b623e16 100644 --- a/js/pnpm-lock.yaml +++ b/js/pnpm-lock.yaml @@ -14,10 +14,6 @@ overrides: importers: .: - dependencies: - '@js-temporal/polyfill': - specifier: ^0.5.1 - version: 0.5.1 devDependencies: '@arethetypeswrong/cli': specifier: ^0.18.5 @@ -25,6 +21,9 @@ importers: '@eslint/js': specifier: ^10.0.1 version: 10.0.1(eslint@10.13.0(jiti@2.7.0)) + '@js-temporal/polyfill': + specifier: ^0.5.1 + version: 0.5.1 '@types/node': specifier: ^26.6.5 version: 26.6.5 @@ -91,6 +90,9 @@ importers: cli: dependencies: + '@js-temporal/polyfill': + specifier: ^0.5.1 + version: 0.5.1 '@riverqueue/driver-pg': specifier: workspace:0.2.0 version: link:../driver/pg diff --git a/js/scripts/check-packages.mjs b/js/scripts/check-packages.mjs index 8449ac200..6bf8e2ec0 100644 --- a/js/scripts/check-packages.mjs +++ b/js/scripts/check-packages.mjs @@ -14,7 +14,7 @@ import { import { tmpdir } from "node:os"; import { dirname, join, posix, relative, resolve } from "node:path"; import process from "node:process"; -import { fileURLToPath } from "node:url"; +import { fileURLToPath, pathToFileURL } from "node:url"; import { promisify } from "node:util"; import { build as buildWithVite } from "vite"; @@ -272,8 +272,19 @@ function inspectDependencies(packageSpec, packageJson) { `${label} makes ${name} an optional peer` ); } - if (label === "riverqueue") return; + if (label === "riverqueue") { + assert.equal( + dependencies["@js-temporal/polyfill"], + undefined, + "riverqueue leaves Temporal installation to the application" + ); + return; + } if (label === "@riverqueue/cli") { + assert.ok( + dependencies["@js-temporal/polyfill"], + "the CLI declares its own Temporal polyfill dependency" + ); assert.equal( packageJson.dependencies?.riverqueue, packageJson.version, @@ -359,10 +370,9 @@ async function verifyConsumers(archiveByPackageName) { const consumerDirectory = join(temporaryDirectory, "consumer"); await mkdir(consumerDirectory); const dependencies = Object.fromEntries( - [...archiveByPackageName].map(([name, archivePath]) => [ - name, - `file:${archivePath}`, - ]) + [...archiveByPackageName] + .filter(([name]) => name !== "@riverqueue/cli") + .map(([name, archivePath]) => [name, `file:${archivePath}`]) ); await writeFile( join(consumerDirectory, "package.json"), @@ -378,22 +388,19 @@ async function verifyConsumers(archiveByPackageName) { 2 )}\n` ); - // Install without type packages first: JavaScript consumers must not need - // them, and the published metadata must not pull them in. + // Install libraries before the CLI, which owns a polyfill for its executable. + // Libraries must not install type packages or a Temporal implementation. await npmInstall(consumerDirectory); - for (const typesPackage of ["@types/node", "@types/pg"]) { - await assert.rejects( - stat(join(consumerDirectory, "node_modules", typesPackage)), - { code: "ENOENT" }, - `packed packages do not install ${typesPackage}` - ); - } + await assert.rejects( + stat(join(consumerDirectory, "node_modules", "@js-temporal/polyfill")), + { code: "ENOENT" }, + "packed libraries do not install a Temporal polyfill" + ); await writeFile( join(consumerDirectory, "import.mjs"), ` import * as river from "riverqueue"; -import * as cli from "@riverqueue/cli"; import * as knexDriver from "@riverqueue/driver-knex"; import * as pg from "@riverqueue/driver-pg"; import * as prisma from "@riverqueue/driver-prisma"; @@ -402,7 +409,7 @@ import * as migrate from "@riverqueue/migrate"; import * as testing from "@riverqueue/test"; import * as workerThreads from "@riverqueue/worker-threads"; -for (const packageNamespace of [river, cli, knexDriver, pg, prisma, sqlite, migrate, testing, workerThreads]) { +for (const packageNamespace of [river, knexDriver, pg, prisma, sqlite, migrate, testing, workerThreads]) { if (Object.keys(packageNamespace).length === 0) throw new Error("empty package namespace"); } @@ -417,16 +424,40 @@ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed) || } ` ); - await run(process.execPath, [join(consumerDirectory, "import.mjs")]); + // Native runtimes must work with no polyfill package installed at all. + if (typeof globalThis.Temporal !== "undefined") { + await run(process.execPath, [join(consumerDirectory, "import.mjs")]); + } + // Add the CLI only after proving the library packages work without a + // polyfill dependency. Programmatic CLI imports are checked below too. + const packagePath = join(consumerDirectory, "package.json"); + const packageJson = JSON.parse(await readFile(packagePath, "utf8")); + packageJson.dependencies["@riverqueue/cli"] = + `file:${archiveByPackageName.get("@riverqueue/cli")}`; + await writeFile(packagePath, `${JSON.stringify(packageJson, null, 2)}\n`); + await npmInstall(consumerDirectory); + for (const dependency of ["@types/node", "@types/pg"]) { + await assert.rejects( + stat(join(consumerDirectory, "node_modules", dependency)), + { code: "ENOENT" }, + `packed packages do not install ${dependency}` + ); + } - // A bundler must retain Temporal initialization even when most exports - // are unused. Exercise the published sideEffects metadata on Node 24 too. + // Library consumers still explicitly choose and preload their implementation. + await installTemporalSetup(consumerDirectory); + await run(process.execPath, [join(consumerDirectory, "import.mjs")], { + temporalDirectory: consumerDirectory, + }); + + // A bundler must retain runtime validation even when most exports are + // unused. Exercise the published sideEffects metadata on Node 24 too. await writeFile( join(consumerDirectory, "bundle-entry.mjs"), `import { cron } from "riverqueue"; const start = Temporal.Instant.from("2026-10-11T12:34:56Z"); if (cron("@every 1s").next(start).toString() !== "2026-10-11T12:34:57Z") { - throw new Error("bundled River did not initialize Temporal"); + throw new Error("bundled River did not use the application Temporal"); } ` ); @@ -439,10 +470,25 @@ if (cron("@every 1s").next(start).toString() !== "2026-10-11T12:34:57Z") { configFile: false, logLevel: "error", root: consumerDirectory, - ssr: { noExternal: ["riverqueue", "@js-temporal/polyfill", "jsbi"] }, + ssr: { noExternal: ["riverqueue"] }, }); + await run( + process.execPath, + [join(consumerDirectory, "bundled", "bundle-entry.js")], + { temporalDirectory: consumerDirectory } + ); await run(process.execPath, [ - join(consumerDirectory, "bundled", "bundle-entry.js"), + "--input-type=module", + "-e", + ` +import assert from "node:assert/strict"; +delete globalThis.Temporal; +await assert.rejects(import(${JSON.stringify(pathToFileURL(join(consumerDirectory, "bundled", "bundle-entry.js")).href)}), { + name: "ConfigurationError", + code: "configuration", +}); +assert.equal(globalThis.Temporal, undefined); +`, ]); await writeFile( @@ -475,11 +521,14 @@ import("riverqueue").then((imported) => { }); ` ); - await run(process.execPath, [join(consumerDirectory, "require.cjs")]); + await run(process.execPath, [join(consumerDirectory, "require.cjs")], { + temporalDirectory: consumerDirectory, + }); const { stdout: cliVersion } = await run( join(consumerDirectory, "node_modules", ".bin", "riverqueue"), - ["--version"] + ["--version"], + { env: { ...process.env, NODE_OPTIONS: "", NO_COLOR: "1" } } ); assert.match( cliVersion, @@ -524,7 +573,9 @@ driver.close(); if (!completed) throw new Error("packed SQLite worker did not complete its job"); ` ); - await run(process.execPath, [join(consumerDirectory, "sqlite-worker.mjs")]); + await run(process.execPath, [join(consumerDirectory, "sqlite-worker.mjs")], { + temporalDirectory: consumerDirectory, + }); // Exercise real behavior through the installed tarballs with Node's own // test runner, so no transpiler or workspace alias can hide a packaging @@ -537,7 +588,7 @@ if (!completed) throw new Error("packed SQLite worker did not complete its job") await run( process.execPath, ["--test", "--test-reporter=spec", "packed-tests/*.test.mjs"], - { cwd: consumerDirectory } + { cwd: consumerDirectory, temporalDirectory: consumerDirectory } ); await writeFile( @@ -884,7 +935,9 @@ main().catch((error: unknown) => { /require\("riverqueue"\)/u, `${compilerPackage} ${moduleMode} emits require() for riverqueue` ); - await run(process.execPath, [join(outDir, "commonjs-consumer.cjs")]); + await run(process.execPath, [join(outDir, "commonjs-consumer.cjs")], { + temporalDirectory: consumerDirectory, + }); } } @@ -995,6 +1048,7 @@ async function verifyExamples(archiveByPackageName) { )}\n` ); await npmInstall(examplesRoot); + await installTemporalSetup(examplesRoot); const runDatabaseExamples = typeof process.env.DATABASE_URL === "string" && @@ -1006,11 +1060,28 @@ async function verifyExamples(archiveByPackageName) { if (!spec.requiresDatabase || runDatabaseExamples) { await run("npm", ["run", "start", "--workspace", spec.name], { cwd: examplesRoot, + temporalDirectory: examplesRoot, }); } } } +async function installTemporalSetup(directory) { + const packagePath = join(directory, "package.json"); + const packageJson = JSON.parse(await readFile(packagePath, "utf8")); + packageJson.dependencies = { + ...packageJson.dependencies, + "@js-temporal/polyfill": + rootPackageJson.devDependencies["@js-temporal/polyfill"], + }; + await writeFile(packagePath, `${JSON.stringify(packageJson, null, 2)}\n`); + await npmInstall(directory); + await copyFile( + resolve(repositoryRoot, "scripts", "temporal-setup.mjs"), + join(directory, "temporal-setup.mjs") + ); +} + async function listFiles(root) { const result = []; @@ -1053,12 +1124,26 @@ function npmInstall(cwd, args = [], options = {}) { ); } -async function run(command, args, { quiet = false, ...options } = {}) { +async function run( + command, + args, + { quiet = false, temporalDirectory, ...options } = {} +) { try { return await execFileAsync(command, args, { env: { ...process.env, NO_COLOR: "1", + ...(temporalDirectory === undefined + ? {} + : { + NODE_OPTIONS: [ + process.env.NODE_OPTIONS, + `--import=${pathToFileURL(join(temporalDirectory, "temporal-setup.mjs")).href}`, + ] + .filter(Boolean) + .join(" "), + }), npm_config_cache: join(temporaryDirectory, "npm-cache"), }, maxBuffer: 10 * 1024 * 1024, diff --git a/js/scripts/packed-tests/cli.test.mjs b/js/scripts/packed-tests/cli.test.mjs index e3e44de86..4896a408b 100644 --- a/js/scripts/packed-tests/cli.test.mjs +++ b/js/scripts/packed-tests/cli.test.mjs @@ -5,7 +5,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import process from "node:process"; import { after, before, describe, it } from "node:test"; -import { fileURLToPath, URL } from "node:url"; +import { fileURLToPath, pathToFileURL, URL } from "node:url"; import { promisify } from "node:util"; const execFileAsync = promisify(execFile); @@ -13,10 +13,32 @@ const bin = fileURLToPath( new URL("../node_modules/.bin/riverqueue", import.meta.url) ); +function cliEnvironment() { + // The parent test runner preloads Temporal for library tests. Standalone + // CLI processes must supply their own implementation on Node 24. + return { ...process.env, NO_COLOR: "1", NODE_OPTIONS: "", PGDATABASE: "" }; +} + +function importCli(beforeImport, afterImport = "") { + return execFileAsync( + process.execPath, + [ + "--input-type=module", + "-e", + ` +import assert from "node:assert/strict"; +process.argv = [process.execPath, ${JSON.stringify(bin)}, "--help"]; +${beforeImport} +await import(${JSON.stringify(pathToFileURL(bin).href)}); +${afterImport} +`, + ], + { env: cliEnvironment() } + ); +} + function riverqueue(...args) { - return execFileAsync(bin, args, { - env: { ...process.env, NO_COLOR: "1", PGDATABASE: "" }, - }); + return execFileAsync(bin, args, { env: cliEnvironment() }); } describe("packed riverqueue CLI", () => { @@ -30,12 +52,45 @@ describe("packed riverqueue CLI", () => { await rm(directory, { force: true, recursive: true }); }); - it("reports its version", async () => { + it("initializes Temporal only in the executable when the global is absent", async () => { + const { stdout, stderr } = await importCli( + "delete globalThis.Temporal;", + `assert.equal(typeof globalThis.Temporal.Instant.from, "function"); +assert.equal(Temporal.Instant.from("2026-10-26T00:00:00Z").toString(), "2026-10-26T00:00:00Z");` + ); + assert.match(stdout, /Commands:/); + assert.equal(stderr, ""); + }); + + it("preserves native or application-provided Temporal in the executable", async () => { + const { stdout } = await importCli( + `const original = globalThis.Temporal ?? (await import("@js-temporal/polyfill")).Temporal; +Object.defineProperty(globalThis, "Temporal", { value: original, configurable: false, writable: false });`, + "assert.equal(globalThis.Temporal, original);" + ); + assert.match(stdout, /Commands:/); + }); + + it("reports initialization errors without a stack trace", async () => { + await assert.rejects(importCli("globalThis.Temporal = {};"), (error) => { + assert.equal(error.code, 1); + assert.equal(error.stdout, ""); + assert.match( + error.stderr, + /^riverqueue: River requires the Temporal API/ + ); + assert.match(error.stderr, /#temporal-on-nodejs-24/); + assert.equal(error.stderr.trim().split("\n").length, 1); + return true; + }); + }); + + it("reports its version without an application preload", async () => { const { stdout } = await riverqueue("--version"); assert.match(stdout, /^riverqueue version \d+\.\d+\.\d+/); }); - it("prints help for the command set", async () => { + it("prints help for the command set without an application preload", async () => { const { stdout } = await riverqueue("--help"); for (const command of [ "bench", @@ -50,7 +105,7 @@ describe("packed riverqueue CLI", () => { } }); - it("migrates and validates a SQLite database with bundled migrations", async () => { + it("migrates and validates a SQLite database without an application preload", async () => { const databaseUrl = `sqlite://${join(directory, "river.db")}`; await assert.rejects( riverqueue("validate", "--database-url", databaseUrl), diff --git a/js/scripts/packed-tests/fixtures/thread-handlers.mjs b/js/scripts/packed-tests/fixtures/thread-handlers.mjs index da27b82ca..73270199b 100644 --- a/js/scripts/packed-tests/fixtures/thread-handlers.mjs +++ b/js/scripts/packed-tests/fixtures/thread-handlers.mjs @@ -3,6 +3,9 @@ import { complete, isExactJsonNumber } from "riverqueue"; export async function echoAccount({ job, signal }) { signal.throwIfAborted(); + if (!(job.createdAt instanceof Temporal.Instant)) { + throw new TypeError("job timestamp does not use the worker's Temporal"); + } const { accountId } = job.args; if (!isExactJsonNumber(accountId)) { throw new TypeError("accountId did not cross the thread boundary exactly"); diff --git a/js/scripts/packed-tests/packages.test.mjs b/js/scripts/packed-tests/packages.test.mjs index b6b734022..e3a2ea7b5 100644 --- a/js/scripts/packed-tests/packages.test.mjs +++ b/js/scripts/packed-tests/packages.test.mjs @@ -30,7 +30,60 @@ const SATELLITES = [ ]; describe("packed package graph", () => { - it("initializes Temporal from every entry point and preserves native constructors", async () => { + it("fails helpfully without installing Temporal from any entry point", async () => { + for (const name of [ + "riverqueue", + "riverqueue/unstable-driver", + ...SATELLITES, + ]) { + await execFileAsync( + process.execPath, + [ + "--input-type=module", + "-e", + `import assert from "node:assert/strict"; +delete globalThis.Temporal; +await assert.rejects(import(${JSON.stringify(name)}), (error) => { + assert.equal(error.name, "ConfigurationError"); + assert.equal(error.code, "configuration"); + assert.match(error.message, /preload a Temporal polyfill before importing River/); + return true; +}); +assert.equal(globalThis.Temporal, undefined); +`, + ], + { cwd: consumerDirectory } + ); + } + }); + + it("preserves an application-provided polyfill from every entry point", async () => { + for (const name of [ + "riverqueue", + "riverqueue/unstable-driver", + ...SATELLITES, + ]) { + await execFileAsync( + process.execPath, + [ + "--input-type=module", + "-e", + `import assert from "node:assert/strict"; +import { Temporal } from "@js-temporal/polyfill"; +Object.defineProperty(globalThis, "Temporal", { configurable: true, value: Temporal, writable: false }); +const descriptor = Object.getOwnPropertyDescriptor(globalThis, "Temporal"); +await import(${JSON.stringify(name)}); +assert.deepEqual(Object.getOwnPropertyDescriptor(globalThis, "Temporal"), descriptor); +const { defineJob } = await import("riverqueue"); +defineJob({ kind: "application_polyfill", defaults: { scheduledAt: Temporal.Instant.from("2026-10-11T12:34:56Z") } }); +`, + ], + { cwd: consumerDirectory } + ); + } + }); + + it("preserves the runtime's Temporal from every entry point", async () => { for (const name of [ "riverqueue", "riverqueue/unstable-driver", @@ -45,7 +98,7 @@ describe("packed package graph", () => { const original = globalThis.Temporal; await import(${JSON.stringify(name)}); assert.equal(typeof Temporal, "object"); -if (original) assert.equal(Temporal, original); +assert.equal(Temporal, original); const instant = Temporal.Instant.from("2026-10-11T12:34:56.123456789Z"); assert.equal(instant.toString(), "2026-10-11T12:34:56.123456789Z"); assert.equal(Temporal.Duration.from({ seconds: 2 }).total("milliseconds"), 2000); diff --git a/js/scripts/temporal-setup.mjs b/js/scripts/temporal-setup.mjs new file mode 100644 index 000000000..928c8fbdd --- /dev/null +++ b/js/scripts/temporal-setup.mjs @@ -0,0 +1,10 @@ +// Application-style setup for tests and packed consumers. Library imports never +// install a polyfill. Preload with --import so worker threads run this too. +if (globalThis.Temporal === undefined) { + const { Temporal } = await import("@js-temporal/polyfill"); + Object.defineProperty(globalThis, "Temporal", { + configurable: true, + value: Temporal, + writable: true, + }); +} diff --git a/js/scripts/vitest-setup.mjs b/js/scripts/vitest-setup.mjs index 72fdfb91d..52eb3c27e 100644 --- a/js/scripts/vitest-setup.mjs +++ b/js/scripts/vitest-setup.mjs @@ -13,9 +13,8 @@ import { import fc from "fast-check"; import { afterAll, afterEach, beforeAll } from "vitest"; -// Tests also import internal modules directly, bypassing the public entries -// that initialize Temporal for applications. -import "../src/internal/temporal.ts"; +// Vitest isolates each test file's globals from the process preload. +import "./temporal-setup.mjs"; // Property tests are deterministic by default. Set FAST_CHECK_SEED to an // integer to replay a reported failure or explore another region of the diff --git a/js/src/index.ts b/js/src/index.ts index 39e6bcdbf..ca2d63447 100644 --- a/js/src/index.ts +++ b/js/src/index.ts @@ -2,7 +2,7 @@ // The public declarations use the global `Temporal` types. The preserved // reference loads them for TypeScript consumers without a `lib` setting. -import "./internal/temporal.js"; +import "./internal/runtime-check.js"; export { Client } from "./client.js"; export type { diff --git a/js/src/internal/runtime-check.ts b/js/src/internal/runtime-check.ts new file mode 100644 index 000000000..61a26472f --- /dev/null +++ b/js/src/internal/runtime-check.ts @@ -0,0 +1,5 @@ +import { assertRuntimeSupport } from "../runtime-support.js"; + +// Public entry points evaluate this before modules that use Temporal at import +// time, so missing runtime features produce a useful configuration error. +assertRuntimeSupport(); diff --git a/js/src/internal/temporal.ts b/js/src/internal/temporal.ts deleted file mode 100644 index b0b5f0c58..000000000 --- a/js/src/internal/temporal.ts +++ /dev/null @@ -1,13 +0,0 @@ -// Node 24 needs a polyfill; Node 26 keeps its native implementation. Install -// it before evaluating River modules so applications can use the same global -// Temporal constructors as River, including inside worker threads. -import { Temporal } from "@js-temporal/polyfill"; - -const nativeTemporal: unknown = Reflect.get(globalThis, "Temporal"); -if (nativeTemporal === undefined) { - Object.defineProperty(globalThis, "Temporal", { - configurable: true, - value: Temporal, - writable: true, - }); -} diff --git a/js/src/runtime-support.test.ts b/js/src/runtime-support.test.ts index 09761d9e9..91e226d26 100644 --- a/js/src/runtime-support.test.ts +++ b/js/src/runtime-support.test.ts @@ -15,14 +15,18 @@ describe("assertRuntimeSupport", () => { ).toBe("2026-08-30T18:00:00.123456Z"); }); - it("points runtimes without Temporal at the requirements", () => { - vi.stubGlobal("Temporal", undefined); + it.each([undefined, null, {}, { Instant: {} }])( + "points runtimes with missing or incomplete Temporal (%j) at the requirements", + (temporal) => { + vi.stubGlobal("Temporal", temporal); - expect(() => assertRuntimeSupport()).toThrow(ConfigurationError); - expect(() => assertRuntimeSupport()).toThrow( - /Temporal API.*river\/tree\/master\/js#requirements/u - ); - }); + expect(() => assertRuntimeSupport()).toThrow(ConfigurationError); + expect(() => assertRuntimeSupport()).toThrow( + /preload a Temporal polyfill before importing River.*river\/tree\/master\/js#temporal-on-nodejs-24/u + ); + expect(Reflect.get(globalThis, "Temporal")).toBe(temporal); + } + ); it("rejects Node versions older than 24", () => { vi.stubGlobal("process", { diff --git a/js/src/runtime-support.ts b/js/src/runtime-support.ts index eaeea853e..1784dfbf9 100644 --- a/js/src/runtime-support.ts +++ b/js/src/runtime-support.ts @@ -1,16 +1,16 @@ -import "./internal/temporal.js"; - import { ConfigurationError } from "./errors.js"; const REQUIREMENTS_URL = "https://github.com/riverqueue/river/tree/master/js#requirements"; +const TEMPORAL_SETUP_URL = + "https://github.com/riverqueue/river/tree/master/js#temporal-on-nodejs-24"; /** * Verify the runtime features River relies on before database work begins. * - * River provides a Temporal polyfill when the runtime has no native - * implementation. This check gives unsupported runtimes a useful failure - * instead of a later `ReferenceError` or a lossy timestamp fallback. + * Runtimes without native Temporal must preload an application-provided + * polyfill before importing River, including in worker threads. River checks + * these requirements on import and when constructing a client. */ export function assertRuntimeSupport(): void { const major = Number.parseInt(process.versions.node.split(".")[0] ?? "", 10); @@ -29,17 +29,18 @@ export function assertRuntimeSupport(): void { }; Now?: { instant?: unknown }; } + | null | undefined; if ( - temporal === undefined || + temporal == null || typeof temporal.Instant?.from !== "function" || typeof temporal.Instant.fromEpochNanoseconds !== "function" || typeof temporal.Now?.instant !== "function" ) { throw new ConfigurationError( - "River requires the Temporal API; keep the native implementation " + - "or the polyfill installed by River available on globalThis " + - `(see ${REQUIREMENTS_URL})` + "River requires the Temporal API on globalThis; use a runtime with " + + "native Temporal or preload a Temporal polyfill before importing River " + + `(see ${TEMPORAL_SETUP_URL})` ); } diff --git a/js/src/unstable-driver.ts b/js/src/unstable-driver.ts index 39acc3144..c84cb9f00 100644 --- a/js/src/unstable-driver.ts +++ b/js/src/unstable-driver.ts @@ -2,7 +2,7 @@ // The public declarations use the global `Temporal` types. The preserved // reference loads them for TypeScript consumers without a `lib` setting. -import "./internal/temporal.js"; +import "./internal/runtime-check.js"; /** * Explicitly unstable semantic SPI for first-party River backend packages. diff --git a/js/test/README.md b/js/test/README.md index a9ea5d52d..19b7a127c 100644 --- a/js/test/README.md +++ b/js/test/README.md @@ -93,9 +93,9 @@ external service and exercises the complete runtime. ## Requirements -Node.js 24 or newer. River automatically installs a global `Temporal` -polyfill when the runtime has no native implementation; Node.js 26 uses -native `Temporal`. +Node.js 24 or newer with a global `Temporal` implementation. Node.js 26 +provides it natively; on Node.js 24, [preload a polyfill](../README.md#temporal-on-nodejs-24) +before importing River, including in worker threads. River never installs one. Install `riverqueue` at exactly this package's version. It is a peer dependency, so npm rejects a mismatched pair instead of loading two copies. diff --git a/js/vitest.config.ts b/js/vitest.config.ts index 985bd2c4a..38fec3916 100644 --- a/js/vitest.config.ts +++ b/js/vitest.config.ts @@ -45,6 +45,12 @@ export default defineConfig({ // Runs with `node --test` against packed tarballs in package:check. "scripts/packed-tests/**", ], + // Vitest filters the parent's execArgv. Preload explicitly so River's + // worker threads inherit the same application-provided Temporal setup. + execArgv: [ + "--import", + path.resolve(import.meta.dirname, "scripts/temporal-setup.mjs"), + ], setupFiles: ["./scripts/vitest-setup.mjs"], }, }); diff --git a/js/vitest.integration.config.ts b/js/vitest.integration.config.ts index 899201a87..712a21038 100644 --- a/js/vitest.integration.config.ts +++ b/js/vitest.integration.config.ts @@ -22,6 +22,12 @@ export default defineConfig({ ], }, test: { + // Vitest filters the parent's execArgv. Preload explicitly so River's + // worker threads inherit the same application-provided Temporal setup. + execArgv: [ + "--import", + path.resolve(import.meta.dirname, "scripts/temporal-setup.mjs"), + ], // Every Postgres integration fixture owns the canonical River tables. // Keep files sequential so one fixture cannot truncate another's rows. fileParallelism: false, diff --git a/js/worker-threads/README.md b/js/worker-threads/README.md index 0bf6c0597..8262e482b 100644 --- a/js/worker-threads/README.md +++ b/js/worker-threads/README.md @@ -185,9 +185,9 @@ run trusted handler modules. ## Requirements -Node.js 24 or newer. River automatically installs a global `Temporal` -polyfill when the runtime has no native implementation; Node.js 26 uses -native `Temporal`. +Node.js 24 or newer with a global `Temporal` implementation. Node.js 26 +provides it natively; on Node.js 24, [preload a polyfill](../README.md#temporal-on-nodejs-24) +before importing River, including in worker threads. River never installs one. Install `riverqueue` at exactly this package's version. It is a peer dependency, so npm rejects a mismatched pair instead of loading two copies.