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.
This commit is contained in:
Daniel Salazar authored and GitHub committed 2026-10-21 13:04:23 -07:00
1 parent 0760530d0c
commit d22d419c4d
9 files changed
+80 -38

No files matched your search

@@ -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
+3 -3
View File
@@ -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
+1 -1
View File
@@ -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
+3 -3
View File
@@ -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
+2 -2
View File
@@ -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
+2 -1
View File
@@ -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(
+39 -27
View File
@@ -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<symbol, unknown>;
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<object>()) as WeakSet<object>;
type DispatchFn = (opts: Record<string, unknown>, handler: unknown) => boolean;
interface ComposableDispatcher {
@@ -61,29 +71,31 @@ interface ComposableDispatcher {
) => ComposableDispatcher;
}
const claimed = globalSlots[DISPATCHER_SLOT] as
| (ComposableDispatcher & Record<symbol, unknown>)
| 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<symbol, unknown>;
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 {};
+26
View File
@@ -17,6 +17,9 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
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<void>((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', () => {
+3
View File
@@ -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,
},
}));