From d22d419c4d27618e92aef722fcb1b01296e83dc0 Mon Sep 17 00:00:00 2001 From: Daniel Salazar Date: Wed, 21 Oct 2026 13:04:23 -0700 Subject: [PATCH] chore: move to Node 26 (#4295) * chore: move to Node 26 Docker image and CI run on Node 26 (bookworm-slim, same Debian base as before). Node 26's bundled undici reads the global dispatcher from the `undici.globalDispatcher.2` slot, so the guard's 10-minute timeout wrap on `.1` no longer reached plain fetch() and provider calls fell back to the 5-minute headers timeout. The guard now wraps every present slot and tracks wrapped dispatchers in a set, since compose() can return a Proxy that writes a marker property through to the original dispatcher. * test(puter-js): disable Node's web storage in test workers Node 26 defines its own localStorage global, undefined without a storage file, and vitest's jsdom environment doesn't replace globals that already exist, so jsdom tests saw localStorage as undefined. --- .../ai-provider-integration-tests.yaml | 2 +- .github/workflows/backend-tests.yaml | 6 +- .github/workflows/docs-build.yml | 2 +- .github/workflows/puterjs-tests.yaml | 6 +- Dockerfile | 4 +- src/backend/drivers/util/imageInput.test.ts | 3 +- src/backend/util/nodeFetchDispatcherGuard.ts | 66 +++++++++++-------- src/backend/util/secureHttp.test.ts | 26 ++++++++ src/puter-js/tests/api/vitest.config.ts | 3 + 9 files changed, 80 insertions(+), 38 deletions(-) diff --git a/.github/workflows/ai-provider-integration-tests.yaml b/.github/workflows/ai-provider-integration-tests.yaml index a63a72da2..614e7b027 100644 --- a/.github/workflows/ai-provider-integration-tests.yaml +++ b/.github/workflows/ai-provider-integration-tests.yaml @@ -22,7 +22,7 @@ jobs: - name: Set up Node.js uses: actions/setup-node@v7 with: - node-version: '24' + node-version: '26' cache: 'npm' - name: Install dependencies diff --git a/.github/workflows/backend-tests.yaml b/.github/workflows/backend-tests.yaml index 9bb8a62df..6f4de90c9 100644 --- a/.github/workflows/backend-tests.yaml +++ b/.github/workflows/backend-tests.yaml @@ -57,7 +57,7 @@ jobs: - name: Set up Node.js uses: actions/setup-node@v7 with: - node-version: '24' + node-version: '26' cache: 'npm' - name: Install dependencies @@ -88,7 +88,7 @@ jobs: - name: Set up Node.js uses: actions/setup-node@v7 with: - node-version: '24' + node-version: '26' cache: 'npm' - name: Install dependencies @@ -154,7 +154,7 @@ jobs: - name: Set up Node.js uses: actions/setup-node@v7 with: - node-version: '24' + node-version: '26' cache: 'npm' - name: Install dependencies diff --git a/.github/workflows/docs-build.yml b/.github/workflows/docs-build.yml index b711b1578..dd4532ffd 100644 --- a/.github/workflows/docs-build.yml +++ b/.github/workflows/docs-build.yml @@ -16,7 +16,7 @@ jobs: - name: Set up Node.js uses: actions/setup-node@v7 with: - node-version: '24' + node-version: '26' cache: 'npm' - name: Install dependencies diff --git a/.github/workflows/puterjs-tests.yaml b/.github/workflows/puterjs-tests.yaml index 5d29bc5fb..c1a3f0e32 100644 --- a/.github/workflows/puterjs-tests.yaml +++ b/.github/workflows/puterjs-tests.yaml @@ -57,7 +57,7 @@ jobs: - name: Set up Node.js uses: actions/setup-node@v7 with: - node-version: '24' + node-version: '26' cache: 'npm' - name: Install dependencies @@ -78,7 +78,7 @@ jobs: - name: Set up Node.js uses: actions/setup-node@v7 with: - node-version: '24' + node-version: '26' cache: 'npm' - name: Install dependencies @@ -135,7 +135,7 @@ jobs: if: needs.changes.outputs.puterjs == 'true' uses: actions/setup-node@v7 with: - node-version: '24' + node-version: '26' cache: 'npm' - name: Install dependencies diff --git a/Dockerfile b/Dockerfile index 3f612de65..d7c10c0c5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,7 +14,7 @@ # config.default.json, so partial overrides work. Absent file = defaults. # ---- Build stage ---- -FROM node:24-slim AS build +FROM node:26-bookworm-slim AS build WORKDIR /opt/puter @@ -59,7 +59,7 @@ RUN set -e; \ wait $pjs_pid # ---- Runtime stage (slim — no build tools) ---- -FROM node:24-slim +FROM node:26-bookworm-slim WORKDIR /opt/puter diff --git a/src/backend/drivers/util/imageInput.test.ts b/src/backend/drivers/util/imageInput.test.ts index fd2da9050..6dc19a4a0 100644 --- a/src/backend/drivers/util/imageInput.test.ts +++ b/src/backend/drivers/util/imageInput.test.ts @@ -99,7 +99,8 @@ describe('image downloads', () => { statusCode: 400, code: 'input_too_large', }); - expect(pulled).toBeLessThanOrEqual(4); + // Readable.fromWeb reads ahead by a few chunks before the cap trips. + expect(pulled).toBeLessThanOrEqual(6); }); it('gives up on a slow host with a 400 rather than waiting it out', async () => { secureFetchMock.mockImplementationOnce( diff --git a/src/backend/util/nodeFetchDispatcherGuard.ts b/src/backend/util/nodeFetchDispatcherGuard.ts index aaadb4f1c..8b0fc9f54 100644 --- a/src/backend/util/nodeFetchDispatcherGuard.ts +++ b/src/backend/util/nodeFetchDispatcherGuard.ts @@ -18,8 +18,8 @@ */ // Importing npm `undici` has a process-wide side effect: if the global -// dispatcher slot — `Symbol.for('undici.globalDispatcher.1')`, shared with -// Node's built-in fetch — is empty, undici's module initializer installs its +// dispatcher slots — `Symbol.for('undici.globalDispatcher.N')`, shared with +// Node's built-in fetch — are empty, undici's module initializer installs its // own Agent there. From that moment EVERY plain `fetch()` in the process // (the AI provider SDKs, everything) is silently served by the npm copy of // undici instead of the copy bundled with — and security-patched by — Node. @@ -34,7 +34,13 @@ // This module must therefore be imported before `undici` — it intentionally // imports nothing itself, and secureHttp.ts (the backend's only undici // importer) imports it before importing undici. -const DISPATCHER_SLOT = Symbol.for('undici.globalDispatcher.1'); +// +// Newer undici reads the `.2` slot and older undici the `.1` slot, and both +// copies can share the process, so every present slot gets wrapped. +const DISPATCHER_SLOTS = [ + Symbol.for('undici.globalDispatcher.2'), + Symbol.for('undici.globalDispatcher.1'), +]; const globalSlots = globalThis as unknown as Record; void fetch('data:text/plain,dispatcher-guard').catch(() => { @@ -52,7 +58,11 @@ void fetch('data:text/plain,dispatcher-guard').catch(() => { const OUTBOUND_FETCH_TIMEOUT_MS = 10 * 60 * 1000; // Symbol.for so a re-imported copy of this module (fresh module registry, // same process) recognizes an already-wrapped dispatcher and doesn't re-wrap. -const TIMEOUTS_APPLIED = Symbol.for('puter.outboundFetchTimeouts'); +// A set, not a marker property: compose() can return a Proxy, which would +// write the marker through to the unwrapped dispatcher. +const WRAPPED_DISPATCHERS = Symbol.for('puter.outboundFetchTimeouts'); +const wrappedDispatchers = (globalSlots[WRAPPED_DISPATCHERS] ??= + new WeakSet()) as WeakSet; type DispatchFn = (opts: Record, handler: unknown) => boolean; interface ComposableDispatcher { @@ -61,29 +71,31 @@ interface ComposableDispatcher { ) => ComposableDispatcher; } -const claimed = globalSlots[DISPATCHER_SLOT] as - | (ComposableDispatcher & Record) - | undefined; -if ( - claimed && - typeof claimed.compose === 'function' && - !(TIMEOUTS_APPLIED in claimed) -) { - const wrapped = claimed.compose( - (dispatch) => (opts, handler) => - // Defaults first so a per-request timeout, if one is ever passed, - // still wins. - dispatch( - { - headersTimeout: OUTBOUND_FETCH_TIMEOUT_MS, - bodyTimeout: OUTBOUND_FETCH_TIMEOUT_MS, - ...opts, - }, - handler, - ), - ) as ComposableDispatcher & Record; - wrapped[TIMEOUTS_APPLIED] = true; - globalSlots[DISPATCHER_SLOT] = wrapped; +const withOutboundTimeouts = + (dispatch: DispatchFn): DispatchFn => + (opts, handler) => + // Defaults first so a per-request timeout, if one is ever passed, + // still wins. + dispatch( + { + headersTimeout: OUTBOUND_FETCH_TIMEOUT_MS, + bodyTimeout: OUTBOUND_FETCH_TIMEOUT_MS, + ...opts, + }, + handler, + ); + +for (const slot of DISPATCHER_SLOTS) { + const claimed = globalSlots[slot] as ComposableDispatcher | undefined; + if ( + !claimed || + typeof claimed.compose !== 'function' || + wrappedDispatchers.has(claimed) + ) + continue; + const wrapped = claimed.compose(withOutboundTimeouts); + wrappedDispatchers.add(wrapped); + globalSlots[slot] = wrapped; } export {}; diff --git a/src/backend/util/secureHttp.test.ts b/src/backend/util/secureHttp.test.ts index 8395bcef1..ca5b3c24b 100644 --- a/src/backend/util/secureHttp.test.ts +++ b/src/backend/util/secureHttp.test.ts @@ -17,6 +17,9 @@ * along with this program. If not, see . */ +import diagnosticsChannel from 'node:diagnostics_channel'; +import { createServer } from 'node:http'; +import type { AddressInfo } from 'node:net'; import { Agent as UndiciAgent } from 'undici'; import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; import { configContainer } from '../exports.js'; @@ -139,6 +142,29 @@ describe('global fetch dispatcher isolation', () => { globalSlots[SLOT] = before; } }); + + it('sends plain fetch() requests out with the 10-minute timeouts', async () => { + const created: { headersTimeout?: number; bodyTimeout?: number }[] = []; + const onCreate = (message: unknown) => { + created.push((message as { request: (typeof created)[0] }).request); + }; + const server = createServer((_req, res) => res.end('ok')); + await new Promise((resolve) => + server.listen(0, '127.0.0.1', resolve), + ); + diagnosticsChannel.subscribe('undici:request:create', onCreate); + try { + const { port } = server.address() as AddressInfo; + const response = await fetch(`http://127.0.0.1:${port}/`); + expect(await response.text()).toBe('ok'); + expect(created).toHaveLength(1); + expect(created[0].headersTimeout).toBe(10 * 60 * 1000); + expect(created[0].bodyTimeout).toBe(10 * 60 * 1000); + } finally { + diagnosticsChannel.unsubscribe('undici:request:create', onCreate); + server.close(); + } + }); }); describe('secureHttp URL validation', () => { diff --git a/src/puter-js/tests/api/vitest.config.ts b/src/puter-js/tests/api/vitest.config.ts index a523ff9bb..01edff412 100644 --- a/src/puter-js/tests/api/vitest.config.ts +++ b/src/puter-js/tests/api/vitest.config.ts @@ -44,6 +44,9 @@ export default defineConfig(({ mode }) => ({ // workerd runners are slower than unit tests. testTimeout: 60_000, hookTimeout: 120_000, + // Node's own localStorage global is undefined without a storage file + // and keeps vitest from installing jsdom's in its place. + execArgv: ['--no-experimental-webstorage'], root: repoRoot, }, }));