mirror of
https://github.com/HeyPuter/puter.git
synced 2026-10-25 08:09:15 +00:00
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:
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 {};
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}));
|
||||
Reference in new issue
Block a user