diff --git a/.github/actions/setup-js/action.yaml b/.github/actions/setup-js/action.yaml index f48956aba..b257d37d6 100644 --- a/.github/actions/setup-js/action.yaml +++ b/.github/actions/setup-js/action.yaml @@ -1,7 +1,7 @@ name: Set up JavaScript workspace description: >- - Install pnpm and an official Node.js 26 build, prove that it has native - Temporal, and install the locked dependencies of the workspace in `js/`. + Install pnpm and an official Node.js build, then install the locked + dependencies of the workspace in `js/`. inputs: node-version: @@ -23,13 +23,9 @@ runs: node-version: ${{ inputs.node-version }} node-version-file: ${{ inputs.node-version == '' && 'js/.node-version' || '' }} - # Official Node.js 26 binaries enable Temporal; some builds from source do - # not, and River refuses to run without it. - - name: Require native Temporal + - name: Report Node.js version shell: bash - run: | - node --version - test "$(node -p 'typeof Temporal')" = object + run: node --version - run: pnpm install --frozen-lockfile shell: bash diff --git a/.github/workflows/js.yaml b/.github/workflows/js.yaml index af5306e28..dab55a710 100644 --- a/.github/workflows/js.yaml +++ b/.github/workflows/js.yaml @@ -30,9 +30,8 @@ concurrency: permissions: contents: read -# The full runtime deliberately requires native Temporal from Node 26. Every -# job installs the official build selected by js/.node-version (or the matrix) -# and asserts `typeof Temporal` before doing anything else. +# Node 24 exercises the Temporal polyfill; Node 26 exercises native Temporal. +# Jobs use js/.node-version unless a matrix selects a different release. defaults: run: @@ -90,10 +89,15 @@ jobs: - run: pnpm run license:check packages: - name: Package archives + name: Package archives (Node ${{ matrix.node-version || 'from .node-version' }}) runs-on: ubuntu-latest timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + node-version: ["24", ""] + # The packed `node:test` suite runs its Postgres tests against this # database, and RIVER_REQUIRE_POSTGRES makes them fail instead of skip # when DATABASE_URL is missing. @@ -121,6 +125,8 @@ jobs: persist-credentials: false - uses: ./.github/actions/setup-js + with: + node-version: ${{ matrix.node-version }} - run: pnpm run build:all @@ -172,8 +178,8 @@ jobs: strategy: fail-fast: false matrix: - # The minimum supported release and the current Node 26 release. - node-version: ["26.0.0", ""] + # Current releases for both supported Node lines. + node-version: ["24", ""] steps: - uses: actions/checkout@v7 @@ -200,7 +206,7 @@ jobs: - run: pnpm run test test_integration: - name: Test (integration, Postgres ${{ matrix.postgres-version }}) + name: Test (integration, Node ${{ matrix.node-version || 'from .node-version' }}, Postgres ${{ matrix.postgres-version }}) runs-on: ubuntu-latest timeout-minutes: 20 @@ -208,6 +214,10 @@ jobs: fail-fast: false matrix: postgres-version: [14, 15, 16, 17, 18] + node-version: [""] + include: + - node-version: "24" + postgres-version: 18 services: postgres: @@ -232,6 +242,8 @@ jobs: persist-credentials: false - uses: ./.github/actions/setup-js + with: + node-version: ${{ matrix.node-version }} - run: pnpm run build:all diff --git a/Makefile b/Makefile index a3c116d43..4c9f13ab6 100644 --- a/Makefile +++ b/Makefile @@ -132,7 +132,7 @@ lint/java/tools: ## Lint Java's Go maintenance tools golangci-lint run --fix ./java/bin/sync-migrations/*.go # JavaScript targets, like the Rust ones, are separate from `lint` and `test` -# and need Node.js 26 and pnpm; they delegate to the workspace's own scripts. +# and need Node.js 24 or newer and pnpm; they delegate to the workspace's own scripts. # Run `pnpm -C js install` first. .PHONY: build/js build/js: ## Build every JavaScript package diff --git a/js/CHANGELOG.md b/js/CHANGELOG.md index b747fd449..a94f7c92d 100644 --- a/js/CHANGELOG.md +++ b/js/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- 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. + +### Fixed + +- Worker threads respect cancellation received while loading a handler module, before invoking its handler. + ## [0.2.0] - 2026-10-07 ### Added diff --git a/js/README.md b/js/README.md index 7d8f0773c..3e0f35037 100644 --- a/js/README.md +++ b/js/README.md @@ -7,21 +7,17 @@ the same jobs in the same database. ## Requirements -- **Node.js 26 with native `Temporal`.** River uses `Temporal.Instant` for +- **Node.js 24 or newer.** River uses `Temporal.Instant` for timestamps and `bigint` for IDs, so no value read from the database is lost. - Official Node.js 26 builds (nodejs.org, `actions/setup-node`, the official - Docker images) enable Temporal. Some distribution and Homebrew builds do - not; check with: - - ```sh - node -p "typeof Temporal" # must print "object" - ``` - + 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. - **TypeScript 6.0 or newer**, if you use TypeScript. Install `@types/node` - 26 or newer (and `@types/pg` with `@riverqueue/driver-pg`) and list `"node"` + 24 or newer (and `@types/pg` with `@riverqueue/driver-pg`) and list `"node"` in `compilerOptions.types`. JavaScript users need neither. - **ESM.** The packages ship one ES module build. CommonJS code can - `require()` them on Node 26, which returns the same module instance as + `require()` them on Node 24 or newer, which returns the same module instance as `import`; TypeScript CommonJS projects need `module: "node20"` or `"nodenext"`. diff --git a/js/cli/README.md b/js/cli/README.md index 32542c34a..126aa4985 100644 --- a/js/cli/README.md +++ b/js/cli/README.md @@ -2,7 +2,7 @@ The `riverqueue` command runs River's database migrations and a throughput benchmark, and upgrades code written for `riverqueue@0.1`. It requires -Node.js 26 or newer and mirrors the commands of River's Go CLI. +Node.js 24 or newer and mirrors the commands of River's Go CLI. ```sh npm install --save-dev @riverqueue/cli @@ -142,9 +142,9 @@ process.exitCode = await run(process.argv.slice(2), { ## Requirements -Node.js 26 or newer with native `Temporal`: `node -p "typeof Temporal"` must -print `object`. Official Node.js binaries include it; some builds compiled from -source, including some distribution and Homebrew packages, do not. +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`. The CLI installs its own matching `riverqueue` and database packages. diff --git a/js/cli/package.json b/js/cli/package.json index 2b61dd6de..96915fcfc 100644 --- a/js/cli/package.json +++ b/js/cli/package.json @@ -7,7 +7,7 @@ "./dist/bin.js" ], "engines": { - "node": ">=26" + "node": ">=24" }, "bin": { "riverqueue": "./dist/bin.js" @@ -55,7 +55,7 @@ "riverqueue": "workspace:0.2.0" }, "peerDependencies": { - "@types/node": ">=26", + "@types/node": ">=24", "@types/pg": ">=8", "typescript": "^5.0.0 || ^6.0.0" }, diff --git a/js/docs/development.md b/js/docs/development.md index d0b951162..eb9a28c44 100644 --- a/js/docs/development.md +++ b/js/docs/development.md @@ -7,15 +7,12 @@ Go and Rust implementations. Run the `pnpm` commands below from `js/`, or use River's top-level `make` targets (`make lint/js`, `make test/js`, and so on), which delegate to `pnpm -C js`. -Use an official Node.js 26 build, which includes native Temporal. The -workspace's `.node-version` selects Node 26 for version managers such as fnm, -nvm (`nvm use $(cat .node-version)`), and `actions/setup-node`. Some Node.js -builds compiled from source, including some distribution and Homebrew -packages, lack Temporal, and most unit tests then fail with -`ReferenceError: Temporal is not defined`. Check before installing, then use -the pnpm version pinned by `packageManager` in `package.json`: - - node -p "typeof Temporal" # must print "object" +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. +Use the pnpm version pinned by `packageManager` in `package.json`: + pnpm install TypeScript 6.0 is the minimum compiler for the published declarations, which @@ -190,13 +187,13 @@ to benchmark against a disposable database: River's `.github/workflows/js.yaml` runs on pull requests and pushes to `master` that change `js/`, River's migrations or SQL, or the workflow itself, -and on `js/v*` release tags. Each job installs the official Node.js build from -`js/.node-version` and fails unless `typeof Temporal` is `object`. The jobs +and on `js/v*` release tags. Each job installs an official Node.js build from +`js/.node-version` or its version matrix. The jobs cover build, both type-check lanes, generated migrations (compared with River's sources), API reports, TypeDoc, README snippets, the 0.1 fixture, lint, -formatting, licenses, packed archives and examples, unit tests on Node 26.0.0 -and the current Node 26 release, and integration tests on Postgres 14 -through 18. +formatting, licenses, packed archives and examples. Unit tests and packed +packages run on supported Node.js versions. Package tests include CommonJS +and TypeScript consumers. Integration tests cover supported Postgres versions. Unit tests compare unique keys, protocol values, notification dispatch, retry timing, cron schedules, and snooze counting with fixtures that diff --git a/js/docs/migrating-from-0.1.md b/js/docs/migrating-from-0.1.md index 3887c454a..f59a06241 100644 --- a/js/docs/migrating-from-0.1.md +++ b/js/docs/migrating-from-0.1.md @@ -7,9 +7,9 @@ that still needs attention. ## Runtime requirements -The package needs Node.js 26 with native `Temporal` and ships as ESM; see -[requirements](../README.md#requirements). There is no second CommonJS build -and no `Temporal` polyfill. +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. ## Codemod diff --git a/js/driver/pg/README.md b/js/driver/pg/README.md index 792c66989..8f64ff791 100644 --- a/js/driver/pg/README.md +++ b/js/driver/pg/README.md @@ -1,6 +1,6 @@ # `@riverqueue/driver-pg` -This package is River's complete Postgres backend for Node.js 26 and newer. +This package is River's complete Postgres backend for Node.js 24 and newer. It accepts a caller-owned `pg.Pool`, performs insertion and runtime operations, and implements notifications, leadership, maintenance, and queue control. @@ -94,9 +94,9 @@ driver, migrator, and CLI. Never interpolate an untrusted schema name. ## Requirements -Node.js 26 or newer with native `Temporal`: `node -p "typeof Temporal"` must -print `object`. Official Node.js binaries include it; some builds compiled from -source, including some distribution and Homebrew packages, do not. +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`. 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/package.json b/js/driver/pg/package.json index 5927888d9..542f2d529 100644 --- a/js/driver/pg/package.json +++ b/js/driver/pg/package.json @@ -14,7 +14,7 @@ } }, "engines": { - "node": ">=26" + "node": ">=24" }, "files": [ "dist", diff --git a/js/driver/prisma/README.md b/js/driver/prisma/README.md index 469f124b0..10beff902 100644 --- a/js/driver/prisma/README.md +++ b/js/driver/prisma/README.md @@ -78,9 +78,9 @@ JavaScript's safe range in arguments or metadata stay exact. ## Requirements -Node.js 26 or newer with native `Temporal`: `node -p "typeof Temporal"` must -print `object`. Official Node.js binaries include it; some builds compiled from -source, including some distribution and Homebrew packages, do not. +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`. 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/prisma/package.json b/js/driver/prisma/package.json index 5d1684ef0..ae88e4608 100644 --- a/js/driver/prisma/package.json +++ b/js/driver/prisma/package.json @@ -14,7 +14,7 @@ } }, "engines": { - "node": ">=26" + "node": ">=24" }, "files": [ "dist", diff --git a/js/driver/sqlite/README.md b/js/driver/sqlite/README.md index c6f781180..283b637f9 100644 --- a/js/driver/sqlite/README.md +++ b/js/driver/sqlite/README.md @@ -1,6 +1,6 @@ # `@riverqueue/driver-sqlite` -This package is River's preview SQLite backend for Node 26 and newer. It uses +This package is River's preview SQLite backend for Node 24 and newer. It uses the built-in synchronous `node:sqlite` API. Apply River's generated canonical migrations explicitly before constructing a @@ -241,9 +241,9 @@ listeners once per job. ## Requirements -Node.js 26 or newer with native `Temporal`: `node -p "typeof Temporal"` must -print `object`. Official Node.js binaries include it; some builds compiled from -source, including some distribution and Homebrew packages, do not. +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`. 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/package.json b/js/driver/sqlite/package.json index 1a88031b5..3f291ca68 100644 --- a/js/driver/sqlite/package.json +++ b/js/driver/sqlite/package.json @@ -5,7 +5,7 @@ "type": "module", "sideEffects": false, "engines": { - "node": ">=26" + "node": ">=24" }, "main": "./dist/index.js", "types": "./dist/index.d.ts", @@ -45,7 +45,7 @@ "provenance": true }, "peerDependencies": { - "@types/node": ">=26", + "@types/node": ">=24", "riverqueue": "workspace:0.2.0" }, "peerDependenciesMeta": { diff --git a/js/etc/riverqueue.api.md b/js/etc/riverqueue.api.md index d4f3b693e..92e2cf195 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. * - * Official Node.js 26 builds expose Temporal by default. This explicit check - * gives custom builds and unsupported runtimes a useful failure instead of a - * later `ReferenceError` or a lossy timestamp fallback. + * 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. */ export declare function assertRuntimeSupport(): void; ``` diff --git a/js/examples/graceful-shutdown/package.json b/js/examples/graceful-shutdown/package.json index 3767ce26c..0baf4fe17 100644 --- a/js/examples/graceful-shutdown/package.json +++ b/js/examples/graceful-shutdown/package.json @@ -4,7 +4,7 @@ "private": true, "type": "module", "engines": { - "node": ">=26" + "node": ">=24" }, "scripts": { "build": "tsc", diff --git a/js/examples/hooks-metrics/package.json b/js/examples/hooks-metrics/package.json index 3b0602ab0..74f67ada6 100644 --- a/js/examples/hooks-metrics/package.json +++ b/js/examples/hooks-metrics/package.json @@ -4,7 +4,7 @@ "private": true, "type": "module", "engines": { - "node": ">=26" + "node": ">=24" }, "scripts": { "build": "tsc", diff --git a/js/examples/mixed-language/package.json b/js/examples/mixed-language/package.json index c24c1826e..9b2dbac67 100644 --- a/js/examples/mixed-language/package.json +++ b/js/examples/mixed-language/package.json @@ -4,7 +4,7 @@ "private": true, "type": "module", "engines": { - "node": ">=26" + "node": ">=24" }, "scripts": { "build": "tsc", diff --git a/js/examples/node-postgres/package.json b/js/examples/node-postgres/package.json index 2434a10e0..546f67cbc 100644 --- a/js/examples/node-postgres/package.json +++ b/js/examples/node-postgres/package.json @@ -4,7 +4,7 @@ "private": true, "type": "module", "engines": { - "node": ">=26" + "node": ">=24" }, "scripts": { "build": "tsc", diff --git a/js/examples/pg-worker/package.json b/js/examples/pg-worker/package.json index ff2fca531..6545b2cae 100644 --- a/js/examples/pg-worker/package.json +++ b/js/examples/pg-worker/package.json @@ -4,7 +4,7 @@ "private": true, "type": "module", "engines": { - "node": ">=26" + "node": ">=24" }, "scripts": { "build": "tsc", diff --git a/js/examples/prisma/package.json b/js/examples/prisma/package.json index 35af42adc..af67caa8c 100644 --- a/js/examples/prisma/package.json +++ b/js/examples/prisma/package.json @@ -4,7 +4,7 @@ "private": true, "type": "module", "engines": { - "node": ">=26" + "node": ">=24" }, "scripts": { "generate": "prisma generate", diff --git a/js/examples/sqlite-worker/README.md b/js/examples/sqlite-worker/README.md index 92cff0030..192d4f773 100644 --- a/js/examples/sqlite-worker/README.md +++ b/js/examples/sqlite-worker/README.md @@ -2,7 +2,7 @@ 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 26 and the workspace packages. +and shuts down cleanly. It needs only Node.js 24 and the workspace packages. From the repository root: diff --git a/js/examples/sqlite-worker/package.json b/js/examples/sqlite-worker/package.json index 8b4c5c860..b83c615a6 100644 --- a/js/examples/sqlite-worker/package.json +++ b/js/examples/sqlite-worker/package.json @@ -4,7 +4,7 @@ "private": true, "type": "module", "engines": { - "node": ">=26" + "node": ">=24" }, "scripts": { "build": "tsc", diff --git a/js/examples/worker-thread-cpu/package.json b/js/examples/worker-thread-cpu/package.json index 28125e35c..bf4f3ac0e 100644 --- a/js/examples/worker-thread-cpu/package.json +++ b/js/examples/worker-thread-cpu/package.json @@ -4,7 +4,7 @@ "private": true, "type": "module", "engines": { - "node": ">=26" + "node": ">=24" }, "scripts": { "build": "tsc", diff --git a/js/migrate/README.md b/js/migrate/README.md index 25a3ae3f0..81b0bd015 100644 --- a/js/migrate/README.md +++ b/js/migrate/README.md @@ -1,7 +1,7 @@ # `@riverqueue/migrate` River's Postgres and SQLite migrations, with a runner that applies them. -Requires Node.js 26 or newer. +Requires Node.js 24 or newer. River never migrates on its own: clients don't touch the schema when they are constructed or started. Run migrations as a deployment step, either with this @@ -119,9 +119,9 @@ checked for drift, so don't edit it by hand. ## Requirements -Node.js 26 or newer with native `Temporal`: `node -p "typeof Temporal"` must -print `object`. Official Node.js binaries include it; some builds compiled from -source, including some distribution and Homebrew packages, do not. +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`. 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/migrate/package.json b/js/migrate/package.json index 3bf4090a5..c8294eef9 100644 --- a/js/migrate/package.json +++ b/js/migrate/package.json @@ -5,7 +5,7 @@ "type": "module", "sideEffects": false, "engines": { - "node": ">=26" + "node": ">=24" }, "main": "./dist/index.js", "types": "./dist/index.d.ts", @@ -45,7 +45,7 @@ "provenance": true }, "peerDependencies": { - "@types/node": ">=26", + "@types/node": ">=24", "riverqueue": "workspace:0.2.0" }, "peerDependenciesMeta": { diff --git a/js/package.json b/js/package.json index 1b8ef963a..a617dfbff 100644 --- a/js/package.json +++ b/js/package.json @@ -3,7 +3,14 @@ "version": "0.2.0", "description": "TypeScript implementation of River, a fast and reliable background job system.", "type": "module", - "sideEffects": false, + "sideEffects": [ + "./dist/index.js", + "./dist/unstable-driver.js", + "./dist/internal/temporal.js", + "./src/index.ts", + "./src/unstable-driver.ts", + "./src/internal/temporal.ts" + ], "main": "./dist/index.js", "types": "./dist/index.d.ts", "exports": { @@ -19,7 +26,7 @@ } }, "engines": { - "node": ">=26" + "node": ">=24" }, "files": [ "dist", @@ -72,8 +79,11 @@ "access": "public", "provenance": true }, + "dependencies": { + "@js-temporal/polyfill": "^0.5.1" + }, "peerDependencies": { - "@types/node": ">=26" + "@types/node": ">=24" }, "peerDependenciesMeta": { "@types/node": { diff --git a/js/pnpm-lock.yaml b/js/pnpm-lock.yaml index fa5c8f39c..742e4fed1 100644 --- a/js/pnpm-lock.yaml +++ b/js/pnpm-lock.yaml @@ -14,6 +14,10 @@ overrides: importers: .: + dependencies: + '@js-temporal/polyfill': + specifier: ^0.5.1 + version: 0.5.1 devDependencies: '@arethetypeswrong/cli': specifier: ^0.18.5 @@ -546,6 +550,10 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@js-temporal/polyfill@0.5.1': + resolution: {integrity: sha512-hloP58zRVCRSpgDxmqCWJNlizAlUgJFqG2ypq79DCvyv9tHjRYMDOcPFjzfl/A1/YxDvRCZz8wvZvmapQnKwFQ==} + engines: {node: '>=12'} + '@loaderkit/resolve@1.0.6': resolution: {integrity: sha512-G8FdIoF5CypfwmD9rl8BXod5HDn8JqB0CCNBXDTaRZ+yRYhARrrSToX1zg1zy9jX3zLqigsELwhT4gNtkdQAUg==} @@ -1939,6 +1947,9 @@ packages: js-tokens@10.0.0: resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} + jsbi@4.3.2: + resolution: {integrity: sha512-9fqMSQbhJykSeii05nxKl4m6Eqn2P6rOlYiS+C5Dr/HPIU/7yZxu5qzbs40tgaFORiw2Amd0mirjxatXYMkIew==} + json-buffer@3.0.1: resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} @@ -3114,6 +3125,10 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@js-temporal/polyfill@0.5.1': + dependencies: + jsbi: 4.3.2 + '@loaderkit/resolve@1.0.6': dependencies: '@braidai/lang': 1.1.2 @@ -4491,6 +4506,8 @@ snapshots: js-tokens@10.0.0: {} + jsbi@4.3.2: {} + json-buffer@3.0.1: {} json-parse-even-better-errors@5.0.0: {} diff --git a/js/scripts/check-packages.mjs b/js/scripts/check-packages.mjs index f2ff305b2..d97cbf098 100644 --- a/js/scripts/check-packages.mjs +++ b/js/scripts/check-packages.mjs @@ -17,6 +17,8 @@ import process from "node:process"; import { fileURLToPath } from "node:url"; import { promisify } from "node:util"; +import { build as buildWithVite } from "vite"; + import { assertNoDeniedContent, assertPortableArchivePath, @@ -165,7 +167,7 @@ async function inspectArchive(packageSpec, archivePath) { `${packageSpec.name} packages the canonical MPL-2.0 text` ); assert.equal(packageJson.license, "MPL-2.0"); - assert.equal(packageJson.engines?.node, ">=26"); + assert.equal(packageJson.engines?.node, ">=24"); assert.equal(packageJson.publishConfig?.access, "public"); assert.equal(packageJson.publishConfig?.provenance, true); assert.equal(packageJson.authors, undefined, "`authors` is not an npm field"); @@ -415,6 +417,32 @@ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed) || ); await run(process.execPath, [join(consumerDirectory, "import.mjs")]); + // A bundler must retain Temporal initialization 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"); +} +` + ); + await buildWithVite({ + build: { + outDir: "bundled", + ssr: join(consumerDirectory, "bundle-entry.mjs"), + target: "node24", + }, + configFile: false, + logLevel: "error", + root: consumerDirectory, + ssr: { noExternal: ["riverqueue", "@js-temporal/polyfill", "jsbi"] }, + }); + await run(process.execPath, [ + join(consumerDirectory, "bundled", "bundle-entry.js"), + ]); + await writeFile( join(consumerDirectory, "require.cjs"), ` @@ -703,7 +731,8 @@ void [workers, exactNumber, uniqueStates, insertion, pgInsertion, sqliteInsertio await npmInstall(consumerDirectory, [ "--save-dev", ...["@types/node", "@types/pg"].map( - (name) => `${name}@${rootPackageJson.devDependencies[name]}` + (name) => + `${name}@${name === "@types/node" ? "^24" : rootPackageJson.devDependencies[name]}` ), ]); for (const compilerPackage of ["typescript", "typescript-next"]) { @@ -721,7 +750,7 @@ void [workers, exactNumber, uniqueStates, insertion, pgInsertion, sqliteInsertio await verifyCommonJsTypeScriptConsumer(consumerDirectory); } -// The packages are ESM-only. Node 26 loads them from CommonJS through +// The packages are ESM-only. Node 24 and newer load them from CommonJS through // `require(esm)`, and TypeScript models that only for `module: node20` and // `nodenext`; `node16` and `node18` report TS1479, which is what Are The Types // Wrong's `cjs-resolves-to-esm` rule flags. Compile a CommonJS consumer in the diff --git a/js/scripts/packed-tests/packages.test.mjs b/js/scripts/packed-tests/packages.test.mjs index 594a79a3d..c6489d870 100644 --- a/js/scripts/packed-tests/packages.test.mjs +++ b/js/scripts/packed-tests/packages.test.mjs @@ -29,6 +29,32 @@ const SATELLITES = [ ]; describe("packed package graph", () => { + it("initializes Temporal from every entry point and preserves native constructors", async () => { + for (const name of [ + "riverqueue", + "riverqueue/unstable-driver", + ...SATELLITES, + ]) { + await execFileAsync( + process.execPath, + [ + "--input-type=module", + "-e", + `import assert from "node:assert/strict"; +const original = globalThis.Temporal; +await import(${JSON.stringify(name)}); +assert.equal(typeof Temporal, "object"); +if (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); +`, + ], + { cwd: consumerDirectory } + ); + } + }); + it("resolves exactly one riverqueue from every satellite package", () => { const root = require.resolve("riverqueue"); for (const name of SATELLITES) { diff --git a/js/scripts/vitest-setup.mjs b/js/scripts/vitest-setup.mjs index 44af8d667..72fdfb91d 100644 --- a/js/scripts/vitest-setup.mjs +++ b/js/scripts/vitest-setup.mjs @@ -13,6 +13,10 @@ 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"; + // Property tests are deterministic by default. Set FAST_CHECK_SEED to an // integer to replay a reported failure or explore another region of the // input space; FAST_CHECK_SEED=random picks a fresh seed per run. fast-check diff --git a/js/src/cron.property.test.ts b/js/src/cron.property.test.ts index 3c978059e..dc913db52 100644 --- a/js/src/cron.property.test.ts +++ b/js/src/cron.property.test.ts @@ -108,7 +108,8 @@ const instant = fc .bigInt({ max: 2_208_988_800_000_000_000n, min: 946_684_800_000_000_000n }) .map((nanoseconds) => Temporal.Instant.fromEpochNanoseconds(nanoseconds)); -describe("cron properties", () => { +// The Temporal polyfill on Node 24 needs more time for 300 cases on CI runners. +describe("cron properties", { timeout: 30_000 }, () => { it("returns strictly increasing whole-second occurrences", () => { fc.assert( fc.property( diff --git a/js/src/index.ts b/js/src/index.ts index ed04a2d6d..39e6bcdbf 100644 --- a/js/src/index.ts +++ b/js/src/index.ts @@ -2,6 +2,8 @@ // The public declarations use the global `Temporal` types. The preserved // reference loads them for TypeScript consumers without a `lib` setting. +import "./internal/temporal.js"; + export { Client } from "./client.js"; export type { CheckedInsertManyItems, diff --git a/js/src/internal/temporal.ts b/js/src/internal/temporal.ts new file mode 100644 index 000000000..b0b5f0c58 --- /dev/null +++ b/js/src/internal/temporal.ts @@ -0,0 +1,13 @@ +// 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 68dd37241..09761d9e9 100644 --- a/js/src/runtime-support.test.ts +++ b/js/src/runtime-support.test.ts @@ -8,19 +8,30 @@ describe("assertRuntimeSupport", () => { vi.unstubAllGlobals(); }); - it("accepts the supported Node runtime and native Temporal implementation", () => { + it("accepts the supported Node runtime and Temporal implementation", () => { expect(() => assertRuntimeSupport()).not.toThrow(); expect( Temporal.Instant.from("2026-08-30T18:00:00.123456Z").toString() ).toBe("2026-08-30T18:00:00.123456Z"); }); - it("points runtimes without native Temporal at the requirements", () => { + it("points runtimes without Temporal at the requirements", () => { vi.stubGlobal("Temporal", undefined); expect(() => assertRuntimeSupport()).toThrow(ConfigurationError); expect(() => assertRuntimeSupport()).toThrow( - /native Temporal API.*typeof Temporal.*river\/tree\/master\/js#requirements/u + /Temporal API.*river\/tree\/master\/js#requirements/u + ); + }); + + it("rejects Node versions older than 24", () => { + vi.stubGlobal("process", { + ...process, + versions: { ...process.versions, node: "22.0.0" }, + }); + + expect(() => assertRuntimeSupport()).toThrow( + /requires Node.js 24 or newer; found 22.0.0/u ); }); }); diff --git a/js/src/runtime-support.ts b/js/src/runtime-support.ts index eb63b0230..eaeea853e 100644 --- a/js/src/runtime-support.ts +++ b/js/src/runtime-support.ts @@ -1,3 +1,5 @@ +import "./internal/temporal.js"; + import { ConfigurationError } from "./errors.js"; const REQUIREMENTS_URL = @@ -6,15 +8,15 @@ const REQUIREMENTS_URL = /** * Verify the runtime features River relies on before database work begins. * - * Official Node.js 26 builds expose Temporal by default. This explicit check - * gives custom builds and unsupported runtimes a useful failure instead of a - * later `ReferenceError` or a lossy timestamp fallback. + * 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. */ export function assertRuntimeSupport(): void { const major = Number.parseInt(process.versions.node.split(".")[0] ?? "", 10); - if (!Number.isSafeInteger(major) || major < 26) { + if (!Number.isSafeInteger(major) || major < 24) { throw new ConfigurationError( - `River requires Node.js 26 or newer; found ${process.versions.node} ` + + `River requires Node.js 24 or newer; found ${process.versions.node} ` + `(see ${REQUIREMENTS_URL})` ); } @@ -35,9 +37,8 @@ export function assertRuntimeSupport(): void { typeof temporal.Now?.instant !== "function" ) { throw new ConfigurationError( - "River requires a Node.js build with the native Temporal API enabled; " + - "official Node.js 26 binaries include it, but some builds compiled " + - 'from source do not. `node -p "typeof Temporal"` must print "object" ' + + "River requires the Temporal API; keep the native implementation " + + "or the polyfill installed by River available on globalThis " + `(see ${REQUIREMENTS_URL})` ); } diff --git a/js/src/unstable-driver.ts b/js/src/unstable-driver.ts index efb3133f6..d6176552a 100644 --- a/js/src/unstable-driver.ts +++ b/js/src/unstable-driver.ts @@ -2,6 +2,8 @@ // The public declarations use the global `Temporal` types. The preserved // reference loads them for TypeScript consumers without a `lib` setting. +import "./internal/temporal.js"; + /** * Explicitly unstable semantic SPI for first-party River backend packages. * diff --git a/js/test/README.md b/js/test/README.md index aa5d35b63..49bcf6c5d 100644 --- a/js/test/README.md +++ b/js/test/README.md @@ -94,9 +94,9 @@ external service and exercises the complete runtime. ## Requirements -Node.js 26 or newer with native `Temporal`: `node -p "typeof Temporal"` must -print `object`. Official Node.js binaries include it; some builds compiled from -source, including some distribution and Homebrew packages, do not. +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`. 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/test/package.json b/js/test/package.json index 9c8c95649..bf2ab4ebe 100644 --- a/js/test/package.json +++ b/js/test/package.json @@ -14,7 +14,7 @@ } }, "engines": { - "node": ">=26" + "node": ">=24" }, "files": [ "dist", diff --git a/js/worker-threads/README.md b/js/worker-threads/README.md index 5ba56e997..3303b37d5 100644 --- a/js/worker-threads/README.md +++ b/js/worker-threads/README.md @@ -158,7 +158,7 @@ so an executor that is never closed does not prevent exit. ## TypeScript sources in development -Node 26 strips TypeScript types natively, and River's threads load a module's +Node 24 and newer strip TypeScript types natively, and River's threads load a module's TypeScript source when the `.js` file that a handler URL or one of its relative imports names does not exist but a `.ts` file beside it does. Keep referencing handler modules by their compiled `.js` name, as above; the same URL then @@ -184,9 +184,9 @@ run trusted handler modules. ## Requirements -Node.js 26 or newer with native `Temporal`: `node -p "typeof Temporal"` must -print `object`. Official Node.js binaries include it; some builds compiled from -source, including some distribution and Homebrew packages, do not. +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`. 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/worker-threads/package.json b/js/worker-threads/package.json index a86c99865..8e3fb62d9 100644 --- a/js/worker-threads/package.json +++ b/js/worker-threads/package.json @@ -16,7 +16,7 @@ } }, "engines": { - "node": ">=26" + "node": ">=24" }, "files": [ "dist", @@ -47,7 +47,7 @@ "provenance": true }, "peerDependencies": { - "@types/node": ">=26", + "@types/node": ">=24", "riverqueue": "workspace:0.2.0" }, "peerDependenciesMeta": { diff --git a/js/worker-threads/src/client.test.ts b/js/worker-threads/src/client.test.ts index 8fc91ec09..a281ccca3 100644 --- a/js/worker-threads/src/client.test.ts +++ b/js/worker-threads/src/client.test.ts @@ -4,6 +4,7 @@ import { SqliteDriver } from "@riverqueue/driver-sqlite"; import { createMigrator } from "@riverqueue/migrate"; import { Client, + consoleLogger, exactJsonNumber, isExactJsonNumber, LifecycleError, @@ -38,6 +39,7 @@ describe("WorkerThreads with River clients", () => { interface TestClient { readonly client: Client; readonly driver: SqliteDriver; + readonly started: Promise; } const setup = async ( @@ -56,8 +58,15 @@ describe("WorkerThreads with River clients", () => { testJob, executor.handler(testJob, { exportName, module: handlers }) ); + const started = Promise.withResolvers(); const client = new Client(driver, { ...(jobStuckThreshold === undefined ? {} : { jobStuckThreshold }), + logger: { + ...consoleLogger, + info: (_attributes, message) => { + if (message === "started") started.resolve(undefined); + }, + }, queues: { default: { fetchCooldown: { milliseconds: 1 }, @@ -67,7 +76,7 @@ describe("WorkerThreads with River clients", () => { }, workers, }); - return { client, driver }; + return { client, driver, started: started.promise }; }; it("closes an executor at the end of an await using scope", async () => { @@ -97,11 +106,11 @@ describe("WorkerThreads with River clients", () => { it("cancels a cooperative handler and reuses its thread", async () => { await using executor = new WorkerThreads({ maxThreads: 1 }); - const { client, driver } = await setup(executor, "cooperate"); + const { client, driver, started } = await setup(executor, "cooperate"); const run = await client.start(); const inserted = await client.insert(testJob, {}); - await waitFor(() => executor.diagnostics().activeThreads === 1); + await started; await client.jobs.cancel(inserted.job.id); await expect( @@ -118,13 +127,13 @@ describe("WorkerThreads with River clients", () => { it("persists an ignored cancellation only after terminating its thread", async () => { await using executor = new WorkerThreads({ maxThreads: 1 }); - const { client, driver } = await setup(executor, "spin", { + const { client, driver, started } = await setup(executor, "spin", { milliseconds: 10, }); const run = await client.start(); const inserted = await client.insert(testJob, {}); - await waitFor(() => executor.diagnostics().activeThreads === 1); + await started; await client.jobs.cancel(inserted.job.id); await expect( @@ -141,13 +150,13 @@ describe("WorkerThreads with River clients", () => { it("fails an attempt whose thread ignored a stop's abort, after the stuck threshold", async () => { await using executor = new WorkerThreads({ maxThreads: 1 }); - const { client, driver } = await setup(executor, "spin", { + const { client, driver, started } = await setup(executor, "spin", { milliseconds: 200, }); const run = await client.start(); const spinning = await client.insert(testJob, {}); - await waitFor(() => executor.diagnostics().activeThreads === 1); + await started; // The client works two jobs, so this one waits for the busy thread. const waiting = await client.insert(testJob, {}); await waitFor(() => executor.diagnostics().pendingTasks === 1); @@ -176,11 +185,11 @@ describe("WorkerThreads with River clients", () => { it("interrupts a handler that stops because of a stop's abort without using its attempt", async () => { await using executor = new WorkerThreads({ maxThreads: 1 }); - const { client, driver } = await setup(executor, "cooperate"); + const { client, driver, started } = await setup(executor, "cooperate"); const run = await client.start(); const inserted = await client.insert(testJob, {}); - await waitFor(() => executor.diagnostics().activeThreads === 1); + await started; await run.stop({ mode: "cancel" }); await expect(client.jobs.get(inserted.job.id)).resolves.toMatchObject({ diff --git a/js/worker-threads/src/index.test.ts b/js/worker-threads/src/index.test.ts index 76f70317b..bf12cb0ca 100644 --- a/js/worker-threads/src/index.test.ts +++ b/js/worker-threads/src/index.test.ts @@ -67,6 +67,27 @@ describe("WorkerThreads", () => { expect(executor.diagnostics()).toMatchObject({ totalThreads: 0 }); }); + it("does not invoke a handler aborted while its module loads", async () => { + await using executor = new WorkerThreads({ maxThreads: 1 }); + const loading = signals(); + const target = executor.handler(testJob, { + exportName: "complete", + module: new URL("./testdata/loading.js", import.meta.url), + }); + const handle = executor.start(context({}, loading), target.handler); + const rejected = expect(handle.result).rejects.toMatchObject({ + message: "cancelled while loading", + }); + + await loading.next(); + await expect( + handle.abort(new Error("cancelled while loading"), { + gracePeriod: gracePeriod(1_000), + }) + ).resolves.toEqual({ terminated: false }); + await rejected; + }); + it("reports an attempt as started only once a thread takes it", async () => { const executor = new WorkerThreads({ maxThreads: 1 }); const started = signals(); @@ -867,10 +888,10 @@ describe("WorkerThreads", () => { }); }); - it("revives execution timestamps with the worker realm's native Temporal", async () => { + it("revives execution timestamps with the worker realm's Temporal", async () => { const executor = new WorkerThreads({ maxThreads: 1 }); const target = executor.handler(testJob, { - exportName: "confirmNativeTemporal", + exportName: "confirmTemporal", module: handlers, }); diff --git a/js/worker-threads/src/testdata/handlers.ts b/js/worker-threads/src/testdata/handlers.ts index c69f81e2d..360339881 100644 --- a/js/worker-threads/src/testdata/handlers.ts +++ b/js/worker-threads/src/testdata/handlers.ts @@ -27,9 +27,9 @@ export const allocateForever: TestHandler = ({ logger }) => { export const complete: TestHandler = ({ job }) => completeOutcome({ output: { value: job.args.value ?? null } }); -export const confirmNativeTemporal: TestHandler = ({ execution }) => { +export const confirmTemporal: TestHandler = ({ execution }) => { if (!(execution.startedAt instanceof Temporal.Instant)) { - throw new TypeError("startedAt is not a native Temporal.Instant"); + throw new TypeError("startedAt is not a Temporal.Instant from this realm"); } return completeOutcome({ output: { startedAt: execution.startedAt.toString() }, diff --git a/js/worker-threads/src/testdata/loading.ts b/js/worker-threads/src/testdata/loading.ts new file mode 100644 index 000000000..cf8da4a64 --- /dev/null +++ b/js/worker-threads/src/testdata/loading.ts @@ -0,0 +1,17 @@ +import { parentPort } from "node:worker_threads"; + +// The test starts this module as task 1 in a fresh pool. Report that its +// import is pending and finish importing only once the abort message arrives. +await new Promise((resolve) => { + parentPort?.once("message", () => resolve()); + parentPort?.postMessage({ + level: "info", + message: "started", + taskId: 1, + type: "log", + }); +}); + +export function complete(): never { + throw new Error("aborted handler was invoked"); +} diff --git a/js/worker-threads/src/thread.ts b/js/worker-threads/src/thread.ts index c730d6adf..a8492bb09 100644 --- a/js/worker-threads/src/thread.ts +++ b/js/worker-threads/src/thread.ts @@ -98,6 +98,9 @@ async function run(message: RunMessage, signal: AbortSignal): Promise { try { const context = workContext(message, signal); const module = (await import(message.moduleUrl)) as Record; + // Cancellation can arrive while the handler module is still loading. + // Do not invoke a handler after its signal has already been aborted. + signal.throwIfAborted(); const handler = module[message.exportName]; if (typeof handler !== "function") { throw new TypeError(