From 67bfefb8da064f26777a05e236fe2acf85e70729 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 26 Oct 2026 14:48:42 +0100 Subject: [PATCH 01/22] Restore cancellation after Deno consumes streamed request bodies Deno2.9.7 and2.9.8 can lose the HTTP1 disconnect signal after bodyEOF. Reuse public Node transports only for those affected Deno listeners, retaining cleartext HTTP2, exact-request WebSocket ownership, native server DOM behavior and request-local context. Keep all accepted sockets and sessions owned through denial, cancellation and shutdown. Constraint: Preserve valid HTTP1, h2c and WebSocket contracts without new dependencies Rejected: Older compiler rollback | known security advisories Rejected: Early response or timeout workaround | changes streaming semantics without fixing cancellation Rejected: Native WebSocket bridge | public shutdown remained hung Confidence: high Scope-risk: moderate Directive: Retain HTTP2, WebSocket and segmented-body cancellation regressions when changing transport selection Tested: Pinned Deno focused69tests187steps; Node51tests; Bun2files; npm/dnt; normal proxy compile; macARM fullchain10of10abort1to3ms Not-tested: Actual Linux execution and staging Acceptance2086/2173 --- cli/proxy-main.ts | 1 + extensions/ext-node-websocket-ws/src/index.ts | 9 +- scripts/build/proxy-deno.lock | 11 + .../embedded-npm-packages.generated.ts | 4 + .../node-websocket-server-provider.test.ts | 45 +++- .../node-websocket-server-provider.ts | 50 +++- src/platform/adapters/base.ts | 7 +- .../adapters/runtime/deno/http-server.ts | 18 +- .../runtime/deno/http-transport.test.ts | 22 ++ .../adapters/runtime/deno/http-transport.ts | 47 ++++ .../adapters/runtime/deno/node-websocket.ts | 215 +++++++++++++++ .../runtime/deno/websocket-adapter.ts | 7 +- .../runtime/node/dual-protocol-server.test.ts | 44 +++ .../runtime/node/dual-protocol-server.ts | 100 +++++++ .../adapters/runtime/node/http-server.test.ts | 101 ++++++- .../adapters/runtime/node/http-server.ts | 133 ++++++++-- .../runtime/node/websocket-adapter.test.ts | 134 +++++++++- .../runtime/node/websocket-adapter.ts | 104 +++++++- .../runtime/shared/deferred-websocket.ts | 8 +- .../shared/http-upgrade-provenance.test.ts | 70 +++++ .../runtime/shared/http-upgrade-provenance.ts | 71 +++++ .../runtime/shared/server-lifecycle.ts | 4 +- src/platform/compat/http/deno-server.ts | 76 +++++- src/platform/compat/http/native-response.ts | 6 +- src/platform/compat/http/node-server.ts | 3 +- src/platform/compat/http/types.ts | 9 +- src/platform/compat/http/websocket.ts | 5 + src/proxy/main.ts | 24 +- src/server/bootstrap.ts | 8 +- src/server/dev-server/server.ts | 6 +- src/server/index.ts | 43 ++- src/server/production-server.ts | 6 +- src/server/service-server.ts | 4 +- .../core/deno-http-cancellation.test.ts | 251 ++++++++++++++++++ .../core/deno-http2-transport.test.ts | 200 ++++++++++++++ .../core/deno-node-http-upgrade.test.ts | 98 +++++++ .../core/deno-websocket-dom.test.ts | 248 +++++++++++++++++ 37 files changed, 2117 insertions(+), 75 deletions(-) create mode 100644 src/platform/adapters/runtime/deno/http-transport.test.ts create mode 100644 src/platform/adapters/runtime/deno/http-transport.ts create mode 100644 src/platform/adapters/runtime/deno/node-websocket.ts create mode 100644 src/platform/adapters/runtime/node/dual-protocol-server.test.ts create mode 100644 src/platform/adapters/runtime/node/dual-protocol-server.ts create mode 100644 src/platform/adapters/runtime/shared/http-upgrade-provenance.test.ts create mode 100644 src/platform/adapters/runtime/shared/http-upgrade-provenance.ts create mode 100644 tests/integration/core/deno-http-cancellation.test.ts create mode 100644 tests/integration/core/deno-http2-transport.test.ts create mode 100644 tests/integration/core/deno-node-http-upgrade.test.ts create mode 100644 tests/integration/core/deno-websocket-dom.test.ts diff --git a/cli/proxy-main.ts b/cli/proxy-main.ts index 6b63ef877b..d7692edc0d 100644 --- a/cli/proxy-main.ts +++ b/cli/proxy-main.ts @@ -34,6 +34,7 @@ import "../extensions/ext-observability-opentelemetry/src/index.ts"; import "../extensions/ext-observability-sentry/src/index.ts"; import "../extensions/ext-redis/src/index.ts"; import "../extensions/ext-schema-zod/src/index.ts"; +import "../extensions/ext-node-websocket-ws/src/index.ts"; setLoggerPreset("cli"); diff --git a/extensions/ext-node-websocket-ws/src/index.ts b/extensions/ext-node-websocket-ws/src/index.ts index 1db7c23263..b15dc5b993 100644 --- a/extensions/ext-node-websocket-ws/src/index.ts +++ b/extensions/ext-node-websocket-ws/src/index.ts @@ -7,10 +7,17 @@ import { NodeWebSocketServerProviderName, } from "veryfront/extensions/websocket"; // @ts-types="@types/ws" -import { WebSocketServer } from "ws"; +import { WebSocket, WebSocketServer } from "ws"; export const WsNodeWebSocketServerProvider = createNodeWebSocketServerProvider( (options) => captureNodeWebSocketServer(new WebSocketServer(options)), + { + supportsIdleTimeout: true, + getBufferedAmount(connection) { + if (!(connection instanceof WebSocket)) throw new TypeError("Unknown ws connection"); + return connection.bufferedAmount; + }, + }, ); /** Create the standard Node.js `ws` transport extension. */ diff --git a/scripts/build/proxy-deno.lock b/scripts/build/proxy-deno.lock index 6b5ef05d99..eda0d92559 100644 --- a/scripts/build/proxy-deno.lock +++ b/scripts/build/proxy-deno.lock @@ -18,10 +18,12 @@ "npm:@opentelemetry/semantic-conventions@1.43.0": "1.43.0", "npm:@redis/client@1.5.8": "1.5.8", "npm:@sentry/deno@10.68.0": "10.68.0", + "npm:@types/ws@8.18.1": "8.18.1", "npm:ajv-formats@3.0.1": "3.0.1_ajv@8.18.0", "npm:ajv@8.18.0": "8.18.0", "npm:jose@5.9.6": "5.9.6", "npm:redis@5.11.0": "5.11.0", + "npm:ws@8.21.1": "8.21.1", "npm:zod@4.6.7": "4.6.7" }, "npm": { @@ -997,6 +999,12 @@ "@types/node" ] }, + "@types/ws@8.18.1": { + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dependencies": [ + "@types/node" + ] + }, "agent-base@7.1.4": { "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==" }, @@ -1445,6 +1453,9 @@ "strip-ansi@7.2.0" ] }, + "ws@8.21.1": { + "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==" + }, "xtend@4.0.2": { "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==" }, diff --git a/src/discovery/embedded-npm-packages.generated.ts b/src/discovery/embedded-npm-packages.generated.ts index 489a08be79..15dbf6632c 100644 --- a/src/discovery/embedded-npm-packages.generated.ts +++ b/src/discovery/embedded-npm-packages.generated.ts @@ -772,6 +772,7 @@ export const PROXY_EMBEDDED_NPM_PACKAGES: Readonly snapshotNodeWebSocketServerProvider(value), TypeError, - "one enumerable createServer data property", + "an enumerable createServer data function and optional supportsIdleTimeout: true", ); } assertEquals(accessorCalls, 0); @@ -204,3 +204,46 @@ Deno.test("Node WebSocket provider helper and missing-contract diagnostic are ac "install it or remove the extension disable directive", ); }); + +Deno.test("heartbeat capability is immutable captured registration metadata", () => { + const source = { createServer: () => SERVER, supportsIdleTimeout: true as const }; + const captured = snapshotNodeWebSocketServerProvider(source); + Object.defineProperty(source, "supportsIdleTimeout", { value: false }); + assertEquals(captured.supportsIdleTimeout, true); + assertEquals(Object.isFrozen(captured), true); + assertThrows(() => + snapshotNodeWebSocketServerProvider({ createServer: () => SERVER, supportsIdleTimeout: false }) + ); + assertThrows(() => + snapshotNodeWebSocketServerProvider({ + createServer: () => SERVER, + get supportsIdleTimeout() { + throw new Error("must not read"); + }, + }) + ); +}); + +Deno.test("buffered amount accessor is captured without invoking registration getters", () => { + const provider = { + createServer: () => SERVER, + getBufferedAmount: () => 17, + }; + const snapshot = snapshotNodeWebSocketServerProvider(provider); + provider.getBufferedAmount = () => 99; + assertEquals(snapshot.getBufferedAmount?.({} as NodeWebSocketConnection), 17); + let reads = 0; + const capabilities = Object.defineProperty( + { supportsIdleTimeout: true as const }, + "getBufferedAmount", + { + enumerable: true, + get() { + reads++; + return () => 0; + }, + }, + ); + assertThrows(() => createNodeWebSocketServerProvider(() => SERVER, capabilities), TypeError); + assertEquals(reads, 0); +}); diff --git a/src/extensions/websocket/node-websocket-server-provider.ts b/src/extensions/websocket/node-websocket-server-provider.ts index 48a63277ba..c7038905f8 100644 --- a/src/extensions/websocket/node-websocket-server-provider.ts +++ b/src/extensions/websocket/node-websocket-server-provider.ts @@ -35,6 +35,7 @@ export interface NodeWebSocketConnection extends EventTarget { send(data: string | ArrayBuffer): void; close(code?: number, reason?: string): void; terminate?(): void; + ping?(): void; } /** Exact no-server options supplied by core for an existing HTTP listener. */ @@ -64,6 +65,9 @@ export interface NodeWebSocketServer { } export interface NodeWebSocketServerProvider { + readonly supportsIdleTimeout?: true; + /** Actual transport send-buffer size, required by the Deno DOM facade. */ + readonly getBufferedAmount?: (connection: NodeWebSocketConnection) => number; readonly createServer: ( options: NodeWebSocketServerOptions, ) => NodeWebSocketServer; @@ -177,7 +181,7 @@ export function captureNodeWebSocketServer( function providerInspectionError(cause?: unknown): TypeError { return new TypeError( - "Node WebSocket server provider must be a plain object with one enumerable createServer data property", + "Node WebSocket server provider must contain an enumerable createServer data function and optional supportsIdleTimeout: true", cause === undefined ? undefined : { cause }, ); } @@ -210,7 +214,16 @@ export function snapshotNodeWebSocketServerProvider( const descriptor = descriptors.createServer; if ( (prototype !== Object.prototype && prototype !== null) || - keys.length !== 1 || keys[0] !== "createServer" || + keys.some((key) => + key !== "createServer" && key !== "supportsIdleTimeout" && key !== "getBufferedAmount" + ) || + (descriptors.supportsIdleTimeout !== undefined && + (!("value" in descriptors.supportsIdleTimeout) || + !descriptors.supportsIdleTimeout.enumerable || + descriptors.supportsIdleTimeout.value !== true)) || + (descriptors.getBufferedAmount !== undefined && + (!("value" in descriptors.getBufferedAmount) || !descriptors.getBufferedAmount.enumerable || + typeof descriptors.getBufferedAmount.value !== "function")) || !descriptor?.enumerable || !("value" in descriptor) || typeof descriptor.value !== "function" ) { @@ -220,7 +233,20 @@ export function snapshotNodeWebSocketServerProvider( const createServer = descriptor.value as ( options: NodeWebSocketServerOptions, ) => NodeWebSocketServer; + const getBufferedAmount = descriptors.getBufferedAmount?.value; return Object.freeze({ + ...(descriptors.supportsIdleTimeout === undefined + ? {} + : { supportsIdleTimeout: true as const }), + ...(getBufferedAmount === undefined ? {} : { + getBufferedAmount(connection: NodeWebSocketConnection): number { + const amount = Reflect.apply(getBufferedAmount, undefined, [connection]); + if (typeof amount !== "number" || !Number.isFinite(amount) || amount < 0) { + throw new TypeError("WebSocket buffered amount must be a finite nonnegative number"); + } + return amount; + }, + }), createServer(options: NodeWebSocketServerOptions): NodeWebSocketServer { return Reflect.apply(createServer, undefined, [options]); }, @@ -232,6 +258,24 @@ export function createNodeWebSocketServerProvider( createServer: ( options: NodeWebSocketServerOptions, ) => NodeWebSocketServer, + capabilities?: { + readonly supportsIdleTimeout: true; + readonly getBufferedAmount?: (connection: NodeWebSocketConnection) => number; + }, ): Readonly { - return snapshotNodeWebSocketServerProvider({ createServer }); + if (capabilities === undefined) return snapshotNodeWebSocketServerProvider({ createServer }); + if (typeof capabilities !== "object" || capabilities === null || isProxy(capabilities)) { + throw providerInspectionError(); + } + const descriptors = Object.getOwnPropertyDescriptors(capabilities); + if ( + Reflect.ownKeys(descriptors).some((key) => + key !== "supportsIdleTimeout" && key !== "getBufferedAmount" + ) + ) { + throw providerInspectionError(); + } + return snapshotNodeWebSocketServerProvider( + Object.defineProperties({ createServer }, descriptors), + ); } diff --git a/src/platform/adapters/base.ts b/src/platform/adapters/base.ts index 533e51d2f2..4e46bf5bc0 100644 --- a/src/platform/adapters/base.ts +++ b/src/platform/adapters/base.ts @@ -43,7 +43,7 @@ export interface RuntimeAdapter { server: ServerAdapter; serve( - handler: (request: Request) => Promise | Response, + handler: (request: Request) => Promise | HttpHandlerResponse, options: ServeOptions, ): Promise; @@ -140,6 +140,8 @@ export interface WebSocketUpgradeResponse { readonly body: null; } +export type HttpHandlerResponse = Response | WebSocketUpgradeResponse; + export interface WebSocketUpgrade { socket: WebSocketConnection; response: Response | WebSocketUpgradeResponse; @@ -241,7 +243,8 @@ export interface ServeOptions { * WebSocket upgrades. When absent, HTTP serving remains available and every * Node WebSocket upgrade fails closed. */ - nodeWebSocketServerProvider?: Readonly; + /** Deno: undefined preserves implicit WS support; null explicitly disables it. */ + nodeWebSocketServerProvider?: Readonly | null; } export interface Server { diff --git a/src/platform/adapters/runtime/deno/http-server.ts b/src/platform/adapters/runtime/deno/http-server.ts index c55dc1e621..ea064dc81f 100644 --- a/src/platform/adapters/runtime/deno/http-server.ts +++ b/src/platform/adapters/runtime/deno/http-server.ts @@ -1,4 +1,10 @@ -import type { ServeOptions, Server } from "../../base.ts"; +import { createDenoNodeServerWithStartupOwner } from "../node/http-server.ts"; +import { + needsDenoNodeHttpTransport, + resolveDenoNodeHttpOptions, + scopeDenoNodeHttpHandler, +} from "./http-transport.ts"; +import type { HttpHandlerResponse, ServeOptions, Server } from "../../base.ts"; import { DEFAULT_PORT } from "../../../compat/constants.ts"; import { getNativeDeno, @@ -14,7 +20,7 @@ import { scopeRequestAsyncContext } from "../../../compat/http/request-async-sco type DenoRequestHandler = ( request: Request, -) => Promise | Response; +) => Promise | HttpHandlerResponse; /** * Whether `error` is a failure to bind the requested address. @@ -374,7 +380,7 @@ export async function createDenoServerWithRuntime( return server; } -export function createDenoServer( +export async function createDenoServer( handler: DenoRequestHandler, options: ServeOptions = {}, ): Promise { @@ -385,6 +391,12 @@ export function createDenoServer( context: { platform: "deno", operation: "serve" }, }); } + if (needsDenoNodeHttpTransport()) { + return await createDenoNodeServerWithStartupOwner( + scopeDenoNodeHttpHandler(handler), + await resolveDenoNodeHttpOptions(options), + ); + } return createDenoServerWithRuntime( runtime as DenoServeRuntime, handler, diff --git a/src/platform/adapters/runtime/deno/http-transport.test.ts b/src/platform/adapters/runtime/deno/http-transport.test.ts new file mode 100644 index 0000000000..0c5a73ab95 --- /dev/null +++ b/src/platform/adapters/runtime/deno/http-transport.test.ts @@ -0,0 +1,22 @@ +import { it } from "#veryfront/testing/bdd.ts"; +import { assertEquals, assertStrictEquals } from "#veryfront/testing/assert.ts"; +import { needsDenoNodeHttpTransport, resolveDenoNodeHttpOptions } from "./http-transport.ts"; +import { WsNodeWebSocketServerProvider } from "../../../../../extensions/ext-node-websocket-ws/src/index.ts"; + +it("Deno transport selection is bounded to source-proven affected versions", () => { + for (const version of ["2.9.7", "2.9.8"]) assertEquals(needsDenoNodeHttpTransport(version), true); + for (const version of ["2.7.12", "2.9.6", "2.9.9", "3.0.0"]) { + assertEquals(needsDenoNodeHttpTransport(version), false); + } +}); + +it("explicit disabled and custom provider intent bypasses the Deno default loader", async () => { + const disabled = { nodeWebSocketServerProvider: null }; + assertStrictEquals(await resolveDenoNodeHttpOptions(disabled), disabled); + const custom = { nodeWebSocketServerProvider: WsNodeWebSocketServerProvider }; + assertStrictEquals(await resolveDenoNodeHttpOptions(custom), custom); + assertEquals( + (await resolveDenoNodeHttpOptions({})).nodeWebSocketServerProvider?.supportsIdleTimeout, + true, + ); +}); diff --git a/src/platform/adapters/runtime/deno/http-transport.ts b/src/platform/adapters/runtime/deno/http-transport.ts new file mode 100644 index 0000000000..14fc63cd83 --- /dev/null +++ b/src/platform/adapters/runtime/deno/http-transport.ts @@ -0,0 +1,47 @@ +import type { ServeOptions } from "../../base.ts"; +import { getNativeDeno } from "../../../compat/http/native-response.ts"; +import { scopeRequestAsyncContext } from "../../../compat/http/request-async-scope.ts"; +import { getEnvOverlayStorage } from "../../../compat/process.ts"; +import { importFirstPartyExtensionModule } from "#veryfront/extensions/first-party-import.ts"; +import { + type NodeWebSocketServerProvider, + snapshotNodeWebSocketServerProvider, +} from "#veryfront/extensions/websocket"; + +/** Deno 2.9.7 and 2.9.8 lose HTTP/1 disconnects after a streamed request body reaches EOF. */ +export function needsDenoNodeHttpTransport(version = getNativeDeno()?.version.deno): boolean { + return version === "2.9.7" || version === "2.9.8"; +} + +/** Preserve the Deno request scope while reusing the Node HTTP transport. */ +export function scopeDenoNodeHttpHandler( + handler: (request: Request) => T, +): (request: Request) => T { + const overlay = getEnvOverlayStorage(); + const store = overlay?.getStore(); + return scopeRequestAsyncContext((request: Request) => { + if (overlay && store) { + if (overlay.run) return overlay.run(store, () => handler(request)); + overlay.enterWith?.(store); + } + return handler(request); + }); +} + +/** + * Direct Deno listeners retain their implicit WebSocket support. A host that + * resolved extension policy passes null to explicitly disable the provider; + * that decision must never trigger a default import. + */ +export async function resolveDenoNodeHttpOptions(options: ServeOptions): Promise { + if (options.nodeWebSocketServerProvider !== undefined) return options; + const extension = await importFirstPartyExtensionModule<{ + WsNodeWebSocketServerProvider: Readonly; + }>("ext-node-websocket-ws", "@veryfront/ext-node-websocket-ws"); + return { + ...options, + nodeWebSocketServerProvider: snapshotNodeWebSocketServerProvider( + extension.WsNodeWebSocketServerProvider, + ), + }; +} diff --git a/src/platform/adapters/runtime/deno/node-websocket.ts b/src/platform/adapters/runtime/deno/node-websocket.ts new file mode 100644 index 0000000000..901c9bfe89 --- /dev/null +++ b/src/platform/adapters/runtime/deno/node-websocket.ts @@ -0,0 +1,215 @@ +import { NodeServerAdapter, NodeWebSocket } from "../node/websocket-adapter.ts"; +import type { HttpHandlerResponse, WebSocketUpgradeOptions } from "../../base.ts"; +import { recordDenoWebSocketResponse } from "../shared/http-upgrade-provenance.ts"; + +/** DOM surface retained by Deno when its HTTP listener uses Node transport. */ +export class DenoNodeWebSocket extends EventTarget implements WebSocket { + readonly CONNECTING = 0; + readonly OPEN = 1; + readonly CLOSING = 2; + readonly CLOSED = 3; + readonly url = ""; + readonly protocol = ""; + readonly extensions = ""; + private binary: BinaryType = "arraybuffer"; + private readonly sendQueue: Array = []; + private sending = false; + private queuedClose?: { code?: number; reason?: string }; + + private openHandler: WebSocket["onopen"] = null; + private readonly openListener: EventListener = (event) => { + this.openHandler?.call(this, event as Event); + }; + get onopen(): WebSocket["onopen"] { + return this.openHandler; + } + set onopen(callback: WebSocket["onopen"]) { + this.updateHandler("open", this.openHandler !== null, callback !== null, this.openListener); + this.openHandler = callback; + } + private closeHandler: WebSocket["onclose"] = null; + private readonly closeListener: EventListener = (event) => { + this.closeHandler?.call(this, event as CloseEvent); + }; + get onclose(): WebSocket["onclose"] { + return this.closeHandler; + } + set onclose(callback: WebSocket["onclose"]) { + this.updateHandler("close", this.closeHandler !== null, callback !== null, this.closeListener); + this.closeHandler = callback; + } + private errorHandler: WebSocket["onerror"] = null; + private readonly errorListener: EventListener = (event) => { + this.errorHandler?.call(this, event as Event); + }; + get onerror(): WebSocket["onerror"] { + return this.errorHandler; + } + set onerror(callback: WebSocket["onerror"]) { + this.updateHandler("error", this.errorHandler !== null, callback !== null, this.errorListener); + this.errorHandler = callback; + } + private messageHandler: WebSocket["onmessage"] = null; + private readonly messageListener: EventListener = (event) => { + this.messageHandler?.call(this, event as MessageEvent); + }; + get onmessage(): WebSocket["onmessage"] { + return this.messageHandler; + } + set onmessage(callback: WebSocket["onmessage"]) { + this.updateHandler( + "message", + this.messageHandler !== null, + callback !== null, + this.messageListener, + ); + this.messageHandler = callback; + } + constructor(private readonly inner: NodeWebSocket) { + super(); + inner.addEventListener("open", () => this.deliver(new Event("open"))); + inner.addEventListener("close", (event) => { + this.sendQueue.length = 0; + const close = event as CloseEvent; + this.deliver( + new CloseEvent("close", { + code: close.code, + reason: close.reason, + wasClean: close.wasClean, + }), + ); + }); + inner.addEventListener("error", (event) => { + const error = event as ErrorEvent; + this.deliver(new ErrorEvent("error", { message: error.message, error: error.error })); + }); + inner.addEventListener("message", (event) => { + const data: unknown = (event as MessageEvent).data; + this.deliver( + new MessageEvent("message", { + data: data instanceof ArrayBuffer && this.binary === "blob" ? new Blob([data]) : data, + }), + ); + }); + } + + get readyState(): WebSocket["readyState"] { + const state = this.inner.readyState; + if (state === this.OPEN && this.queuedClose) return this.CLOSING; + if (state === 0 || state === 1 || state === 2 || state === 3) return state; + throw new TypeError("Invalid WebSocket transport state"); + } + get bufferedAmount(): number { + return this.readyState === this.OPEN ? this.inner._getBufferedAmount() : 0; + } + get binaryType(): BinaryType { + return this.binary; + } + set binaryType(value: BinaryType) { + if (value === "blob" || value === "arraybuffer") this.binary = value; + } + + send(data: string | ArrayBufferLike | Blob | ArrayBufferView): void { + if (arguments.length === 0) throw new TypeError("WebSocket.send requires an argument"); + if (this.readyState === this.CONNECTING) { + throw new DOMException("'readyState' not OPEN", "InvalidStateError"); + } + if (this.readyState !== this.OPEN) return; + let normalized: string | ArrayBuffer | Blob; + if (typeof data === "string" || data instanceof Blob) normalized = data; + else if (ArrayBuffer.isView(data)) { + normalized = new Uint8Array(data.buffer, data.byteOffset, data.byteLength).slice().buffer; + } else if (data instanceof ArrayBuffer) normalized = data.slice(0); + else normalized = String(data); + if (!this.sending && !(normalized instanceof Blob)) { + this.inner.send(normalized); + return; + } + this.sendQueue.push(normalized); + if (!this.sending) void this.flushSends(); + } + + close(code?: number, reason?: string): void { + if (code !== undefined) { + const value = Number(code); + const clamped = Number.isNaN(value) ? 0 : Math.min(65535, Math.max(0, value)); + const lower = Math.floor(clamped); + code = clamped - lower === 0.5 ? lower + (lower % 2) : Math.round(clamped); + // Native Deno permits malformed server close frames. Preserve valid + // protocol inputs while retaining the provider's synchronous RFC guard. + if ( + !((code >= 1000 && code <= 1014 && ![1004, 1005, 1006].includes(code)) || + (code >= 3000 && code <= 4999)) + ) { + throw new TypeError("WebSocket close code must be a valid protocol status code"); + } + } + if (reason !== undefined) { + if (typeof reason === "symbol") { + throw new TypeError("A WebSocket close reason must be a string"); + } + reason = String(reason).toWellFormed(); + } + if (reason !== undefined && new TextEncoder().encode(reason).length > 123) { + throw new DOMException("The close reason may not be longer than 123 bytes", "SyntaxError"); + } + if (this.readyState === this.CLOSING || this.readyState === this.CLOSED) return; + if (this.sending && this.readyState === this.OPEN) { + this.queuedClose = { code, reason }; + return; + } + this.inner.close(code, reason); + } + + private async flushSends(): Promise { + this.sending = true; + try { + while (this.sendQueue.length > 0) { + const item = this.sendQueue.shift()!; + const data = item instanceof Blob ? await item.slice().arrayBuffer() : item; + if (this.inner.readyState !== this.OPEN) break; + this.inner.send(data); + } + } catch (error) { + this.inner._emitError(error instanceof Error ? error : new Error(String(error))); + } finally { + this.sendQueue.length = 0; + this.sending = false; + const close = this.queuedClose; + this.queuedClose = undefined; + if (close) { + try { + this.inner.close(close.code, close.reason); + } catch (error) { + this.inner._emitError(error instanceof Error ? error : new Error(String(error))); + } + } + } + } + + private updateHandler( + type: string, + previous: boolean, + next: boolean, + listener: EventListener, + ): void { + if (!previous && next) this.addEventListener(type, listener); + else if (previous && !next) this.removeEventListener(type, listener); + } + + private deliver(event: Event): void { + this.dispatchEvent(event); + } +} + +export function upgradeDenoNodeWebSocket( + request: Request, + options?: WebSocketUpgradeOptions, +): { socket: WebSocket; response: HttpHandlerResponse } { + const upgrade = new NodeServerAdapter(true).upgradeWebSocket(request, options); + if (!(upgrade.socket instanceof NodeWebSocket)) { + throw new TypeError("Deno WebSocket transport did not create its owned socket"); + } + recordDenoWebSocketResponse(upgrade.response); + return { socket: new DenoNodeWebSocket(upgrade.socket), response: upgrade.response }; +} diff --git a/src/platform/adapters/runtime/deno/websocket-adapter.ts b/src/platform/adapters/runtime/deno/websocket-adapter.ts index af3ed223fb..9d92178a24 100644 --- a/src/platform/adapters/runtime/deno/websocket-adapter.ts +++ b/src/platform/adapters/runtime/deno/websocket-adapter.ts @@ -1,3 +1,5 @@ +import { upgradeDenoNodeWebSocket } from "./node-websocket.ts"; +import { hasNodeWebSocketUpgradeRequest } from "../shared/http-upgrade-provenance.ts"; import type { ServerAdapter, WebSocketUpgrade, WebSocketUpgradeOptions } from "../../base.ts"; import { getNativeDeno } from "../../../compat/http/native-response.ts"; import { resolveDenoWebSocketUpgradeOptions } from "../../../compat/http/websocket.ts"; @@ -9,7 +11,10 @@ export class DenoServerAdapter implements ServerAdapter { upgradeWebSocket( request: Request, options?: WebSocketUpgradeOptions, - ): WebSocketUpgrade { + ): WebSocketUpgrade & { socket: WebSocket } { + if (hasNodeWebSocketUpgradeRequest(request)) { + return upgradeDenoNodeWebSocket(request, options); + } const nativeDeno = getNativeDeno(); if (typeof nativeDeno?.upgradeWebSocket !== "function") { throw NOT_SUPPORTED.create({ diff --git a/src/platform/adapters/runtime/node/dual-protocol-server.test.ts b/src/platform/adapters/runtime/node/dual-protocol-server.test.ts new file mode 100644 index 0000000000..2d785388b7 --- /dev/null +++ b/src/platform/adapters/runtime/node/dual-protocol-server.test.ts @@ -0,0 +1,44 @@ +import { Socket } from "node:net"; +import { it } from "#veryfront/testing/bdd.ts"; +import { assertEquals } from "#veryfront/testing/assert.ts"; +import { DualProtocolServer } from "./dual-protocol-server.ts"; + +it("dual protocol ownership does not expose raw sockets to authored Set observers", async () => { + const server = new DualProtocolServer(() => {}); + const socket = new Socket(); + const closed = new Promise((resolve) => socket.once("close", resolve)); + const descriptors = Object.getOwnPropertyDescriptors(Set.prototype); + const apply = Reflect.apply; + const originals = { add: Set.prototype.add, delete: Set.prototype.delete }; + const forEach = Set.prototype.forEach; + let observations = 0; + try { + for (const key of ["add", "delete"] as const) { + const original = originals[key]; + Object.defineProperty(Set.prototype, key, { + ...descriptors[key], + value(value: unknown) { + if (value === socket) observations++; + return apply(original, this, [value]); + }, + }); + } + + Object.defineProperty(Set.prototype, "forEach", { + ...descriptors.forEach, + value(callback: (value: unknown) => void) { + return apply(forEach, this, [(value: unknown) => { + if (value === socket) observations++; + callback(value); + }]); + }, + }); + server.emit("connection", socket); + server.closeAllConnections(); + } finally { + Object.defineProperties(Set.prototype, descriptors); + socket.destroy(); + } + await closed; + assertEquals(observations, 0); +}); diff --git a/src/platform/adapters/runtime/node/dual-protocol-server.ts b/src/platform/adapters/runtime/node/dual-protocol-server.ts new file mode 100644 index 0000000000..7b55aaeff8 --- /dev/null +++ b/src/platform/adapters/runtime/node/dual-protocol-server.ts @@ -0,0 +1,100 @@ +import { Server as TcpServer, type Socket } from "node:net"; +import { + createServer as createHttpServer, + type IncomingMessage, + type ServerResponse, +} from "node:http"; +import { + createServer as createHttp2Server, + type Http2ServerRequest, + type Http2ServerResponse, + type ServerHttp2Session, +} from "node:http2"; + +const apply = Reflect.apply; +const setAdd = Set.prototype.add; +const setDelete = Set.prototype.delete; +const setForEach = Set.prototype.forEach; + +const HTTP2_PREFACE = new TextEncoder().encode("PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n"); + +/** Public Node transports sharing Deno's existing cleartext HTTP/1 + h2c port. */ +export class DualProtocolServer extends TcpServer { + private readonly sockets = new Set(); + private readonly sessions = new Set(); + private readonly http1; + private readonly http2; + + constructor( + listener: ( + request: IncomingMessage | Http2ServerRequest, + response: ServerResponse | Http2ServerResponse, + ) => void, + private readonly prefaceTimeoutMs = 10_000, + ) { + super(); + this.http1 = createHttpServer(listener); + this.http2 = createHttp2Server(listener); + this.http1.on( + "upgrade", + (request, socket, head) => this.emit("upgrade", request, socket, head), + ); + this.http1.on("error", (error) => this.emit("error", error)); + this.http2.on("error", (error) => this.emit("error", error)); + this.http2.on("session", (session) => { + apply(setAdd, this.sessions, [session]); + session.on("error", () => session.destroy()); + session.once("close", () => apply(setDelete, this.sessions, [session])); + }); + this.on("connection", (socket) => this.route(socket)); + } + + private route(socket: Socket): void { + apply(setAdd, this.sockets, [socket]); + socket.once("close", () => apply(setDelete, this.sockets, [socket])); + // Only the fixed protocol preface is inspected, never request headers/body. + // A stalled partial preface must not retain an unbounded live connection. + const deadline = setTimeout(() => socket.destroy(), this.prefaceTimeoutMs); + let buffered = new Uint8Array(0); + const cleanup = () => { + clearTimeout(deadline); + socket.off("data", data); + socket.off("end", cleanup); + socket.off("close", cleanup); + socket.off("error", cleanup); + }; + const data = (chunk: Uint8Array) => { + socket.pause(); + const bytes = new Uint8Array(buffered.length + chunk.length); + bytes.set(buffered); + bytes.set(chunk, buffered.length); + const compared = Math.min(bytes.length, HTTP2_PREFACE.length); + let matches = true; + for (let i = 0; i < compared; i++) { + if (bytes[i] !== HTTP2_PREFACE[i]) { + matches = false; + break; + } + } + if (matches && bytes.length < HTTP2_PREFACE.length) { + buffered = bytes; + socket.resume(); + return; + } + cleanup(); + socket.unshift(bytes); + (matches ? this.http2 : this.http1).emit("connection", socket); + socket.resume(); + }; + socket.on("data", data); + socket.once("end", cleanup); + socket.once("close", cleanup); + socket.once("error", cleanup); + } + + closeAllConnections(): void { + apply(setForEach, this.sessions, [(session: ServerHttp2Session) => session.destroy()]); + this.http1.closeAllConnections(); + apply(setForEach, this.sockets, [(socket: Socket) => socket.destroy()]); + } +} diff --git a/src/platform/adapters/runtime/node/http-server.test.ts b/src/platform/adapters/runtime/node/http-server.test.ts index 047cb538d3..41534a0228 100644 --- a/src/platform/adapters/runtime/node/http-server.test.ts +++ b/src/platform/adapters/runtime/node/http-server.test.ts @@ -8,7 +8,12 @@ import { assertStringIncludes, } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; -import { createServer, request as nodeRequest, Server as NativeHttpServer } from "node:http"; +import { + createServer, + request as nodeRequest, + Server as NativeHttpServer, + ServerResponse, +} from "node:http"; import { createConnection } from "node:net"; import { isNode } from "#veryfront/platform/compat/runtime.ts"; import { createWebSocketUpgradeResponse } from "../../base.ts"; @@ -798,7 +803,94 @@ describe("NodeServer lifecycle", () => { } }); - it("destroys an upgrade that the request handler did not authorize", async () => { + it("contains asynchronous upgrade-denial response errors", async () => { + if (!isNode) return; + const original = ServerResponse.prototype.assignSocket; + let injected = 0; + const server = await createNodeServer(() => new Response("forbidden", { status: 403 }), { + hostname: "127.0.0.1", + port: 0, + }); + const socket = createConnection({ host: "127.0.0.1", port: server.addr.port }); + try { + await new Promise((resolve, reject) => { + socket.once("connect", resolve); + socket.once("error", reject); + }); + ServerResponse.prototype.assignSocket = function (connection) { + original.call(this, connection); + queueMicrotask(() => { + injected++; + this.emit("error", new Error("injected asynchronous denial error")); + }); + }; + let timer: ReturnType | undefined; + const closed = new Promise((resolve, reject) => { + timer = setTimeout(() => reject(new Error("Failed denial transport did not close")), 1000); + socket.once("close", resolve); + socket.on("error", () => {}); + }); + socket.resume(); + socket.write( + "GET / HTTP/1.1\r\nHost: localhost\r\nConnection: Upgrade\r\nUpgrade: websocket\r\nSec-WebSocket-Version: 13\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\r\n", + ); + try { + await closed; + } finally { + clearTimeout(timer); + } + assertEquals(injected, 1); + } finally { + ServerResponse.prototype.assignSocket = original; + socket.destroy(); + await server.stop(); + } + }); + + it("stops while a peer leaves an upgrade denial unread", async () => { + if (!isNode) return; + const server = await createNodeServer(() => new Response("forbidden", { status: 403 }), { + hostname: "127.0.0.1", + port: 0, + }); + const transport = Promise.withResolvers(); + rawHttpTransport(server).once("upgrade", (_request, socket) => transport.resolve(socket)); + const socket = createConnection({ host: "127.0.0.1", port: server.addr.port }); + try { + await new Promise((resolve, reject) => { + socket.once("connect", resolve); + socket.once("error", reject); + }); + socket.write( + "GET / HTTP/1.1\r\nHost: localhost\r\nConnection: Upgrade\r\nUpgrade: websocket\r\nSec-WebSocket-Version: 13\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\r\n", + ); + const raw = await transport.promise; + const deadline = performance.now() + 1000; + while (!raw.writableEnded && performance.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 1)); + } + assertEquals(raw.writableEnded, true); + let timer: ReturnType | undefined; + try { + await Promise.race([ + server.stop(), + new Promise((_, reject) => { + timer = setTimeout( + () => reject(new Error("Unread denial escaped server teardown")), + 1000, + ); + }), + ]); + } finally { + clearTimeout(timer); + } + } finally { + socket.destroy(); + await server.stop(); + } + }); + + it("returns the denial response for an upgrade that the handler did not authorize", async () => { if (!isNode) return; let handlerCalls = 0; const server = await createNodeServer(() => { @@ -816,6 +908,9 @@ describe("NodeServer lifecycle", () => { socket.once("connect", resolve); socket.once("error", reject); }); + let denial = ""; + socket.setEncoding("utf8"); + socket.on("data", (chunk) => denial += chunk); socket.write( "GET /_ws HTTP/1.1\r\n" + "Host: 127.0.0.1\r\n" + @@ -839,6 +934,8 @@ describe("NodeServer lifecycle", () => { }); }); assertEquals(handlerCalls, 1); + assertStringIncludes(denial, "HTTP/1.1 403"); + assertStringIncludes(denial, "forbidden"); } finally { await server.stop(); } diff --git a/src/platform/adapters/runtime/node/http-server.ts b/src/platform/adapters/runtime/node/http-server.ts index 79380dabc1..908d30b24e 100644 --- a/src/platform/adapters/runtime/node/http-server.ts +++ b/src/platform/adapters/runtime/node/http-server.ts @@ -19,6 +19,22 @@ import { recordRequestTransportLifetime, } from "../shared/request-peer.ts"; +import { + getWebSocketUpgradeIdleTimeout, + isDenoWebSocketResponse, + recordNodeWebSocketUpgradeRequest, + recordWebSocketBufferedAmount, + revokeNodeWebSocketUpgradeRequest, +} from "../shared/http-upgrade-provenance.ts"; + +type NativeListener = import("node:net").Server & { closeAllConnections?(): void }; +type IncomingRequest = + | import("node:http").IncomingMessage + | import("node:http2").Http2ServerRequest; +type OutgoingResponse = + | import("node:http").ServerResponse + | import("node:http2").Http2ServerResponse; + const NodeTransportWithResolvers = Promise.withResolvers.bind(Promise); const pendingWebSocketUpgrades = new Map< @@ -266,7 +282,7 @@ function reportNodeRuntimeError( } function retireLateNodeListener( - server: import("node:http").Server, + server: NativeListener, onSettled: () => void, ): void { let settled = false; @@ -307,19 +323,19 @@ function retireLateNodeListener( } function absorbCanceledNodeListenError( - this: import("node:http").Server, + this: NativeListener, ): void { this.off("listening", retireCanceledNodeListener); } function retireCanceledNodeListener( - this: import("node:http").Server, + this: NativeListener, ): void { this.off("error", absorbCanceledNodeListenError); retireLateNodeListener(this, () => undefined); } -function armCanceledNodeListenGuards(server: import("node:http").Server): void { +function armCanceledNodeListenGuards(server: NativeListener): void { // Some Node resolver paths may still bind or emit a bind error after close() // reports that a pending hostname listen is closed. Shared one-shot guards // own either late outcome without retaining application/startup closures. @@ -328,7 +344,7 @@ function armCanceledNodeListenGuards(server: import("node:http").Server): void { } function waitForResponseDrain( - response: import("node:http").ServerResponse, + response: OutgoingResponse, signal: AbortSignal, ): Promise { if (signal.aborted) return Promise.reject(signal.reason ?? clientDisconnectedError()); @@ -446,8 +462,8 @@ export function createNodeRequestListener( handler: NodeRequestHandler, fallbackHostname = "localhost", ): ( - request: import("node:http").IncomingMessage, - response: import("node:http").ServerResponse, + request: IncomingRequest, + response: OutgoingResponse, ) => Promise { return async (_req, _res) => { const requestAbort = new AbortController(); @@ -477,17 +493,23 @@ export function createNodeRequestListener( _res.once("close", abortForPrematureResponseClose); try { - const url = new URL(_req.url ?? "/", `http://${_req.headers.host ?? fallbackHostname}`); + const http2 = _req.httpVersionMajor === 2; + const authority = http2 ? _req.headers[":authority"] : _req.headers.host; + const scheme = http2 && _req.headers[":scheme"] === "https" ? "https" : "http"; + const url = new URL(_req.url ?? "/", `${scheme}://${authority ?? fallbackHostname}`); const method = _req.method ?? "GET"; const body = method === "GET" || method === "HEAD" ? null : _req; const headers = new Headers(); for (const [key, value] of Object.entries(_req.headers)) { + if (key.startsWith(":")) continue; if (typeof value === "string") headers.append(key, value); else if (Array.isArray(value)) { for (const entry of value) headers.append(key, entry); } } + if (http2 && typeof authority === "string") headers.set("host", authority); + const requestInit: RequestInit & { duplex?: string } = { method, headers, @@ -522,17 +544,39 @@ export function createNodeRequestListener( } _res.statusCode = response.status; - _res.statusMessage = response.statusText; + if (!http2) _res.statusMessage = response.statusText; const responseHeaders = response.headers as Headers & { getSetCookie?: () => string[]; }; const setCookies = responseHeaders.getSetCookie?.() ?? []; + const connectionHeaders = http2 + ? new Set( + (response.headers.get("connection") ?? "").split(",").map((value) => + value.trim().toLowerCase() + ), + ) + : undefined; for (const [key, value] of response.headers) { + if ( + http2 && (connectionHeaders?.has(key.toLowerCase()) || + [ + "connection", + "http2-settings", + "keep-alive", + "proxy-connection", + "te", + "transfer-encoding", + "upgrade", + ] + .includes(key.toLowerCase())) + ) continue; if (key.toLowerCase() === "set-cookie" && setCookies.length > 0) continue; _res.setHeader(key, value); } - if (setCookies.length > 0) _res.setHeader("set-cookie", setCookies); + if (setCookies.length > 0 && !connectionHeaders?.has("set-cookie")) { + _res.setHeader("set-cookie", setCookies); + } if (response.body) { responseReader = response.body.getReader(); @@ -542,7 +586,8 @@ export function createNodeRequestListener( if (requestAbort.signal.aborted || _res.destroyed) { throw requestAbort.signal.reason ?? clientDisconnectedError(); } - if (!_res.write(value)) { + const writable = "stream" in _res ? _res.write(value) : _res.write(value); + if (!writable) { await waitForResponseDrain(_res, requestAbort.signal); } } @@ -588,10 +633,20 @@ export function createNodeServerWithStartupOwner( return createNodeServerInternal(handler, options, ownStartupServer); } +/** @internal Affected Deno listeners retain their native cleartext HTTP/2 contract. */ +export function createDenoNodeServerWithStartupOwner( + handler: NodeRequestHandler, + options: ServeOptions, + owner?: (server: NodeServer) => void, +): Promise { + return createNodeServerInternal(handler, options, owner, true); +} + async function createNodeServerInternal( handler: NodeRequestHandler, options: ServeOptions, ownStartupServer?: (server: NodeServer) => void, + dualProtocol = false, ): Promise { const { port = DEFAULT_PORT, @@ -600,7 +655,7 @@ async function createNodeServerInternal( onRuntimeError, signal, } = options; - const nodeWebSocketServerProvider = options.nodeWebSocketServerProvider === undefined + const nodeWebSocketServerProvider = options.nodeWebSocketServerProvider == null ? undefined : snapshotNodeWebSocketServerProvider(options.nodeWebSocketServerProvider); if (onRuntimeError !== undefined && typeof onRuntimeError !== "function") { @@ -692,7 +747,10 @@ async function createNodeServerInternal( return attempt; }; - const server = createServer(createNodeRequestListener(handler, hostname)); + const listener = createNodeRequestListener(handler, hostname); + const server = dualProtocol + ? new (await import("./dual-protocol-server.ts")).DualProtocolServer(listener) + : createServer(listener); const upgradeListener = ( request: import("node:http").IncomingMessage, @@ -706,8 +764,10 @@ async function createNodeServerInternal( const requestAbort = new AbortController(); upgradeAbortControllers.set(requestId, requestAbort); let transportSettled = false; + let webRequest: Request | undefined; const clearTransportState = (): void => { + if (webRequest) revokeNodeWebSocketUpgradeRequest(webRequest); activeRequestIds.delete(requestId); upgradeAbortControllers.delete(requestId); handshakeController.release(request); @@ -743,7 +803,7 @@ async function createNodeServerInternal( } headersRecord[NODE_WEBSOCKET_UPGRADE_ID_HEADER] = requestId; - const webRequest = new Request(url.toString(), { + webRequest = new Request(url.toString(), { method: request.method ?? "GET", headers: headersRecord, signal: requestAbort.signal, @@ -755,8 +815,36 @@ async function createNodeServerInternal( hostname: request.socket.remoteAddress, }); } + recordNodeWebSocketUpgradeRequest(webRequest, requestId); const response = await handler(webRequest); - + revokeNodeWebSocketUpgradeRequest(webRequest); + + if (!upgradesDisposed && !isWebSocketUpgradeResponse(response)) { + // Upgrade denials are ordinary HTTP responses. Reuse the same writer + // as regular requests so status, cookies and streamed bodies survive. + const denied = new Error("Request handler did not authorize a WebSocket upgrade"); + rejectWebSocketUpgrade(requestId, denied); + const { ServerResponse } = await import("node:http"); + const denial = new ServerResponse(request); + denial.once("error", (error) => { + failUpgrade(error); + socket.destroy(); + }); + denial.shouldKeepAlive = false; + denial.assignSocket(socket as import("node:net").Socket); + denial.once("finish", () => socket.end()); + await createNodeRequestListener(() => response, hostname)(request, denial); + transportSettled = true; + if (!requestAbort.signal.aborted) requestAbort.abort(denied); + clearTransportState(); + // A peer may leave a denial unread. Keep ownership until its socket + // actually closes so stop() can force-close this half-closed stream. + if (!socket.destroyed) { + rawUpgradeSockets.add(socket); + socket.once("close", () => rawUpgradeSockets.delete(socket)); + } + return; + } if (upgradesDisposed || !isWebSocketUpgradeResponse(response)) { failUpgrade( new Error( @@ -784,6 +872,17 @@ async function createNodeServerInternal( failUpgrade(new Error(NODE_WEBSOCKET_SERVER_PROVIDER_MISSING_MESSAGE)); return; } + const idleTimeout = getWebSocketUpgradeIdleTimeout(response) ?? 0; + if (idleTimeout > 0 && !nodeWebSocketServerProvider.supportsIdleTimeout) { + failUpgrade(new Error("WebSocket provider does not support the requested idle timeout")); + return; + } + if (isDenoWebSocketResponse(response) && !nodeWebSocketServerProvider.getBufferedAmount) { + failUpgrade( + new Error("Deno WebSocket provider must expose its transport buffered amount"), + ); + return; + } if (!wsServer) { const candidate = captureNodeWebSocketServer( nodeWebSocketServerProvider.createServer( @@ -816,6 +915,10 @@ async function createNodeServerInternal( } transportSettled = true; clearTransportState(); + const getBufferedAmount = nodeWebSocketServerProvider.getBufferedAmount; + if (getBufferedAmount) { + recordWebSocketBufferedAmount(ws, () => getBufferedAmount(ws)); + } if (!resolveWebSocketUpgrade(requestId, ws)) { if (ws.terminate) ws.terminate(); else ws.close(); diff --git a/src/platform/adapters/runtime/node/websocket-adapter.test.ts b/src/platform/adapters/runtime/node/websocket-adapter.test.ts index 973435bff1..025f9a49f4 100644 --- a/src/platform/adapters/runtime/node/websocket-adapter.test.ts +++ b/src/platform/adapters/runtime/node/websocket-adapter.test.ts @@ -16,6 +16,10 @@ import { rejectWebSocketUpgrade, resolveWebSocketUpgrade, } from "./http-server.ts"; +import { + getWebSocketUpgradeIdleTimeout, + recordNodeWebSocketUpgradeRequest, +} from "../shared/http-upgrade-provenance.ts"; import { NodeServerAdapter, NodeWebSocket } from "./websocket-adapter.ts"; import type { WSWebSocket } from "./types.ts"; @@ -48,7 +52,7 @@ function websocketRequest( protocols?: string, transportId?: string, ): Request { - return new Request("http://localhost/_ws", { + const request = new Request("http://localhost/_ws", { headers: { connection: "Upgrade", upgrade: "websocket", @@ -58,6 +62,8 @@ function websocketRequest( ...(transportId ? { [NODE_WEBSOCKET_UPGRADE_ID_HEADER]: transportId } : {}), }, }); + recordNodeWebSocketUpgradeRequest(request, transportId ?? key); + return request; } describe("NodeServerAdapter WebSocket upgrade", () => { @@ -505,3 +511,129 @@ describe("NodeWebSocket EventTarget compatibility", () => { assertEquals(socket.readyState, NodeWebSocket.CLOSED); }); }); + +describe("NodeWebSocket Deno idle timeout parity", () => { + it("terminates the transport once when heartbeat ping throws", async () => { + const socket = new NodeWebSocket(0.02); + const ws = createMockWs(); + const events: string[] = []; + ws.ping = () => { + events.push("ping"); + throw new Error("heartbeat transport failed"); + }; + ws.terminate = () => { + events.push("terminate"); + ws.emit("close", 1006); + }; + socket.onerror = () => events.push("error"); + socket.onclose = (event) => events.push(`close:${event.code}`); + socket._attachRealSocket(ws); + await new Promise((resolve) => setTimeout(resolve, 80)); + assertEquals(events, ["ping", "error", "terminate", "close:1006"]); + assertEquals(socket.readyState, 3); + }); + + it("emits one unclean 1001 timeout after ping, without outgoing-send reset", async () => { + const socket = new NodeWebSocket(0.04); + const ws = createMockWs(); + const events: string[] = []; + ws.ping = () => { + events.push("ping"); + }; + ws.terminate = () => { + events.push("terminate"); + ws.emit("close", 1006); + }; + ws.close = (code, reason) => { + events.push(`wire:${code}:${reason}`); + }; + socket.onerror = () => { + events.push("error"); + }; + socket.onclose = (event) => { + events.push(`close:${event.code}:${event.wasClean}:${event.reason}`); + }; + socket._attachRealSocket(ws); + const outgoing = setInterval(() => socket.readyState === 1 && socket.send("outgoing"), 5); + try { + await new Promise((resolve) => setTimeout(resolve, 100)); + assertEquals(events, [ + "ping", + "wire:1001:No response from ping frame.", + "error", + "close:1001:false:No response from ping frame.", + "terminate", + ]); + assertEquals(socket.readyState, 3); + } finally { + clearInterval(outgoing); + socket.close(); + } + }); + + it("resets heartbeat on incoming messages and pong, and disposes on close", async () => { + const socket = new NodeWebSocket(0.08); + const ws = createMockWs(); + let pings = 0; + ws.ping = () => { + pings++; + }; + ws.terminate = () => {}; + socket._attachRealSocket(ws); + const activity = setInterval(() => { + ws.emit("message", Buffer.from("incoming"), false); + ws.emit("pong"); + }, 10); + try { + await new Promise((resolve) => setTimeout(resolve, 110)); + assertEquals(pings, 0); + ws.emit("close", 1000); + } finally { + clearInterval(activity); + socket.close(); + } + await new Promise((resolve) => setTimeout(resolve, 100)); + assertEquals(pings, 0); + }); + + it("zero timeout requires no heartbeat capability", () => { + const { socket } = attach(); + assertEquals(socket.readyState, 1); + socket.close(); + }); + + it("rejects a connection lacking heartbeat termination support", () => { + const socket = new NodeWebSocket(30); + const events: string[] = []; + socket.onerror = () => { + events.push("error"); + }; + socket.onclose = () => { + events.push("close"); + }; + socket._attachRealSocket(createMockWs()); + assertEquals(events, ["error", "close"]); + assertEquals(socket.readyState, 3); + }); +}); + +Deno.test("Deno policy preserves default thirty-second heartbeat, zero and header rejection", () => { + const adapter = new NodeServerAdapter(true); + for ( + const [id, options, expected] of [ + ["deno-default", undefined, 30], + ["deno-zero", { idleTimeout: 0 }, 0], + ["deno-explicit", { idleTimeout: 12 }, 12], + ] as const + ) { + const { response } = adapter.upgradeWebSocket( + websocketRequest(undefined, undefined, id), + options, + ); + assertEquals(getWebSocketUpgradeIdleTimeout(response), expected); + rejectWebSocketUpgrade(id, new Error("test cleanup")); + } + assertThrows(() => + adapter.upgradeWebSocket(websocketRequest(), { headers: { "x-custom": "value" } }) + ); +}); diff --git a/src/platform/adapters/runtime/node/websocket-adapter.ts b/src/platform/adapters/runtime/node/websocket-adapter.ts index fd11434bc2..1ee60e6178 100644 --- a/src/platform/adapters/runtime/node/websocket-adapter.ts +++ b/src/platform/adapters/runtime/node/websocket-adapter.ts @@ -9,7 +9,12 @@ import { resolvePortableWebSocketUpgradeHeaders } from "../../../compat/http/web import type { WSMessageData, WSWebSocket } from "./types.ts"; import { INVALID_ARGUMENT } from "#veryfront/errors/error-registry/general.ts"; import { serverLogger } from "#veryfront/utils/logger/logger.ts"; -import { NODE_WEBSOCKET_UPGRADE_ID_HEADER, registerWebSocketUpgrade } from "./http-server.ts"; +import { registerWebSocketUpgrade } from "./http-server.ts"; +import { + consumeNodeWebSocketUpgradeRequest, + readWebSocketBufferedAmount, + recordWebSocketUpgradeIdleTimeout, +} from "../shared/http-upgrade-provenance.ts"; import * as crypto from "node:crypto"; const WEBSOCKET_KEY_PATTERN = /^[+/0-9A-Za-z]{22}==$/; @@ -44,10 +49,14 @@ function normalizeMessageData( export function resolveNodeWebSocketUpgradeHeaders( request: Request, options: WebSocketUpgradeOptions = {}, + denoPolicy = false, ): Headers { const headers = resolvePortableWebSocketUpgradeHeaders(request, options, { platform: "node", - runtimeName: "Node", + runtimeName: denoPolicy ? "Deno" : "Node", + supportsNonzeroIdleTimeout: denoPolicy, + supportsResponseHeaders: !denoPolicy, + unsupportedResponseHeadersDetail: "Deno does not support custom WebSocket response headers", unsupportedIdleTimeoutDetail: "The Node WebSocket provider contract does not support a per-connection transport idle timeout; use application-level heartbeats", }); @@ -90,13 +99,14 @@ export function resolveNodeWebSocketUpgradeHeaders( } export class NodeServerAdapter implements ServerAdapter { + constructor(private readonly denoPolicy = false) {} private readonly upgradedRequests = new WeakSet(); upgradeWebSocket( request: Request, options?: WebSocketUpgradeOptions, ): WebSocketUpgrade { - const headers = resolveNodeWebSocketUpgradeHeaders(request, options); + const headers = resolveNodeWebSocketUpgradeHeaders(request, options, this.denoPolicy); if (this.upgradedRequests.has(request)) { throw INVALID_ARGUMENT.create({ detail: "Node Request has already been upgraded", @@ -104,9 +114,9 @@ export class NodeServerAdapter implements ServerAdapter { }); } - const key = request.headers.get("sec-websocket-key")!; - const requestId = request.headers.get(NODE_WEBSOCKET_UPGRADE_ID_HEADER) ?? key; - const socket = new NodeWebSocket(); + const requestId = consumeNodeWebSocketUpgradeRequest(request); + const idleTimeout = options?.idleTimeout ?? (this.denoPolicy ? 30 : 0); + const socket = new NodeWebSocket(idleTimeout); this.upgradedRequests.add(request); void (async () => { @@ -119,23 +129,92 @@ export class NodeServerAdapter implements ServerAdapter { } })(); - return { - socket, - response: createWebSocketUpgradeResponse({ headers }), - }; + const response = createWebSocketUpgradeResponse({ headers }); + recordWebSocketUpgradeIdleTimeout(response, idleTimeout); + return { socket, response }; } } export class NodeWebSocket extends DeferredWebSocket { - constructor() { + private idleTimer: ReturnType | undefined; + private timedOut = false; + private realSocket?: WSWebSocket; + + _getBufferedAmount(): number { + return this.realSocket ? readWebSocketBufferedAmount(this.realSocket) : 0; + } + + constructor(private readonly idleTimeout = 0) { super("Node"); } + private clearIdleTimer(): void { + if (this.idleTimer !== undefined) clearTimeout(this.idleTimer); + this.idleTimer = undefined; + } + + override close(code?: number, reason?: string): void { + this.clearIdleTimer(); + super.close(code, reason); + } + _attachRealSocket(ws: WSWebSocket): void { + this.realSocket = ws; + if ( + this.idleTimeout > 0 && (typeof ws.ping !== "function" || typeof ws.terminate !== "function") + ) { + ws.terminate?.(); + this.fail( + new Error("WebSocket provider does not support idle timeout heartbeat and termination"), + ); + return; + } + const resetIdleTimer = (): void => { + this.clearIdleTimer(); + if (this.idleTimeout === 0 || this.readyState !== DeferredWebSocket.OPEN) return; + this.idleTimer = setTimeout(() => { + if (this.readyState !== DeferredWebSocket.OPEN) return; + try { + ws.ping!(); + } catch (error) { + this.clearIdleTimer(); + this.fail(error); + return; + } + this.idleTimer = setTimeout(() => { + if (this.readyState !== DeferredWebSocket.OPEN) return; + this.timedOut = true; + const reason = "No response from ping frame."; + this.readyState = DeferredWebSocket.CLOSING; + try { + ws.close(1001, reason); + } catch { + // An already-broken transport still receives the same terminal events. + } finally { + this.emitTransportError(new Error(reason)); + this.emitClose(1001, reason, false); + try { + ws.terminate!(); + } catch { /* Transport is already terminal. */ } + this.clearIdleTimer(); + } + }, this.idleTimeout * 500); + }, this.idleTimeout * 500); + }; + if (this.idleTimeout > 0) { + // Only a provider declaring heartbeat support promises this event overload. + const heartbeat = ws as WSWebSocket & { + on(event: "pong", listener: () => void): WSWebSocket; + }; + heartbeat.on("pong", resetIdleTimer); + } ws.on("message", (data: WSMessageData, isBinary: boolean) => { + resetIdleTimer(); this.emitMessage(normalizeMessageData(data, isBinary)); }); ws.on("close", (code?: number, reason?: ArrayBufferView | string) => { + this.clearIdleTimer(); + if (this.timedOut) return; this.emitClose( typeof code === "number" ? code : 1006, typeof reason === "string" ? reason : reason @@ -146,13 +225,16 @@ export class NodeWebSocket extends DeferredWebSocket { ); }); ws.on("error", (error: Error) => { + this.clearIdleTimer(); this.emitTransportError(error); }); this.attachTransport(ws); + resetIdleTimer(); } _emitError(error: Error): void { + this.clearIdleTimer(); this.fail(error); } } diff --git a/src/platform/adapters/runtime/shared/deferred-websocket.ts b/src/platform/adapters/runtime/shared/deferred-websocket.ts index 7cc159df68..dc4931aec8 100644 --- a/src/platform/adapters/runtime/shared/deferred-websocket.ts +++ b/src/platform/adapters/runtime/shared/deferred-websocket.ts @@ -28,11 +28,11 @@ function reportUnhandledError(error: unknown): void { }); } -function createCloseEvent(code = 1006, reason = ""): CloseEvent { +function createCloseEvent(code = 1006, reason = "", wasClean = code !== 1006): CloseEvent { return Object.assign(new Event("close"), { code, reason, - wasClean: code !== 1006, + wasClean, }) as CloseEvent; } @@ -104,8 +104,8 @@ export class DeferredWebSocket implements WebSocketConnection { this.dispatch("message", new MessageEvent("message", { data })); } - protected emitClose(code = 1006, reason = ""): void { - this.finishClose(createCloseEvent(code, reason)); + protected emitClose(code = 1006, reason = "", wasClean = code !== 1006): void { + this.finishClose(createCloseEvent(code, reason, wasClean)); } protected emitTransportError(error: unknown): void { diff --git a/src/platform/adapters/runtime/shared/http-upgrade-provenance.test.ts b/src/platform/adapters/runtime/shared/http-upgrade-provenance.test.ts new file mode 100644 index 0000000000..7a810112c2 --- /dev/null +++ b/src/platform/adapters/runtime/shared/http-upgrade-provenance.test.ts @@ -0,0 +1,70 @@ +import { it } from "#veryfront/testing/bdd.ts"; +import { assertEquals, assertThrows } from "#veryfront/testing/assert.ts"; +import { + consumeNodeWebSocketUpgradeRequest, + getWebSocketUpgradeIdleTimeout, + hasNodeWebSocketUpgradeRequest, + isDenoWebSocketResponse, + readWebSocketBufferedAmount, + recordDenoWebSocketResponse, + recordNodeWebSocketUpgradeRequest, + recordWebSocketBufferedAmount, + recordWebSocketUpgradeIdleTimeout, + revokeNodeWebSocketUpgradeRequest, +} from "./http-upgrade-provenance.ts"; + +it("upgrade provenance does not expose requests to replaced collection methods", () => { + const request = new Request("http://localhost", { headers: { authorization: "synthetic" } }); + const clone = request.clone(); + const response = {}; + const connection = {}; + const map = Object.getOwnPropertyDescriptors(WeakMap.prototype); + const set = Object.getOwnPropertyDescriptors(WeakSet.prototype); + let observations = 0; + const observed = () => { + observations++; + throw new Error("authored collection observer"); + }; + let granted = false; + let cloned = true; + let consumed: string | undefined; + let revoked = true; + let timeout: number | undefined; + let dom = false; + let buffered = -1; + try { + for (const key of ["set", "get", "has", "delete"]) { + Object.defineProperty(WeakMap.prototype, key, { ...map[key], value: observed }); + } + for (const key of ["add", "has"]) { + Object.defineProperty(WeakSet.prototype, key, { ...set[key], value: observed }); + } + recordNodeWebSocketUpgradeRequest(request, "exact"); + granted = hasNodeWebSocketUpgradeRequest(request); + cloned = hasNodeWebSocketUpgradeRequest(clone); + consumed = consumeNodeWebSocketUpgradeRequest(request); + recordNodeWebSocketUpgradeRequest(request, "revoked"); + revokeNodeWebSocketUpgradeRequest(request); + revoked = hasNodeWebSocketUpgradeRequest(request); + recordWebSocketUpgradeIdleTimeout(response, 30); + timeout = getWebSocketUpgradeIdleTimeout(response); + recordDenoWebSocketResponse(response); + dom = isDenoWebSocketResponse(response); + recordWebSocketBufferedAmount(connection, () => 7); + buffered = readWebSocketBufferedAmount(connection); + } finally { + Object.defineProperties(WeakMap.prototype, map); + Object.defineProperties(WeakSet.prototype, set); + } + assertEquals({ observations, granted, cloned, consumed, revoked, timeout, dom, buffered }, { + observations: 0, + granted: true, + cloned: false, + consumed: "exact", + revoked: false, + timeout: 30, + dom: true, + buffered: 7, + }); + assertThrows(() => consumeNodeWebSocketUpgradeRequest(clone), Error, "original request"); +}); diff --git a/src/platform/adapters/runtime/shared/http-upgrade-provenance.ts b/src/platform/adapters/runtime/shared/http-upgrade-provenance.ts new file mode 100644 index 0000000000..f1da516fd6 --- /dev/null +++ b/src/platform/adapters/runtime/shared/http-upgrade-provenance.ts @@ -0,0 +1,71 @@ +import { INVALID_ARGUMENT } from "#veryfront/errors/error-registry/general.ts"; + +// Capture before authored code can replace collection methods and observe +// credential-bearing inbound requests at the transport boundary. +const apply = Reflect.apply; +const mapSet = WeakMap.prototype.set; +const mapGet = WeakMap.prototype.get; +const mapHas = WeakMap.prototype.has; +const mapDelete = WeakMap.prototype.delete; +const setAdd = WeakSet.prototype.add; +const setHas = WeakSet.prototype.has; + +const requests = new WeakMap(); +const idleTimeouts = new WeakMap(); + +/** @internal Only the listener grants upgrade ownership to its exact request. */ +export function recordNodeWebSocketUpgradeRequest(request: Request, id: string): void { + apply(mapSet, requests, [request, id]); +} + +/** @internal Cloning a request does not transfer its live transport. */ +export function hasNodeWebSocketUpgradeRequest(request: Request): boolean { + return apply(mapHas, requests, [request]); +} + +/** @internal An upgrade grant is consumed once by the owning request. */ +export function consumeNodeWebSocketUpgradeRequest(request: Request): string { + const id = apply(mapGet, requests, [request]) as string | undefined; + if (id === undefined) { + throw INVALID_ARGUMENT.create({ + detail: "WebSocket upgrade requires the original request from its HTTP listener", + }); + } + apply(mapDelete, requests, [request]); + return id; +} + +/** @internal Response policy never travels through caller-controlled headers. */ +export function recordWebSocketUpgradeIdleTimeout(response: object, seconds: number): void { + apply(mapSet, idleTimeouts, [response, seconds]); +} + +/** @internal Read the policy attached by the trusted upgrade adapter. */ +export function getWebSocketUpgradeIdleTimeout(response: object): number | undefined { + return apply(mapGet, idleTimeouts, [response]) as number | undefined; +} + +/** @internal Revoke any unused grant when the listener finishes handling it. */ +export function revokeNodeWebSocketUpgradeRequest(request: Request): void { + apply(mapDelete, requests, [request]); +} + +const domResponses = new WeakSet(); +const bufferedAmounts = new WeakMap number>(); + +export function recordDenoWebSocketResponse(response: object): void { + apply(setAdd, domResponses, [response]); +} +export function isDenoWebSocketResponse(response: object): boolean { + return apply(setHas, domResponses, [response]); +} +export function recordWebSocketBufferedAmount(connection: object, read: () => number): void { + apply(mapSet, bufferedAmounts, [connection, read]); +} +export function readWebSocketBufferedAmount(connection: object): number { + const read = apply(mapGet, bufferedAmounts, [connection]) as (() => number) | undefined; + if (!read) { + throw new TypeError("WebSocket provider does not expose its transport buffered amount"); + } + return read(); +} diff --git a/src/platform/adapters/runtime/shared/server-lifecycle.ts b/src/platform/adapters/runtime/shared/server-lifecycle.ts index b07db0d8d8..40dfc9802a 100644 --- a/src/platform/adapters/runtime/shared/server-lifecycle.ts +++ b/src/platform/adapters/runtime/shared/server-lifecycle.ts @@ -1,6 +1,6 @@ -import type { ServeOptions, Server } from "../../base.ts"; +import type { HttpHandlerResponse, ServeOptions, Server } from "../../base.ts"; -type RequestHandler = (request: Request) => Promise | Response; +type RequestHandler = (request: Request) => Promise | HttpHandlerResponse; type ServerFactory = ( handler: RequestHandler, options: ServeOptions, diff --git a/src/platform/compat/http/deno-server.ts b/src/platform/compat/http/deno-server.ts index 745ea63df8..b3e0efcb62 100644 --- a/src/platform/compat/http/deno-server.ts +++ b/src/platform/compat/http/deno-server.ts @@ -1,3 +1,10 @@ +import { createDenoNodeServerWithStartupOwner } from "../../adapters/runtime/node/http-server.ts"; +import { NodeHttpServer } from "./node-server.ts"; +import { + needsDenoNodeHttpTransport, + resolveDenoNodeHttpOptions, + scopeDenoNodeHttpHandler, +} from "../../adapters/runtime/deno/http-transport.ts"; import type { Handler, HttpServer, ServeOptions } from "./types.ts"; import { recordDenoServeRequestPeer } from "#veryfront/platform/adapters/runtime/shared/request-peer.ts"; import { LOCALHOST } from "../constants.ts"; @@ -47,15 +54,64 @@ function readBoundAddress( export class DenoHttpServer implements HttpServer { private active?: ActiveDenoServer; + private fallback?: { + server: NodeHttpServer; + controller: AbortController; + finished: ReturnType>; + stopFailed: boolean; + }; async serve(handler: Handler, options: ServeOptions = {}): Promise { - if (this.active) { + if (this.active || this.fallback) { throw INVALID_ARGUMENT.create({ detail: "DenoHttpServer.serve() cannot run more than once concurrently", context: { platform: "deno", operation: "serve" }, }); } + if (options.signal?.aborted) throw abortError(options.signal); + + if (needsDenoNodeHttpTransport()) { + const server = new NodeHttpServer(createDenoNodeServerWithStartupOwner); + const controller = new AbortController(); + const finished = Promise.withResolvers(); + const state = { server, controller, finished, stopFailed: false }; + // Teardown may fail before asynchronous provider resolution finishes. + void finished.promise.catch(() => undefined); + this.fallback = state; + const abort = () => { + controller.abort(options.signal?.reason); + void this.stopFallback(state).catch(() => undefined); + }; + options.signal?.addEventListener("abort", abort, { once: true }); + if (options.signal?.aborted) abort(); + try { + const resolved = await resolveDenoNodeHttpOptions({ + ...options, + hostname: options.hostname ?? LOCALHOST.IPV4, + port: options.port ?? DEFAULT_COMPAT_HTTP_PORT, + signal: controller.signal, + }); + await server.serve(scopeDenoNodeHttpHandler(handler), resolved); + await finished.promise; + } catch (error) { + if (state.stopFailed) throw error; + try { + await this.stopFallback(state); + } catch (cleanupError) { + throw new AggregateError( + [error, cleanupError], + "Deno HTTP fallback cleanup remains incomplete", + ); + } + throw error; + } finally { + options.signal?.removeEventListener("abort", abort); + if (this.fallback === state && !state.stopFailed) this.fallback = undefined; + } + return; + } + const { port = DEFAULT_COMPAT_HTTP_PORT, hostname = LOCALHOST.IPV4, @@ -136,6 +192,11 @@ export class DenoHttpServer implements HttpServer { } close(): Promise { + const fallback = this.fallback; + if (fallback) { + fallback.controller.abort(new DOMException("Deno HTTP server stopped", "AbortError")); + return this.stopFallback(fallback); + } const state = this.active; if (!state) return Promise.resolve(); return this.stop(state).then(() => { @@ -143,6 +204,19 @@ export class DenoHttpServer implements HttpServer { }); } + private async stopFallback(state: NonNullable): Promise { + try { + await state.server.close(); + state.stopFailed = false; + state.finished.resolve(); + if (this.fallback === state) this.fallback = undefined; + } catch (error) { + state.stopFailed = true; + state.finished.reject(error); + throw error; + } + } + private stop(state: ActiveDenoServer): Promise { if (state.stopped) return Promise.resolve(); if (state.stopPromise) return state.stopPromise; diff --git a/src/platform/compat/http/native-response.ts b/src/platform/compat/http/native-response.ts index 265fc4b22c..53119c45fc 100644 --- a/src/platform/compat/http/native-response.ts +++ b/src/platform/compat/http/native-response.ts @@ -1,3 +1,4 @@ +import { type HttpHandlerResponse, isWebSocketUpgradeResponse } from "../../adapters/base.ts"; /** * Shared helpers for bridging polyfilled `Response` objects to native ones * required by `Deno.serve`. @@ -81,9 +82,12 @@ export function getNativeDeno(): typeof Deno | undefined { * into a fresh native `Response`. */ export function toNativeResponse( - response: Response, + response: HttpHandlerResponse, NativeResponse: typeof Response, ): Response { + if (isWebSocketUpgradeResponse(response)) { + throw new TypeError("A branded WebSocket response requires its owning HTTP transport"); + } // If already native (compiled binary or WebSocket upgrade), return as-is. if (response instanceof NativeResponse) return response; // Re-wrap polyfilled Response as native Response. diff --git a/src/platform/compat/http/node-server.ts b/src/platform/compat/http/node-server.ts index 4281981bd3..0151056b0b 100644 --- a/src/platform/compat/http/node-server.ts +++ b/src/platform/compat/http/node-server.ts @@ -52,6 +52,7 @@ function detachExternalSignal( * transport implementation. */ export class NodeHttpServer implements HttpServer { + constructor(private readonly createServer = createNodeServerWithStartupOwner) {} private pending?: PendingNodeServer; private active?: ActiveNodeServer; @@ -79,7 +80,7 @@ export class NodeHttpServer implements HttpServer { "AbortError", ), startPromise: Promise.resolve().then(() => - createNodeServerWithStartupOwner( + this.createServer( handler, { ...options, diff --git a/src/platform/compat/http/types.ts b/src/platform/compat/http/types.ts index e5812c9520..ef0cce8f7e 100644 --- a/src/platform/compat/http/types.ts +++ b/src/platform/compat/http/types.ts @@ -1,4 +1,9 @@ +import type { HttpHandlerResponse } from "../../adapters/base.ts"; +import type { NodeWebSocketServerProvider } from "#veryfront/extensions/websocket"; + export interface ServeOptions { + /** Deno: undefined preserves implicit WS support; null explicitly disables it. */ + nodeWebSocketServerProvider?: Readonly | null; port?: number; hostname?: string; signal?: AbortSignal; @@ -11,7 +16,7 @@ export interface ServeOptions { onRuntimeError?: (error: Error) => void | Promise; } -export type Handler = (request: Request) => Response | Promise; +export type Handler = (request: Request) => HttpHandlerResponse | Promise; export interface HttpServer { /** @@ -28,7 +33,7 @@ export interface HttpServer { export interface WebSocketUpgradeResult { socket: WebSocket; - response: Response; + response: HttpHandlerResponse; } export interface WebSocketUpgradeOptions { diff --git a/src/platform/compat/http/websocket.ts b/src/platform/compat/http/websocket.ts index ebfcdb6b0a..87deab1d79 100644 --- a/src/platform/compat/http/websocket.ts +++ b/src/platform/compat/http/websocket.ts @@ -1,3 +1,5 @@ +import { upgradeDenoNodeWebSocket } from "../../adapters/runtime/deno/node-websocket.ts"; +import { hasNodeWebSocketUpgradeRequest } from "../../adapters/runtime/shared/http-upgrade-provenance.ts"; import { isDeno } from "../runtime.ts"; import type { WebSocketUpgradeOptions, WebSocketUpgradeResult } from "./types.ts"; import { getNativeDeno } from "./native-response.ts"; @@ -16,6 +18,9 @@ export function upgradeWebSocket( request: Request, options?: WebSocketUpgradeOptions, ): WebSocketUpgradeResult { + if (hasNodeWebSocketUpgradeRequest(request) && isDeno) { + return upgradeDenoNodeWebSocket(request, options); + } if (!isDeno) { throw NOT_SUPPORTED.create({ detail: "WebSocket upgrade on Node.js requires server-level handling. " + diff --git a/src/proxy/main.ts b/src/proxy/main.ts index d33f152354..2dc3d75b05 100644 --- a/src/proxy/main.ts +++ b/src/proxy/main.ts @@ -1,3 +1,7 @@ +import { + type HttpHandlerResponse, + isWebSocketUpgradeResponse, +} from "#veryfront/platform/adapters/base.ts"; /** * Veryfront Proxy Server (Split Mode) * @@ -309,7 +313,7 @@ if (Object.keys(proxyHandler.localProjects).length > 0) { * Handle WebSocket upgrade requests. * Bridges browser WebSocket to server HMR WebSocket endpoint. */ -async function handleWebSocketUpgrade(req: Request, url: URL): Promise { +async function handleWebSocketUpgrade(req: Request, url: URL): Promise { const authorization = await authorizeWebSocketRequest( req, url, @@ -827,7 +831,7 @@ function describeRejectedHostHeader(host: string | null): string { /** * Main router. */ -async function router(req: Request): Promise { +async function router(req: Request): Promise { let url: URL; try { url = new URL(req.url); @@ -857,7 +861,7 @@ async function router(req: Request): Promise { proxyRequestDrainTracker.start(requestId, req.method, url.pathname); try { - let response: Response; + let response: HttpHandlerResponse; if (req.headers.get("upgrade")?.toLowerCase() === "websocket") { response = await handleWebSocketUpgrade(req, url); } else if (url.pathname === PROXY_ROUTING_INVALIDATION_PATH) { @@ -879,6 +883,10 @@ async function router(req: Request): Promise { response = await forwardToServer(req, url); } + if (isWebSocketUpgradeResponse(response)) { + proxyRequestDrainTracker.complete(requestId); + return response; + } return proxyRequestDrainTracker.completeOnResponseEnd(requestId, req, response); } catch (error) { proxyRequestDrainTracker.complete(requestId); @@ -1037,4 +1045,12 @@ proxyLogger.debug("Starting proxy server (split mode)", { }); // Start the HTTP server -await server.serve(router, { port: PORT, hostname: HOST }); +const { WsNodeWebSocketServerProvider } = await importFirstPartyExtensionModule<{ + WsNodeWebSocketServerProvider: + import("#veryfront/extensions/websocket").NodeWebSocketServerProvider; +}>("ext-node-websocket-ws", "@veryfront/ext-node-websocket-ws"); +await server.serve(router, { + port: PORT, + hostname: HOST, + nodeWebSocketServerProvider: WsNodeWebSocketServerProvider, +}); diff --git a/src/server/bootstrap.ts b/src/server/bootstrap.ts index cd92965ff8..69456fe7ae 100644 --- a/src/server/bootstrap.ts +++ b/src/server/bootstrap.ts @@ -91,7 +91,7 @@ export interface BootstrapResult { extensionLoader: ExtensionLoader; /** Immutable Node WebSocket implementation selected by this extension generation. */ - nodeWebSocketServerProvider?: Readonly; + nodeWebSocketServerProvider?: Readonly | null; /** * Dispose bootstrap resources: tears down extensions (reverse order), @@ -395,7 +395,7 @@ export async function bootstrap( config, usingFSAdapter: false, extensionLoader, - ...(nodeWebSocketServerProvider === undefined ? {} : { nodeWebSocketServerProvider }), + nodeWebSocketServerProvider: nodeWebSocketServerProvider ?? null, dispose: combineDispose(extensionLoader, undefined, fileLog), }; } @@ -449,7 +449,7 @@ export async function bootstrap( config, usingFSAdapter: false, extensionLoader, - ...(nodeWebSocketServerProvider === undefined ? {} : { nodeWebSocketServerProvider }), + nodeWebSocketServerProvider: nodeWebSocketServerProvider ?? null, dispose: combineDispose(extensionLoader, undefined, fileLog), }; } @@ -535,7 +535,7 @@ export async function bootstrap( usingFSAdapter: true, fsAdapterType: fsType, extensionLoader, - ...(nodeWebSocketServerProvider === undefined ? {} : { nodeWebSocketServerProvider }), + nodeWebSocketServerProvider: nodeWebSocketServerProvider ?? null, dispose: combineDispose(extensionLoader, fsDispose, fileLog), }; } catch (err) { diff --git a/src/server/dev-server/server.ts b/src/server/dev-server/server.ts index 063bbd933e..7c2d0f48bb 100644 --- a/src/server/dev-server/server.ts +++ b/src/server/dev-server/server.ts @@ -84,7 +84,7 @@ export class DevServer { private adapter!: RuntimeAdapter; private server?: Server; private appConfig: VeryfrontConfig | undefined; - private _nodeWebSocketServerProvider?: Readonly; + private _nodeWebSocketServerProvider?: Readonly | null; private requestHandler?: RequestHandler; private _handler?: DevServerHandler; readonly ready: Promise; @@ -317,7 +317,7 @@ export class DevServer { port: this.options.port, hostname: this.options.bindAddress ?? LOCALHOST.IPV4, signal: this.options.signal, - nodeWebSocketServerProvider: this._nodeWebSocketServerProvider, + nodeWebSocketServerProvider: this._nodeWebSocketServerProvider ?? null, onListen: ({ port }: { hostname: string; port: number }) => { const url = buildLocalhostUrl(port); logger.debug(`Dev server running at ${url}`); @@ -349,7 +349,7 @@ export class DevServer { } /** Extension-provided Node WebSocket implementation captured with this bootstrap generation. */ - get nodeWebSocketServerProvider(): Readonly | undefined { + get nodeWebSocketServerProvider(): Readonly | null | undefined { return this._nodeWebSocketServerProvider; } diff --git a/src/server/index.ts b/src/server/index.ts index 21df20744a..ec9cb5e980 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -1,3 +1,10 @@ +import { + getWebSocketUpgradeIdleTimeout, + isDenoWebSocketResponse, + recordNodeWebSocketUpgradeRequest, + recordWebSocketBufferedAmount, + revokeNodeWebSocketUpgradeRequest, +} from "#veryfront/platform/adapters/runtime/shared/http-upgrade-provenance.ts"; /** * Create and run Veryfront servers. * @@ -309,7 +316,7 @@ export async function createHandler( const upgrade = (server: unknown) => { if (disposalStarted) throw new Error("Veryfront handler is already shutting down"); - if (nodeWebSocketServerProvider === undefined) { + if (nodeWebSocketServerProvider == null) { throw new Error(NODE_WEBSOCKET_SERVER_PROVIDER_MISSING_MESSAGE); } const httpServer = server as import("node:http").Server; @@ -384,13 +391,19 @@ export async function createHandler( } if (webSocketKey) headersRecord["sec-websocket-key"] = webSocketKey; headersRecord[upgradeRegistry.NODE_WEBSOCKET_UPGRADE_ID_HEADER] = requestId; - const upgradeResponse = await internalFetch( - new Request(url.toString(), { - method: "GET", - headers: headersRecord, - signal: requestAbort.signal, - }), - ); + const webRequest = new Request(url.toString(), { + method: "GET", + headers: headersRecord, + signal: requestAbort.signal, + }); + recordNodeWebSocketUpgradeRequest(webRequest, requestId); + const upgradeResponse = await (async () => { + try { + return await internalFetch(webRequest); + } finally { + revokeNodeWebSocketUpgradeRequest(webRequest); + } + })(); if (!isWebSocketUpgradeResponse(upgradeResponse)) { rejectWebSocketUpgrade( requestId, @@ -415,6 +428,18 @@ export async function createHandler( return; } + if ( + (getWebSocketUpgradeIdleTimeout(upgradeResponse) ?? 0) > 0 && + !nodeWebSocketServerProvider.supportsIdleTimeout + ) { + throw new Error("WebSocket provider does not support the requested idle timeout"); + } + if ( + isDenoWebSocketResponse(upgradeResponse) && + !nodeWebSocketServerProvider.getBufferedAmount + ) { + throw new Error("Deno WebSocket provider must expose its transport buffered amount"); + } if (!wsServer) { const candidate = captureNodeWebSocketServer( nodeWebSocketServerProvider.createServer( @@ -439,6 +464,8 @@ export async function createHandler( head, (ws: NodeWebSocketConnection) => { releaseTrackedSocket(); + const getBufferedAmount = nodeWebSocketServerProvider.getBufferedAmount; + if (getBufferedAmount) recordWebSocketBufferedAmount(ws, () => getBufferedAmount(ws)); if (!upgradeRegistry.resolveWebSocketUpgrade(requestId, ws)) { if (ws.terminate) ws.terminate(); else ws.close(); diff --git a/src/server/production-server.ts b/src/server/production-server.ts index 3080980b63..d8eee087d1 100644 --- a/src/server/production-server.ts +++ b/src/server/production-server.ts @@ -242,8 +242,8 @@ export function startProductionServerWithDependencies( ): Promise { const suppliedBootstrap = options.bootstrapResult; const suppliedProviderSource = suppliedBootstrap?.nodeWebSocketServerProvider; - const suppliedNodeWebSocketServerProvider = suppliedProviderSource === undefined - ? undefined + const suppliedNodeWebSocketServerProvider = suppliedProviderSource == null + ? null : snapshotNodeWebSocketServerProvider(suppliedProviderSource); let ownedBootstrap: BootstrapResult | undefined; let ownedBootstrapDisposal: Promise | undefined; @@ -501,7 +501,7 @@ export function startProductionServerWithDependencies( port, hostname: bindAddress, // Deno uses "hostname" for bind address signal, - nodeWebSocketServerProvider, + nodeWebSocketServerProvider: nodeWebSocketServerProvider ?? null, onListen: (params) => { resolveListenReady?.(); logger.info("Production server listening", params); diff --git a/src/server/service-server.ts b/src/server/service-server.ts index 2b9dc2ed85..f713dd231b 100644 --- a/src/server/service-server.ts +++ b/src/server/service-server.ts @@ -53,7 +53,7 @@ export type StartNodeVeryfrontServerOptions = { signals?: readonly NodeJS.Signals[]; hardShutdownTimeoutMs?: number; /** Extension-provided Node WebSocket implementation; upgrades fail closed when omitted. */ - nodeWebSocketServerProvider?: Readonly; + nodeWebSocketServerProvider?: Readonly | null; }; /** Options accepted by start veryfront server. */ @@ -65,7 +65,7 @@ export type StartVeryfrontServerOptions = { signals?: readonly NodeJS.Signals[]; hardShutdownTimeoutMs?: number; /** Used only when the selected runtime is Node.js. */ - nodeWebSocketServerProvider?: Readonly; + nodeWebSocketServerProvider?: Readonly | null; }; /** Public API contract for veryfront service server runtime kind. */ diff --git a/tests/integration/core/deno-http-cancellation.test.ts b/tests/integration/core/deno-http-cancellation.test.ts new file mode 100644 index 0000000000..5d184b4768 --- /dev/null +++ b/tests/integration/core/deno-http-cancellation.test.ts @@ -0,0 +1,251 @@ +import { describe, it } from "#veryfront/testing/bdd.ts"; +import { assertEquals } from "#veryfront/testing/assert.ts"; +import { DenoHttpServer } from "#veryfront/platform/compat/http/deno-server.ts"; +import { DenoAdapter } from "#veryfront/platform/adapters/runtime/deno/adapter.ts"; + +async function writeAll(connection: Deno.Conn, text: string): Promise { + const bytes = new TextEncoder().encode(text); + let offset = 0; + while (offset < bytes.length) offset += await connection.write(bytes.subarray(offset)); +} + +describe("Deno HTTP request cancellation", () => { + for (const transport of ["compatibility", "runtime"] as const) { + it(`observes disconnect after consuming a segmented body through ${transport}`, async () => { + const started = Promise.withResolvers(); + const aborted = Promise.withResolvers(); + const released = Promise.withResolvers(); + const listening = Promise.withResolvers(); + const handler = async (request: Request): Promise => { + await request.text(); + request.signal.addEventListener("abort", () => aborted.resolve(), { once: true }); + started.resolve(); + await released.promise; + return new Response("complete"); + }; + const compatibility = new DenoHttpServer(); + const runtime = new DenoAdapter(); + const options = { + hostname: "127.0.0.1", + port: 0, + onListen: ({ port }: { port: number }) => listening.resolve(port), + }; + const running = transport === "compatibility" + ? compatibility.serve(handler, options) + : runtime.serve(handler, options); + const connection = await Deno.connect({ + hostname: "127.0.0.1", + port: await listening.promise, + }); + const body = "x".repeat(65536); + let timeout: ReturnType | undefined; + try { + await writeAll( + connection, + `POST / HTTP/1.1\r\nHost: localhost\r\nContent-Length: ${body.length}\r\n\r\n${ + body.slice(0, 1024) + }`, + ); + await new Promise((resolve) => setTimeout(resolve, 50)); + await writeAll(connection, body.slice(1024)); + await started.promise; + connection.close(); + const observed = await Promise.race([ + aborted.promise.then(() => true), + new Promise((resolve) => { + timeout = setTimeout(() => resolve(false), 1000); + }), + ]); + assertEquals(observed, true); + } finally { + if (timeout !== undefined) clearTimeout(timeout); + released.resolve(); + await compatibility.close(); + await runtime.shutdown(); + await running; + } + }); + } +}); + +for (const transport of ["compatibility", "runtime"] as const) { + it(`preserves implicit Deno WebSocket support through ${transport}`, async () => { + const compatibility = new DenoHttpServer(); + const runtime = new DenoAdapter(); + const listening = Promise.withResolvers(); + const { upgradeWebSocket } = await import("#veryfront/platform/compat/http/websocket.ts"); + const handler = (request: Request) => { + const upgrade = transport === "compatibility" + ? upgradeWebSocket(request, { protocol: "echo", idleTimeout: 1 }) + : runtime.server.upgradeWebSocket(request, { protocol: "echo", idleTimeout: 1 }); + upgrade.socket.addEventListener("message", (event) => { + upgrade.socket.send((event as MessageEvent).data); + }); + return upgrade.response; + }; + const options = { + hostname: "127.0.0.1", + port: 0, + onListen: ({ port }: { port: number }) => listening.resolve(port), + }; + const running = transport === "compatibility" + ? compatibility.serve(handler, options) + : runtime.serve(handler, options); + const client = new WebSocket(`ws://127.0.0.1:${await listening.promise}/`, "echo"); + try { + await new Promise((resolve, reject) => { + client.onopen = () => resolve(); + client.onerror = () => reject(new Error("Deno default WebSocket handshake failed")); + }); + assertEquals(client.protocol, "echo"); + const reply = Promise.withResolvers(); + client.onmessage = (event) => reply.resolve(event.data); + client.send("hello"); + assertEquals(await reply.promise, "hello"); + const binary = Promise.withResolvers(); + client.binaryType = "arraybuffer"; + client.onmessage = (event) => binary.resolve(event.data); + client.send(new Uint8Array([0, 255, 8])); + assertEquals( + new Uint8Array(await binary.promise as ArrayBuffer), + new Uint8Array([0, 255, 8]), + ); + } finally { + const closed = Promise.withResolvers(); + client.onclose = () => closed.resolve(); + client.close(); + await closed.promise; + await compatibility.close(); + await runtime.shutdown(); + await running; + } + }); +} + +for (const transport of ["compatibility", "runtime"] as const) { + it(`isolates request async context through ${transport}`, async () => { + const { AsyncLocalStorage } = await import("node:async_hooks"); + const storage = new AsyncLocalStorage(); + const compatibility = new DenoHttpServer(); + const runtime = new DenoAdapter(); + const listening = Promise.withResolvers(); + const handler = async (request: Request) => { + const initial = storage.getStore(); + const id = new URL(request.url).pathname; + storage.enterWith(id); + await new Promise((resolve) => setTimeout(resolve, id === "/first" ? 10 : 1)); + return Response.json({ initial: initial ?? null, current: storage.getStore() }); + }; + const options = { + hostname: "127.0.0.1", + port: 0, + onListen: ({ port }: { port: number }) => listening.resolve(port), + }; + const running = transport === "compatibility" + ? compatibility.serve(handler, options) + : runtime.serve(handler, options); + const base = `http://127.0.0.1:${await listening.promise}`; + try { + const first = fetch(`${base}/first`).then((response) => response.json()); + const second = fetch(`${base}/second`).then((response) => response.json()); + assertEquals(await first, { initial: null, current: "/first" }); + assertEquals(await second, { initial: null, current: "/second" }); + assertEquals(await (await fetch(`${base}/later`)).json(), { + initial: null, + current: "/later", + }); + } finally { + storage.disable(); + await compatibility.close(); + await runtime.shutdown(); + await running; + } + }); +} + +it("settles serving on a failed fallback close and retains teardown for retry", async () => { + const { NodeHttpServer } = await import("#veryfront/platform/compat/http/node-server.ts"); + const { assertRejects } = await import("#veryfront/testing/assert.ts"); + const originalClose = NodeHttpServer.prototype.close; + const compatibility = new DenoHttpServer(); + const listening = Promise.withResolvers(); + const failure = new Error("injected close failure"); + const outcome = compatibility.serve(() => new Response("ok"), { + hostname: "127.0.0.1", + port: 0, + onListen: () => listening.resolve(), + }).then(() => undefined, (error) => error); + await listening.promise; + try { + NodeHttpServer.prototype.close = () => Promise.reject(failure); + await assertRejects(() => compatibility.close(), Error, "injected close failure"); + assertEquals(await outcome, failure); + } finally { + NodeHttpServer.prototype.close = originalClose; + await compatibility.close(); + } +}); + +it("close during async provider resolution prevents a listener from escaping", async () => { + const compatibility = new DenoHttpServer(); + let listened = false; + const serving = compatibility.serve(() => new Response("ok"), { + hostname: "127.0.0.1", + port: 0, + onListen: () => { + listened = true; + }, + }).then(() => undefined, (error) => error); + await compatibility.close(); + const result = await serving; + assertEquals(result instanceof DOMException && result.name === "AbortError", true); + assertEquals(listened, false); + await compatibility.close(); +}); + +it("Deno compatibility fallback preserves its default listening address", async () => { + const { NodeHttpServer } = await import("#veryfront/platform/compat/http/node-server.ts"); + const original = NodeHttpServer.prototype.serve; + const seen = Promise.withResolvers<{ hostname?: string; port?: number }>(); + NodeHttpServer.prototype.serve = async (_handler, options) => { + seen.resolve(options ?? {}); + }; + const server = new DenoHttpServer(); + const running = server.serve(() => new Response("ok"), { nodeWebSocketServerProvider: null }); + try { + const options = await seen.promise; + assertEquals([options.hostname, options.port], ["127.0.0.1", 8000]); + } finally { + await server.close(); + await running; + NodeHttpServer.prototype.serve = original; + } +}); + +it("Deno fallback retains startup ownership until cleanup can be retried", async () => { + const { NodeHttpServer } = await import("#veryfront/platform/compat/http/node-server.ts"); + const originalServe = NodeHttpServer.prototype.serve; + const originalClose = NodeHttpServer.prototype.close; + let closes = 0; + NodeHttpServer.prototype.serve = () => Promise.reject(new Error("startup failed")); + NodeHttpServer.prototype.close = () => + ++closes === 1 ? Promise.reject(new Error("cleanup failed")) : Promise.resolve(); + const server = new DenoHttpServer(); + try { + let failed = false; + try { + await server.serve(() => new Response("ok"), { nodeWebSocketServerProvider: null }); + } catch (error) { + failed = error instanceof AggregateError; + } + assertEquals(failed, true); + assertEquals(closes, 1); + await server.close(); + assertEquals(closes, 2); + await server.close(); + assertEquals(closes, 2); + } finally { + NodeHttpServer.prototype.serve = originalServe; + NodeHttpServer.prototype.close = originalClose; + } +}); diff --git a/tests/integration/core/deno-http2-transport.test.ts b/tests/integration/core/deno-http2-transport.test.ts new file mode 100644 index 0000000000..75f50bcd05 --- /dev/null +++ b/tests/integration/core/deno-http2-transport.test.ts @@ -0,0 +1,200 @@ +import { it } from "#veryfront/testing/bdd.ts"; +import { connect, constants } from "node:http2"; +import { DenoAdapter } from "#veryfront/platform/adapters/runtime/deno/adapter.ts"; +import { DualProtocolServer } from "#veryfront/platform/adapters/runtime/node/dual-protocol-server.ts"; +import { assert, assertEquals } from "#veryfront/testing/assert.ts"; + +async function bounded(promise: Promise): Promise { + const timeout = AbortSignal.timeout(2000); + return await Promise.race([ + promise, + new Promise((_, reject) => { + timeout.addEventListener("abort", () => reject(new Error("HTTP2 transport did not settle")), { + once: true, + }); + }), + ]); +} + +it("Deno listener serves HTTP1, WebSocket and normalized streaming h2c together", async () => { + const runtime = new DenoAdapter(); + const server = await runtime.serve((request) => { + if (request.headers.get("upgrade") === "websocket") { + const upgrade = runtime.server.upgradeWebSocket(request, { idleTimeout: 0 }); + upgrade.socket.onmessage = (event) => upgrade.socket.send(event.data); + return upgrade.response; + } + if (new URL(request.url).pathname === "/h2") { + assertEquals(request.url, "http://synthetic.example/h2"); + assertEquals(request.headers.get("host"), "synthetic.example"); + assertEquals([...request.headers.keys()].some((key) => key.startsWith(":")), false); + const headers = new Headers({ + connection: "keep-alive, x-private-hop", + "x-private-hop": "remove", + "keep-alive": "timeout=1", + "x-end-to-end": "preserve", + }); + headers.append("set-cookie", "a=1"); + headers.append("set-cookie", "b=2"); + let count = 0; + return new Response( + new ReadableStream({ + pull(controller) { + if (count++ < 32) controller.enqueue(new Uint8Array(65536).fill(7)); + else controller.close(); + }, + }), + { status: 207, statusText: "Synthetic", headers }, + ); + } + return new Response("http1"); + }, { hostname: "127.0.0.1", port: 0 }); + const origin = `http://127.0.0.1:${server.addr.port}`; + const client = connect(origin); + const ws = new WebSocket(origin.replace("http:", "ws:")); + try { + const one = fetch(origin).then((response) => response.text()); + const wsOpen = new Promise((resolve, reject) => { + ws.onopen = () => resolve(); + ws.onerror = () => reject(new Error("WebSocket failed")); + }); + const request = client.request({ ":path": "/h2", ":authority": "synthetic.example" }); + let bytes = 0; + request.on("response", (headers) => { + assertEquals(headers[":status"], 207); + assertEquals(headers["set-cookie"], ["a=1", "b=2"]); + assertEquals(headers["x-end-to-end"], "preserve"); + for (const name of ["connection", "keep-alive", "x-private-hop"]) { + assertEquals(headers[name], undefined); + } + }); + request.pause(); + const finished = new Promise((resolve, reject) => { + request.on("data", (chunk) => { + assert(typeof chunk !== "string"); + assert(chunk.every((byte: number) => byte === 7)); + bytes += chunk.length; + }); + request.on("end", resolve); + request.on("error", reject); + request.end(); + }); + assertEquals(await one, "http1"); + await wsOpen; + const reply = new Promise((resolve) => ws.onmessage = (event) => resolve(event.data)); + ws.send("echo"); + assertEquals(await reply, "echo"); + request.resume(); + await bounded(finished); + assertEquals(bytes, 32 * 65536); + } finally { + const closed = new Promise((resolve) => ws.onclose = () => resolve()); + ws.close(); + await bounded(closed); + client.destroy(); + await bounded(runtime.shutdown()); + } +}); + +it("h2c reset after streamed body EOF aborts only its stream and shutdown owns sessions", async () => { + const runtime = new DenoAdapter(); + const started = Promise.withResolvers(); + const aborted = Promise.withResolvers(); + const shutdownStarted = Promise.withResolvers(); + const shutdownAborted = Promise.withResolvers(); + const server = await runtime.serve(async (request) => { + const path = new URL(request.url).pathname; + if (path === "/sibling") return new Response("sibling"); + if (path === "/shutdown") { + request.signal.addEventListener("abort", () => shutdownAborted.resolve(), { once: true }); + shutdownStarted.resolve(); + await shutdownAborted.promise; + return new Response("stopped"); + } + assertEquals((await request.text()).length, 65536); + request.signal.addEventListener("abort", () => aborted.resolve(), { once: true }); + started.resolve(); + await aborted.promise; + return new Response("cancelled"); + }, { hostname: "127.0.0.1", port: 0, nodeWebSocketServerProvider: null }); + const client = connect(`http://127.0.0.1:${server.addr.port}`); + try { + const cancel = client.request({ ":method": "POST", ":path": "/cancel" }); + cancel.on("error", () => {}); + cancel.write("x".repeat(1024)); + await new Promise((resolve) => setTimeout(resolve, 10)); + cancel.end("x".repeat(64512)); + await bounded(started.promise); + const sibling = client.request({ ":path": "/sibling" }); + let body = ""; + sibling.on("data", (chunk) => body += chunk.toString()); + const siblingDone = new Promise((resolve, reject) => { + sibling.on("end", resolve); + sibling.on("error", reject); + }); + sibling.end(); + cancel.close(constants.NGHTTP2_CANCEL); + await bounded(aborted.promise); + await bounded(siblingDone); + assertEquals(body, "sibling"); + const pending = client.request({ ":path": "/shutdown" }); + pending.on("error", () => {}); + pending.end(); + await bounded(shutdownStarted.promise); + await bounded(runtime.shutdown()); + await bounded(shutdownAborted.promise); + } finally { + client.destroy(); + await runtime.shutdown(); + } +}); + +it("dual protocol preface handles fragmentation, EOF, malformed input and timeout", async () => { + const server = new DualProtocolServer((_request, response) => response.end("ok"), 500); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + assert(address && typeof address !== "string"); + const sockets: Deno.Conn[] = []; + const open = async () => { + const socket = await Deno.connect({ hostname: "127.0.0.1", port: address.port }); + sockets.push(socket); + return socket; + }; + try { + const fragmented = await open(); + const preface = new TextEncoder().encode("PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n"); + for (const byte of preface) { + await fragmented.write(new Uint8Array([byte])); + await new Promise((resolve) => setTimeout(resolve, 1)); + } + const frame = new Uint8Array(9); + let offset = 0; + while (offset < 9) { + const count = await bounded(fragmented.read(frame.subarray(offset))); + assert(count !== null); + offset += count; + } + assertEquals(frame[3], 4, "server SETTINGS proves fragmented h2 preface routed intact"); + const eof = await open(); + await eof.write(preface.subarray(0, 3)); + eof.close(); + sockets.splice(sockets.indexOf(eof), 1); + const stalled = await open(); + await stalled.write(preface.subarray(0, 3)); + assertEquals(await bounded(stalled.read(new Uint8Array(1))), null); + const malformed = await open(); + await malformed.write(new TextEncoder().encode("PRI * HTTP/2.0x\r\n\r\n")); + const bytes = new Uint8Array(256); + const count = await bounded(malformed.read(bytes)); + assert(count !== null); + assert(new TextDecoder().decode(bytes.subarray(0, count)).startsWith("HTTP/1.1 400")); + } finally { + for (const socket of sockets) socket.close(); + server.closeAllConnections(); + await bounded( + new Promise((resolve, reject) => + server.close((error) => error ? reject(error) : resolve()) + ), + ); + } +}); diff --git a/tests/integration/core/deno-node-http-upgrade.test.ts b/tests/integration/core/deno-node-http-upgrade.test.ts new file mode 100644 index 0000000000..f0dd901e93 --- /dev/null +++ b/tests/integration/core/deno-node-http-upgrade.test.ts @@ -0,0 +1,98 @@ +import { describe, it } from "#veryfront/testing/bdd.ts"; +import { assertEquals, assertStringIncludes, assertThrows } from "#veryfront/testing/assert.ts"; +import { createNodeServer } from "#veryfront/platform/adapters/runtime/node/http-server.ts"; +import { NodeServerAdapter } from "#veryfront/platform/adapters/runtime/node/websocket-adapter.ts"; +import { WsNodeWebSocketServerProvider } from "../../../extensions/ext-node-websocket-ws/src/index.ts"; + +// Exercise node:http under Deno as well: the Node-only lifecycle suite deliberately +// skips these real socket cases outside Node. +describe("Deno Node HTTP upgrade transport", () => { + it("preserves a rejected handshake status, repeated cookies and body", async () => { + let calls = 0; + const events: string[] = []; + let requestSignal: AbortSignal | undefined; + const adapter = new NodeServerAdapter(true); + const server = await createNodeServer((request) => { + calls++; + requestSignal = request.signal; + const upgrade = adapter.upgradeWebSocket(request, { idleTimeout: 0 }); + upgrade.socket.addEventListener("error", () => events.push("error")); + upgrade.socket.addEventListener("close", () => events.push("close")); + const headers = new Headers(); + headers.append("set-cookie", "first=1"); + headers.append("set-cookie", "second=2"); + return new Response("forbidden", { status: 403, statusText: "Denied", headers }); + }, { hostname: "127.0.0.1", port: 0 }); + const connection = await Deno.connect({ hostname: "127.0.0.1", port: server.addr.port }); + try { + const request = new TextEncoder().encode( + "GET / HTTP/1.1\r\nHost: localhost\r\nConnection: Upgrade\r\nUpgrade: websocket\r\nSec-WebSocket-Version: 13\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\r\n", + ); + let offset = 0; + while (offset < request.length) offset += await connection.write(request.subarray(offset)); + const chunks: Uint8Array[] = []; + while (true) { + const buffer = new Uint8Array(4096); + const count = await connection.read(buffer); + if (count === null) break; + chunks.push(buffer.subarray(0, count)); + } + const result = chunks.map((chunk) => new TextDecoder().decode(chunk)).join(""); + assertStringIncludes(result, "HTTP/1.1 403 Denied"); + assertStringIncludes(result.toLowerCase(), "set-cookie: first=1"); + assertStringIncludes(result.toLowerCase(), "set-cookie: second=2"); + assertStringIncludes(result, "forbidden"); + assertEquals(calls, 1); + assertEquals(events, ["error", "close"]); + assertEquals(requestSignal?.aborted, true); + } finally { + connection.close(); + await server.stop(); + } + }); + + it("rejects a cloned or header-spoofed request while the exact request upgrades", async () => { + const adapter = new NodeServerAdapter(true); + const server = await createNodeServer((request) => { + assertThrows(() => adapter.upgradeWebSocket(request.clone()), Error, "original request"); + assertThrows( + () => + adapter.upgradeWebSocket( + new Request(request.url, { + headers: request.headers, + }), + ), + Error, + "original request", + ); + const upgrade = adapter.upgradeWebSocket(request, { idleTimeout: 0, protocol: "echo" }); + upgrade.socket.addEventListener( + "message", + (event) => upgrade.socket.send((event as MessageEvent).data), + ); + return upgrade.response; + }, { + hostname: "127.0.0.1", + port: 0, + nodeWebSocketServerProvider: WsNodeWebSocketServerProvider, + }); + const client = new WebSocket(`ws://127.0.0.1:${server.addr.port}/`, "echo"); + try { + await new Promise((resolve, reject) => { + client.onopen = () => resolve(); + client.onerror = () => reject(new Error("Handshake failed")); + }); + assertEquals(client.protocol, "echo"); + const reply = new Promise((resolve) => + client.onmessage = (event) => resolve(event.data) + ); + client.send("hello"); + assertEquals(await reply, "hello"); + } finally { + const closed = new Promise((resolve) => client.onclose = () => resolve()); + client.close(); + await closed; + await server.stop(); + } + }); +}); diff --git a/tests/integration/core/deno-websocket-dom.test.ts b/tests/integration/core/deno-websocket-dom.test.ts new file mode 100644 index 0000000000..770bfddcac --- /dev/null +++ b/tests/integration/core/deno-websocket-dom.test.ts @@ -0,0 +1,248 @@ +import { it } from "#veryfront/testing/bdd.ts"; +import { isWebSocketUpgradeResponse } from "#veryfront/platform/adapters/base.ts"; +import { assert, assertEquals, assertThrows } from "#veryfront/testing/assert.ts"; +import { DenoAdapter } from "#veryfront/platform/adapters/runtime/deno/adapter.ts"; + +for (const transport of ["native", "selected"] as const) { + it(`Deno DOM WebSocket metadata, binary mode and send order: ${transport}`, async () => { + const runtime = new DenoAdapter(); + const listening = Promise.withResolvers(); + const facts: unknown[] = []; + const handler = (request: Request) => { + const upgrade = transport === "native" + ? Deno.upgradeWebSocket(request, { protocol: "echo", idleTimeout: 0 }) + : runtime.server.upgradeWebSocket(request, { protocol: "echo", idleTimeout: 0 }); + const socket = upgrade.socket; + assert(socket instanceof EventTarget); + // The native Deno path is the independent control for these server-side + // defaults, including the intentionally empty protocol metadata. + const dom = socket; + facts.push([ + dom.url, + dom.protocol, + dom.extensions, + dom.binaryType, + dom.bufferedAmount, + dom.CONNECTING, + dom.OPEN, + dom.CLOSING, + dom.CLOSED, + ]); + dom.binaryType = "blob"; + const events: string[] = []; + dom.addEventListener("open", () => events.push("listener")); + dom.onopen = () => { + events.push("property"); + facts.push([...events]); + assertEquals(dom.bufferedAmount >= 0, true); + dom.send(new Blob(["first"])); + dom.send(new Uint8Array([2])); + dom.send("third"); + Reflect.apply(dom.send, dom, [42]); + Reflect.apply(dom.send, dom, [{ toString: () => "object-string" }]); + assertThrows(() => Reflect.apply(dom.send, dom, []), TypeError); + }; + dom.onmessage = async (event) => { + facts.push(event.data instanceof Blob); + assertEquals(new Uint8Array(await event.data.arrayBuffer()), new Uint8Array([7])); + dom.send("received"); + }; + return upgrade.response; + }; + const native = transport === "native" + ? Deno.serve({ + hostname: "127.0.0.1", + port: 0, + onListen: ({ port }: Deno.NetAddr) => listening.resolve(port), + }, (request) => { + const response = handler(request); + assert(!isWebSocketUpgradeResponse(response)); + return response; + }) + : undefined; + const selected = transport === "selected" + ? runtime.serve(handler, { + hostname: "127.0.0.1", + port: 0, + onListen: ({ port }) => listening.resolve(port), + }) + : undefined; + const client = new WebSocket(`ws://127.0.0.1:${await listening.promise}/`, "echo"); + const received: Array> = []; + const completed = Promise.withResolvers(); + client.onmessage = (event) => { + received.push( + typeof event.data === "string" + ? Promise.resolve(event.data) + : event.data.arrayBuffer().then((buffer: ArrayBuffer) => + Array.from(new Uint8Array(buffer)) + ), + ); + if (received.length === 5) client.send(new Uint8Array([7])); + if (received.length === 6) completed.resolve(); + }; + client.onerror = () => completed.reject(new Error("DOM control handshake failed")); + try { + await completed.promise; + assertEquals(await Promise.all(received), [ + [102, 105, 114, 115, 116], + [2], + "third", + "42", + "object-string", + "received", + ]); + assertEquals(facts, [ + ["", "", "", "arraybuffer", 0, 0, 1, 2, 3], + ["listener", "property"], + true, + ]); + } finally { + const closed = Promise.withResolvers(); + client.onclose = () => closed.resolve(); + client.close(); + await closed.promise; + await native?.shutdown(); + await runtime.shutdown(); + await selected; + } + }); +} + +it("explicitly disabled Deno WebSocket provider never falls back", async () => { + const runtime = new DenoAdapter(); + const failed = Promise.withResolvers(); + const server = await runtime.serve((request) => { + const upgrade = runtime.server.upgradeWebSocket(request, { idleTimeout: 0 }); + upgrade.socket.onerror = () => failed.resolve(); + return upgrade.response; + }, { hostname: "127.0.0.1", port: 0, nodeWebSocketServerProvider: null }); + const client = new WebSocket(`ws://127.0.0.1:${server.addr.port}/`); + const closed = Promise.withResolvers(); + let opened = false; + client.onopen = () => { + opened = true; + }; + client.onerror = () => {}; + client.onclose = () => closed.resolve(); + try { + await failed.promise; + await closed.promise; + assertEquals(opened, false); + } finally { + client.close(); + await runtime.shutdown(); + } +}); + +it("custom Deno provider supplies the real buffered amount accessor", async () => { + const { WsNodeWebSocketServerProvider: source } = await import( + "../../../extensions/ext-node-websocket-ws/src/index.ts" + ); + let servers = 0; + let reads = 0; + const provider = { + supportsIdleTimeout: true as const, + createServer(options: Parameters[0]) { + servers++; + return source.createServer(options); + }, + getBufferedAmount(connection: Parameters>[0]) { + reads++; + return source.getBufferedAmount!(connection); + }, + }; + const runtime = new DenoAdapter(); + const observed = Promise.withResolvers(); + const server = await runtime.serve((request) => { + const upgrade = runtime.server.upgradeWebSocket(request); + assertEquals(upgrade.socket.bufferedAmount, 0); + assertEquals(reads, 0, "connecting socket must not query the transport"); + upgrade.socket.onopen = () => { + assertEquals(upgrade.socket.bufferedAmount, 0); + assertEquals(reads, 1); + observed.resolve(); + }; + return upgrade.response; + }, { hostname: "127.0.0.1", port: 0, nodeWebSocketServerProvider: provider }); + const client = new WebSocket(`ws://127.0.0.1:${server.addr.port}/`); + try { + await observed.promise; + assertEquals(servers, 1); + } finally { + const closed = Promise.withResolvers(); + client.onclose = () => closed.resolve(); + // The server open callback can precede the client open event. + if (client.readyState === WebSocket.CONNECTING) { + await new Promise((resolve) => client.onopen = () => resolve()); + } + client.close(); + await closed.promise; + await runtime.shutdown(); + } +}); + +for (const transport of ["native", "selected"] as const) { + it(`Deno Blob send drains before immediate close: ${transport}`, async () => { + const runtime = new DenoAdapter(); + const listening = Promise.withResolvers(); + const serverClosed = Promise.withResolvers(); + const handler = (request: Request) => { + const upgrade = transport === "native" + ? Deno.upgradeWebSocket(request, { idleTimeout: 0 }) + : runtime.server.upgradeWebSocket(request, { idleTimeout: 0 }); + upgrade.socket.onopen = () => { + upgrade.socket.send(new Blob(["before-close"])); + assertEquals(upgrade.socket.bufferedAmount, 0); + if (transport === "selected") { + assertThrows(() => upgrade.socket.close(1005), TypeError); + assertEquals(upgrade.socket.readyState, WebSocket.OPEN); + } + Reflect.apply(upgrade.socket.close, upgrade.socket, [1000.4, { toString: () => "done" }]); + assertEquals(upgrade.socket.readyState, WebSocket.CLOSING); + }; + upgrade.socket.onclose = (event) => serverClosed.resolve(event); + return upgrade.response; + }; + const native = transport === "native" + ? Deno.serve({ + hostname: "127.0.0.1", + port: 0, + onListen: ({ port }) => listening.resolve(port), + }, (request) => { + const response = handler(request); + assert(!isWebSocketUpgradeResponse(response)); + return response; + }) + : undefined; + const selected = transport === "selected" + ? runtime.serve(handler, { + hostname: "127.0.0.1", + port: 0, + onListen: ({ port }) => listening.resolve(port), + }) + : undefined; + const client = new WebSocket(`ws://127.0.0.1:${await listening.promise}/`); + const messages: Promise[] = []; + const closed = Promise.withResolvers(); + client.onmessage = (event) => messages.push(event.data.text()); + client.onclose = (event) => closed.resolve(event); + client.onerror = () => closed.reject(new Error("Blob close control failed")); + try { + const event = await closed.promise; + assertEquals(await Promise.all(messages), ["before-close"]); + assertEquals([event.code, event.reason, event.wasClean], [1000, "done", true]); + const serverEvent = await serverClosed.promise; + assertEquals([serverEvent.code, serverEvent.reason, serverEvent.wasClean], [ + 1000, + "done", + true, + ]); + } finally { + client.close(); + await native?.shutdown(); + await runtime.shutdown(); + await selected; + } + }); +} From 5c2781b6bacb8d20ad6c2cdb958709999799f468 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 26 Oct 2026 14:57:04 +0100 Subject: [PATCH 02/22] Preserve safe Deno startup diagnostics on the cancellation transport Reuse the native bind-error boundary for the selected Node listener so DNS and address collisions retain actionable classification without leaking private bind hosts through Node error causes. Leave unrelated startup failures intact. Keep the public server module documentation ahead of imports so generated references retain their examples. Confidence: high Scope-risk: narrow Tested: Pinned Deno 2.9.7 lifecycle suite, 20 steps; real selected DNS failure; API-reference check, 49 files; lint and formatting Not-tested: Linux execution and deployed staging cancellation remain integration gates --- .../adapters/runtime/deno/http-server.test.ts | 43 ++++++ .../adapters/runtime/deno/http-server.ts | 127 ++++++++++-------- src/server/index.ts | 15 ++- 3 files changed, 122 insertions(+), 63 deletions(-) diff --git a/src/platform/adapters/runtime/deno/http-server.test.ts b/src/platform/adapters/runtime/deno/http-server.test.ts index e2f2bfe76a..146a6a374f 100644 --- a/src/platform/adapters/runtime/deno/http-server.test.ts +++ b/src/platform/adapters/runtime/deno/http-server.test.ts @@ -1,6 +1,7 @@ import "#veryfront/schemas/_test-setup.ts"; import { assertEquals, + assertInstanceOf, assertRejects, assertStrictEquals, assertStringIncludes, @@ -379,6 +380,48 @@ describe("Deno HTTP server lifecycle", () => { assertStrictEquals(error.cause, lookupFailure); }); + it("sanitizes the selected transport getaddrinfo failure without exposing its cause", async () => { + const hostname = "private-bind-regression.invalid"; + const error = await assertRejects( + () => + createDenoServerWithRuntime({ + serve() { + throw Object.assign(new Error(`getaddrinfo ENOTFOUND ${hostname}`), { + code: "ENOTFOUND", + syscall: "getaddrinfo", + }); + }, + }, () => + new Response("ok"), { hostname, port: 4321 }), + Error, + "could not be resolved", + ); + assertInstanceOf(error, Error); + assertEquals("slug" in error && error.slug, "initialization-error"); + assertEquals(error.message.includes(hostname), false); + assertEquals(String(error.cause).includes(hostname), false); + }); + + it("sanitizes the selected transport address-in-use cause", async () => { + const hostname = "private-bind-regression.invalid"; + const error = await assertRejects( + () => + createDenoServerWithRuntime({ + serve() { + throw Object.assign(new Error(`listen EADDRINUSE ${hostname}:4321`), { + code: "EADDRINUSE", + }); + }, + }, () => + new Response("ok"), { hostname, port: 4321 }), + Error, + "Address already in use: port 4321", + ); + assertInstanceOf(error, Error); + assertEquals(error.message.includes(hostname), false); + assertEquals(String(error.cause).includes(hostname), false); + }); + it("names a loopback bind host that fails DNS resolution", async () => { // `localhost` itself can fail to resolve on a broken NSS setup. It is on // the loopback allowlist, so unlike an infrastructure hostname it is safe diff --git a/src/platform/adapters/runtime/deno/http-server.ts b/src/platform/adapters/runtime/deno/http-server.ts index ea064dc81f..59f5d6dc84 100644 --- a/src/platform/adapters/runtime/deno/http-server.ts +++ b/src/platform/adapters/runtime/deno/http-server.ts @@ -58,7 +58,9 @@ function isAddressInUseError(error: unknown): boolean { */ function isAddressResolutionError(error: unknown): boolean { if (!isErrorAcrossRealms(error)) return false; - return error.message.toLowerCase().includes("failed to lookup address"); + return error.message.toLowerCase().includes("failed to lookup address") || + ("syscall" in error && error.syscall === "getaddrinfo" && "code" in error && + (error.code === "ENOTFOUND" || error.code === "EAI_AGAIN")); } /** @@ -93,6 +95,64 @@ function describeBindHost(hostname: string): string | undefined { return host.includes(":") ? `[${hostname}]` : hostname; } +function throwDenoBindError(error: unknown, hostname: string, port: number): never { + // `Deno.serve()` throws synchronously when the address cannot be bound, and + // the raw `AddrInUse: Address already in use (os error 98)` carried no + // hostname or port -- so an operator saw which process died but not which + // address collided (veryfront-issue-inbox#806). + // + // A bind hostname that fails DNS resolution throws the same way, and the + // raw `failed to lookup address information: ...` reached Sentry as an + // unhandled error with no port and no classification + // (veryfront-issue-inbox#810). The message deliberately does not contain + // the canonical in-use phrase: retrying other ports cannot fix a + // resolution fault, so the dev server's port fallback must not trigger. + if (isAddressResolutionError(error)) { + const safeHost = describeBindHost(hostname); + throw INITIALIZATION_ERROR.create({ + detail: safeHost === undefined + ? `Bind hostname could not be resolved: port ${port}` + : `Bind hostname could not be resolved: ${safeHost}:${port}`, + context: { + platform: "deno", + operation: "serve", + port, + ...(safeHost === undefined ? {} : { hostname: safeHost }), + }, + cause: isErrorAcrossRealms(error) && "syscall" in error && error.syscall === "getaddrinfo" + ? new Error("Bind hostname lookup failed") + : error, + }); + } + // Only a bind failure is relabelled. Catching everything here would report + // an unrelated startup fault as an address collision, which is worse than + // the raw error it replaces, so anything else is rethrown untouched. + if (!isAddressInUseError(error)) throw error; + // The canonical phrase is kept at the front of the message on purpose. The + // CLI classifies a taken port by matching it (`isPortInUseError`, + // cli/commands/dev/port-fallback.ts), and dropping it silently disabled the + // dev server's port fallback and broke + // server-start-failure.integration.test.ts, which asserts the surfaced + // message still says the port is in use. Naming the port is the point of + // this change; keeping the phrase is what lets it be added without + // rewriting a contract other code already depends on. + const safeHost = describeBindHost(hostname); + throw INITIALIZATION_ERROR.create({ + detail: safeHost === undefined + ? `Address already in use: port ${port}` + : `Address already in use: ${safeHost}:${port}`, + context: { + platform: "deno", + operation: "serve", + port, + ...(safeHost === undefined ? {} : { hostname: safeHost }), + }, + cause: isErrorAcrossRealms(error) && "code" in error && error.code === "EADDRINUSE" + ? new Error("Address already in use") + : error, + }); +} + export interface DenoNativeHttpServer { readonly addr: unknown; readonly finished: Promise; @@ -274,57 +334,7 @@ export async function createDenoServerWithRuntime( onListen: () => {}, }); } catch (error) { - // `Deno.serve()` throws synchronously when the address cannot be bound, and - // the raw `AddrInUse: Address already in use (os error 98)` carried no - // hostname or port -- so an operator saw which process died but not which - // address collided (veryfront-issue-inbox#806). - // - // A bind hostname that fails DNS resolution throws the same way, and the - // raw `failed to lookup address information: ...` reached Sentry as an - // unhandled error with no port and no classification - // (veryfront-issue-inbox#810). The message deliberately does not contain - // the canonical in-use phrase: retrying other ports cannot fix a - // resolution fault, so the dev server's port fallback must not trigger. - if (isAddressResolutionError(error)) { - const safeHost = describeBindHost(hostname); - throw INITIALIZATION_ERROR.create({ - detail: safeHost === undefined - ? `Bind hostname could not be resolved: port ${port}` - : `Bind hostname could not be resolved: ${safeHost}:${port}`, - context: { - platform: "deno", - operation: "serve", - port, - ...(safeHost === undefined ? {} : { hostname: safeHost }), - }, - cause: error, - }); - } - // Only a bind failure is relabelled. Catching everything here would report - // an unrelated startup fault as an address collision, which is worse than - // the raw error it replaces, so anything else is rethrown untouched. - if (!isAddressInUseError(error)) throw error; - // The canonical phrase is kept at the front of the message on purpose. The - // CLI classifies a taken port by matching it (`isPortInUseError`, - // cli/commands/dev/port-fallback.ts), and dropping it silently disabled the - // dev server's port fallback and broke - // server-start-failure.integration.test.ts, which asserts the surfaced - // message still says the port is in use. Naming the port is the point of - // this change; keeping the phrase is what lets it be added without - // rewriting a contract other code already depends on. - const safeHost = describeBindHost(hostname); - throw INITIALIZATION_ERROR.create({ - detail: safeHost === undefined - ? `Address already in use: port ${port}` - : `Address already in use: ${safeHost}:${port}`, - context: { - platform: "deno", - operation: "serve", - port, - ...(safeHost === undefined ? {} : { hostname: safeHost }), - }, - cause: error, - }); + throwDenoBindError(error, hostname, port); } // Nothing is leaked when the bind fails: the AbortController above is never @@ -392,10 +402,15 @@ export async function createDenoServer( }); } if (needsDenoNodeHttpTransport()) { - return await createDenoNodeServerWithStartupOwner( - scopeDenoNodeHttpHandler(handler), - await resolveDenoNodeHttpOptions(options), - ); + const resolved = await resolveDenoNodeHttpOptions(options); + try { + return await createDenoNodeServerWithStartupOwner( + scopeDenoNodeHttpHandler(handler), + resolved, + ); + } catch (error) { + throwDenoBindError(error, options.hostname ?? "localhost", options.port ?? DEFAULT_PORT); + } } return createDenoServerWithRuntime( runtime as DenoServeRuntime, diff --git a/src/server/index.ts b/src/server/index.ts index ec9cb5e980..9c4da92d28 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -1,10 +1,3 @@ -import { - getWebSocketUpgradeIdleTimeout, - isDenoWebSocketResponse, - recordNodeWebSocketUpgradeRequest, - recordWebSocketBufferedAmount, - revokeNodeWebSocketUpgradeRequest, -} from "#veryfront/platform/adapters/runtime/shared/http-upgrade-provenance.ts"; /** * Create and run Veryfront servers. * @@ -28,6 +21,14 @@ import { * @see docs/security.md */ +import { + getWebSocketUpgradeIdleTimeout, + isDenoWebSocketResponse, + recordNodeWebSocketUpgradeRequest, + recordWebSocketBufferedAmount, + revokeNodeWebSocketUpgradeRequest, +} from "#veryfront/platform/adapters/runtime/shared/http-upgrade-provenance.ts"; + import { DevServer, type DevServerHandler, From c02c21029a475c2c0f6e591cd14cfe9bb2f12f40 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 26 Oct 2026 15:05:37 +0100 Subject: [PATCH 03/22] Keep the Host rejection regression tied to its router catch The successful WebSocket response now completes its drain entry before the router catch. Locate the Host-error catch directly instead of assuming its completion call is first or that its response fits a fixed character window. Preserve checks for drain completion, typed Host rejection and HTTP 400. Confidence: high Scope-risk: narrow Tested: Pinned Deno 2.9.7 standard test:file, 3 suites and 12 steps; lint; clean uncommitted review --- src/proxy/main.test.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/proxy/main.test.ts b/src/proxy/main.test.ts index 220d43e778..07efc40fb5 100644 --- a/src/proxy/main.test.ts +++ b/src/proxy/main.test.ts @@ -54,9 +54,12 @@ describe("proxy main request URL parsing", () => { // An absolute-form target with an empty Host header parses as a URL but // fails Host validation downstream, so the guard on the parse alone would // only move the crash (veryfront-issue-inbox#828). - const catchIndex = source.indexOf("proxyRequestDrainTracker.complete(requestId);"); - assertEquals(catchIndex >= 0, true); - const routerCatch = source.slice(catchIndex, catchIndex + 700); + const hostErrorIndex = source.indexOf("if (error instanceof ProxyRequestHostError)"); + const catchIndex = source.lastIndexOf("} catch (error) {", hostErrorIndex); + const rethrowIndex = source.indexOf("throw error;", hostErrorIndex); + assertEquals(hostErrorIndex >= 0 && catchIndex >= 0 && rethrowIndex > hostErrorIndex, true); + const routerCatch = source.slice(catchIndex, rethrowIndex); + assertStringIncludes(routerCatch, "proxyRequestDrainTracker.complete(requestId);"); assertStringIncludes(routerCatch, "error instanceof ProxyRequestHostError"); assertStringIncludes( routerCatch, From 8a64ceefdf849a5c2b3047d6fbe355477d0360fd Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 26 Oct 2026 15:10:36 +0100 Subject: [PATCH 04/22] Keep publisher upgrade handlers honest about transport responses Propagate the compat upgrade response union through the existing workflow handler so branded Node upgrade responses remain type-correct under Deno. The handler continues to return ordinary HTTP responses for invalid run IDs. Confidence: high Scope-risk: narrow Tested: Pinned Deno 2.9.7 publisher/test typecheck; standard test:file, 4 suites and 42 steps; lint; clean uncommitted review --- src/workflow/claude-code/websocket-publisher.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/workflow/claude-code/websocket-publisher.ts b/src/workflow/claude-code/websocket-publisher.ts index 6885fc97d3..18b8c06539 100644 --- a/src/workflow/claude-code/websocket-publisher.ts +++ b/src/workflow/claude-code/websocket-publisher.ts @@ -696,9 +696,9 @@ function releaseUnpublishedRun( /** Create a WebSocket handler for HTTP upgrade requests. */ export function createWebSocketHandler( config: WebSocketHandlerConfig, -): (req: Request) => Response { +): (req: Request) => WebSocketUpgradeResult["response"] { const { registry } = config; - return (req: Request): Response => { + return (req: Request): WebSocketUpgradeResult["response"] => { const runId = config.getRunId(req); if (!runId) { return new Response("Missing runId", { status: 400 }); From 019c7734b2cdb184ef2af58427b6d463e2a697fa Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 26 Oct 2026 15:21:57 +0100 Subject: [PATCH 05/22] Preserve Deno response delivery before a streamed body produces data The selected listener must commit response headers while an application holds its body stream. Otherwise the existing native ingress cancellation contract cannot observe a disconnect during response delivery. Flush after acquiring the response reader only for the Deno-selected listener; retain Node defaults. Confidence: high Scope-risk: narrow Tested: Existing real cancellation suite, 54 steps; HTTP1/HTTP2/upgrade and listener regressions, 6 suites and 53 steps; clean uncommitted review --- src/platform/adapters/runtime/node/http-server.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/platform/adapters/runtime/node/http-server.ts b/src/platform/adapters/runtime/node/http-server.ts index 908d30b24e..436b165c27 100644 --- a/src/platform/adapters/runtime/node/http-server.ts +++ b/src/platform/adapters/runtime/node/http-server.ts @@ -461,6 +461,7 @@ export type NodeRequestHandler = ( export function createNodeRequestListener( handler: NodeRequestHandler, fallbackHostname = "localhost", + flushResponseHeaders = false, ): ( request: IncomingRequest, response: OutgoingResponse, @@ -580,6 +581,9 @@ export function createNodeRequestListener( if (response.body) { responseReader = response.body.getReader(); + // Deno commits response headers independently of the first body chunk. + // Preserve that contract for its selected transport without changing Node. + if (flushResponseHeaders) _res.flushHeaders(); while (true) { const { done, value } = await responseReader.read(); if (done) break; @@ -747,7 +751,7 @@ async function createNodeServerInternal( return attempt; }; - const listener = createNodeRequestListener(handler, hostname); + const listener = createNodeRequestListener(handler, hostname, dualProtocol); const server = dualProtocol ? new (await import("./dual-protocol-server.ts")).DualProtocolServer(listener) : createServer(listener); @@ -833,7 +837,7 @@ async function createNodeServerInternal( denial.shouldKeepAlive = false; denial.assignSocket(socket as import("node:net").Socket); denial.once("finish", () => socket.end()); - await createNodeRequestListener(() => response, hostname)(request, denial); + await createNodeRequestListener(() => response, hostname, dualProtocol)(request, denial); transportSettled = true; if (!requestAbort.signal.aborted) requestAbort.abort(denied); clearTransportState(); From 0deb73998cb4e4735ce106290e852a13f0adca9b Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 26 Oct 2026 15:25:59 +0100 Subject: [PATCH 06/22] Keep held-body cancellation consistent across Deno HTTP protocols Use the public HTTP2 writeHead operation alongside HTTP1 flushHeaders. Exercise both protocols with a held body: headers arrive before any chunk, client disconnect cancels the handler and reader, sibling traffic continues, and shutdown completes. Removing the flush makes both regressions fail. Confidence: high Scope-risk: narrow Tested: Pinned Deno 2.9.7 transport/typecheck, 5 real-wire tests; no-flush control fails both added cases; clean uncommitted review --- .../adapters/runtime/node/http-server.ts | 5 +- .../core/deno-http2-transport.test.ts | 64 +++++++++++++++++++ 2 files changed, 68 insertions(+), 1 deletion(-) diff --git a/src/platform/adapters/runtime/node/http-server.ts b/src/platform/adapters/runtime/node/http-server.ts index 436b165c27..a36ae03404 100644 --- a/src/platform/adapters/runtime/node/http-server.ts +++ b/src/platform/adapters/runtime/node/http-server.ts @@ -583,7 +583,10 @@ export function createNodeRequestListener( responseReader = response.body.getReader(); // Deno commits response headers independently of the first body chunk. // Preserve that contract for its selected transport without changing Node. - if (flushResponseHeaders) _res.flushHeaders(); + if (flushResponseHeaders) { + if ("flushHeaders" in _res) _res.flushHeaders(); + else _res.writeHead(response.status); + } while (true) { const { done, value } = await responseReader.read(); if (done) break; diff --git a/tests/integration/core/deno-http2-transport.test.ts b/tests/integration/core/deno-http2-transport.test.ts index 75f50bcd05..649bfe8109 100644 --- a/tests/integration/core/deno-http2-transport.test.ts +++ b/tests/integration/core/deno-http2-transport.test.ts @@ -198,3 +198,67 @@ it("dual protocol preface handles fragmentation, EOF, malformed input and timeou ); } }); + +for (const protocol of ["http1", "h2c"] as const) { + it(`Deno ${protocol} delivers held-body headers and cancellation without stopping its sibling`, async () => { + const runtime = new DenoAdapter(); + const aborted = Promise.withResolvers(); + const cancelled = Promise.withResolvers(); + const server = await runtime.serve((request) => { + if (new URL(request.url).pathname === "/sibling") return new Response("sibling"); + request.signal.addEventListener("abort", () => aborted.resolve(), { once: true }); + return new Response( + new ReadableStream({ + cancel() { + cancelled.resolve(); + }, + }), + { status: 202, headers: { "x-held-body": "yes" } }, + ); + }, { hostname: "127.0.0.1", port: 0, nodeWebSocketServerProvider: null }); + const origin = `http://127.0.0.1:${server.addr.port}`; + const controller = new AbortController(); + const client = protocol === "h2c" ? connect(origin) : undefined; + try { + if (client) { + const headers = Promise.withResolvers(); + const request = client.request({ ":path": "/held" }); + request.on("error", headers.reject); + request.on("response", (values) => { + assertEquals(values[":status"], 202); + assertEquals(values["x-held-body"], "yes"); + headers.resolve(); + }); + request.end(); + await bounded(headers.promise); + request.close(constants.NGHTTP2_CANCEL); + } else { + const response = await bounded(fetch(`${origin}/held`, { signal: controller.signal })); + assertEquals(response.status, 202); + assertEquals(response.headers.get("x-held-body"), "yes"); + controller.abort(); + } + await bounded(aborted.promise); + await bounded(cancelled.promise); + if (client) { + const finished = Promise.withResolvers(); + let body = ""; + const sibling = client.request({ ":path": "/sibling" }); + sibling.on("error", finished.reject); + sibling.on("data", (chunk) => body += new TextDecoder().decode(chunk)); + sibling.on("end", () => finished.resolve(body)); + sibling.end(); + assertEquals(await bounded(finished.promise), "sibling"); + } else { + assertEquals( + await bounded(fetch(`${origin}/sibling`).then((response) => response.text())), + "sibling", + ); + } + } finally { + controller.abort(); + client?.destroy(); + await bounded(server.stop()); + } + }); +} From 56f940327c73a7e1f48d63f4325101328cbcfd33 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 26 Oct 2026 15:42:56 +0100 Subject: [PATCH 07/22] Keep real transport regressions in the integration boundary Native CI correctly rejects effect-bearing new unit tests without semantic dispositions. Relocate both unchanged regressions to the existing integration discovery lane instead of adding migration exemptions. Rejected: Add semantic audit migration entries | new effect-bearing units must relocate instead Tested: Two focused tests, frozen typecheck, lint, semantic unit-boundary audit Scope-risk: narrow --- .../integration/core/dual-protocol-ownership.test.ts | 2 +- .../integration/core}/http-upgrade-provenance.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) rename src/platform/adapters/runtime/node/dual-protocol-server.test.ts => tests/integration/core/dual-protocol-ownership.test.ts (93%) rename {src/platform/adapters/runtime/shared => tests/integration/core}/http-upgrade-provenance.test.ts (97%) diff --git a/src/platform/adapters/runtime/node/dual-protocol-server.test.ts b/tests/integration/core/dual-protocol-ownership.test.ts similarity index 93% rename from src/platform/adapters/runtime/node/dual-protocol-server.test.ts rename to tests/integration/core/dual-protocol-ownership.test.ts index 2d785388b7..a573415806 100644 --- a/src/platform/adapters/runtime/node/dual-protocol-server.test.ts +++ b/tests/integration/core/dual-protocol-ownership.test.ts @@ -1,7 +1,7 @@ import { Socket } from "node:net"; import { it } from "#veryfront/testing/bdd.ts"; import { assertEquals } from "#veryfront/testing/assert.ts"; -import { DualProtocolServer } from "./dual-protocol-server.ts"; +import { DualProtocolServer } from "#veryfront/platform/adapters/runtime/node/dual-protocol-server.ts"; it("dual protocol ownership does not expose raw sockets to authored Set observers", async () => { const server = new DualProtocolServer(() => {}); diff --git a/src/platform/adapters/runtime/shared/http-upgrade-provenance.test.ts b/tests/integration/core/http-upgrade-provenance.test.ts similarity index 97% rename from src/platform/adapters/runtime/shared/http-upgrade-provenance.test.ts rename to tests/integration/core/http-upgrade-provenance.test.ts index 7a810112c2..adb796f548 100644 --- a/src/platform/adapters/runtime/shared/http-upgrade-provenance.test.ts +++ b/tests/integration/core/http-upgrade-provenance.test.ts @@ -11,7 +11,7 @@ import { recordWebSocketBufferedAmount, recordWebSocketUpgradeIdleTimeout, revokeNodeWebSocketUpgradeRequest, -} from "./http-upgrade-provenance.ts"; +} from "#veryfront/platform/adapters/runtime/shared/http-upgrade-provenance.ts"; it("upgrade provenance does not expose requests to replaced collection methods", () => { const request = new Request("http://localhost", { headers: { authorization: "synthetic" } }); From 7bcd9516594783ea3cc65c7fcd0397aeb6b4a287 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 26 Oct 2026 15:51:12 +0100 Subject: [PATCH 08/22] Preserve Deno HEAD completion while retaining accurate test contracts Selected Deno listeners must suppress held HEAD response bodies and settle HTTP independently of producer cleanup. Cancel without awaiting or exposing cleanup failure to the response, preserving ordinary Node behavior. Pair the HTTP1 and h2c regressions with native Deno HTTP1 controls for resolved, pending and rejected cleanup. Align three existing test helpers with the actual serve-handler type rather than hiding WebSocket upgrade responses behind Response annotations. Constraint: Native Deno h2c itself does not finish the held-HEAD control; validate selected h2c against HTTP HEAD semantics without claiming native bug-for-bug parity Tested: Fourteen pinned Deno wire tests; three fixture suites with eleven steps; frozen typecheck; lint; forty ordinary Node transport tests Scope-risk: narrow --- .../adapters/runtime/node/http-server.ts | 7 ++ .../runtime/shared/server-lifecycle.test.ts | 3 +- src/server/production-server-ingress.test.ts | 6 +- ...oduction-server-shutdown-admission.test.ts | 2 +- .../core/deno-http2-transport.test.ts | 65 +++++++++++++++++++ 5 files changed, 79 insertions(+), 4 deletions(-) diff --git a/src/platform/adapters/runtime/node/http-server.ts b/src/platform/adapters/runtime/node/http-server.ts index a36ae03404..6ef8cf6024 100644 --- a/src/platform/adapters/runtime/node/http-server.ts +++ b/src/platform/adapters/runtime/node/http-server.ts @@ -579,6 +579,13 @@ export function createNodeRequestListener( _res.setHeader("set-cookie", setCookies); } + if (flushResponseHeaders && method === "HEAD") { + // Native Deno suppresses and cancels HEAD bodies rather than consuming them. + void response.body?.cancel().catch(() => undefined); + _res.end(); + return; + } + if (response.body) { responseReader = response.body.getReader(); // Deno commits response headers independently of the first body chunk. diff --git a/src/platform/adapters/runtime/shared/server-lifecycle.test.ts b/src/platform/adapters/runtime/shared/server-lifecycle.test.ts index 347baa74f7..a4b63adfbf 100644 --- a/src/platform/adapters/runtime/shared/server-lifecycle.test.ts +++ b/src/platform/adapters/runtime/shared/server-lifecycle.test.ts @@ -1,4 +1,5 @@ import "#veryfront/schemas/_test-setup.ts"; +import type { RuntimeAdapter } from "#veryfront/platform/adapters/base.ts"; import { assertEquals, assertRejects, assertStrictEquals } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; import { createServeHandler, ManagedServerRegistry } from "./server-lifecycle.ts"; @@ -13,7 +14,7 @@ describe("platform/adapters/runtime/shared/server-lifecycle", () => { const registry = new ManagedServerRegistry(); const createServer = async ( - _handler: (request: Request) => Promise | Response, + _handler: Parameters[0], _options: unknown, ) => { return fakeServer; diff --git a/src/server/production-server-ingress.test.ts b/src/server/production-server-ingress.test.ts index 40b1615c72..10b54d0e8c 100644 --- a/src/server/production-server-ingress.test.ts +++ b/src/server/production-server-ingress.test.ts @@ -26,7 +26,7 @@ describe("production server ingress", () => { ignore: !isDeno, }, async () => { const adapter = createMockAdapter(); - let servedHandler: ((request: Request) => Response | Promise) | undefined; + let servedHandler: Parameters[0] | undefined; adapter.serve = (handler, options) => { servedHandler = handler; options.onListen?.({ hostname: "127.0.0.1", port: options.port ?? 0 }); @@ -66,7 +66,9 @@ describe("production server ingress", () => { let response: Response; try { if (!servedHandler) throw new Error("production listener did not receive a handler"); - response = await servedHandler(request); + const result = await servedHandler(request); + if (!(result instanceof Response)) throw new Error("Expected an ordinary HTTP response"); + response = result; } finally { probes.restore(); await server.stop(); diff --git a/src/server/production-server-shutdown-admission.test.ts b/src/server/production-server-shutdown-admission.test.ts index 4641619932..ba8f0b8552 100644 --- a/src/server/production-server-shutdown-admission.test.ts +++ b/src/server/production-server-shutdown-admission.test.ts @@ -19,7 +19,7 @@ function createBootstrap(adapter: RuntimeAdapter): BootstrapResult { describe("production server shutdown admission", () => { it("rejects requests before invoking the public interceptor during drain", async () => { const adapter = createMockAdapter(); - let servedHandler: ((request: Request) => Response | Promise) | undefined; + let servedHandler: Parameters[0] | undefined; adapter.serve = (handler, options) => { servedHandler = handler; options.onListen?.({ diff --git a/tests/integration/core/deno-http2-transport.test.ts b/tests/integration/core/deno-http2-transport.test.ts index 649bfe8109..e45ece1d1d 100644 --- a/tests/integration/core/deno-http2-transport.test.ts +++ b/tests/integration/core/deno-http2-transport.test.ts @@ -262,3 +262,68 @@ for (const protocol of ["http1", "h2c"] as const) { } }); } + +for (const transport of ["native", "selected"] as const) { + for (const protocol of transport === "native" ? ["http1"] as const : ["http1", "h2c"] as const) { + for (const cleanup of ["resolve", "pending", "reject"] as const) { + it(`${transport} Deno ${protocol} HEAD completes independently of ${cleanup} body cleanup`, async () => { + const cancelled = Promise.withResolvers(); + const handler = () => + new Response( + new ReadableStream({ + cancel() { + cancelled.resolve(); + if (cleanup === "pending") return new Promise(() => {}); + if (cleanup === "reject") { + return Promise.reject(new Error("synthetic cleanup failure")); + } + }, + }), + { status: 202, headers: { "x-head-contract": "preserve" } }, + ); + const native = transport === "native" + ? Deno.serve({ hostname: "127.0.0.1", port: 0, onListen() {} }, handler) + : undefined; + const selected = transport === "selected" + ? await new DenoAdapter().serve(handler, { + hostname: "127.0.0.1", + port: 0, + nodeWebSocketServerProvider: null, + }) + : undefined; + const origin = `http://127.0.0.1:${native?.addr.port ?? selected!.addr.port}`; + const client = protocol === "h2c" ? connect(origin) : undefined; + try { + if (client) { + const finished = Promise.withResolvers(); + const request = client.request({ ":path": "/", ":method": "HEAD" }); + request.on("error", finished.reject); + request.on("response", (headers) => { + assertEquals(headers[":status"], 202); + assertEquals(headers["x-head-contract"], "preserve"); + }); + request.on("data", () => finished.reject(new Error("HEAD must not send body bytes"))); + request.on("end", () => finished.resolve()); + request.resume(); + request.end(); + await bounded(finished.promise).catch((error) => { + throw new Error("HEAD response did not end", { cause: error }); + }); + } else { + const response = await bounded(fetch(origin, { method: "HEAD" })); + assertEquals(response.status, 202); + assertEquals(response.headers.get("x-head-contract"), "preserve"); + assertEquals(await response.text(), ""); + } + await bounded(cancelled.promise).catch((error) => { + throw new Error("HEAD body was not cancelled", { cause: error }); + }); + } finally { + client?.destroy(); + await native?.shutdown(); + await selected?.stop(); + } + }); + } + } +} From d71dd6c612ccc084b8837ab3bd696b6333ac112d Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 26 Oct 2026 15:54:55 +0100 Subject: [PATCH 09/22] Measure Deno wire behavior in the existing coverage gate The new transport integration tests run in native CI but were absent from its coverage collection. Include the four focused wire suites while retaining all existing integration-client coverage paths. Replace duplicate HTTP1/HTTP2 write branches with one structurally checked byte writer; both overloaded public response APIs satisfy that contract without a cast. Constraint: Preserve the existing coverage threshold and integration selection Rejected: Direct call on the overloaded response union | incompatible TypeScript call signatures Tested: Frozen HTTP response typecheck; lint; fourteen Deno wire tests; forty Node HTTP transport steps Scope-risk: narrow --- deno.json | 2 +- src/platform/adapters/runtime/node/http-server.ts | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/deno.json b/deno.json index b5130fad66..40d4c51eaf 100644 --- a/deno.json +++ b/deno.json @@ -554,7 +554,7 @@ "coverage:gate": "deno coverage coverage --include=src/ --include=cli/ --exclude=/tests/ --exclude=/__tests__/ --lcov > coverage/lcov.info && deno run --allow-read scripts/lint/check-coverage.ts 80", "coverage:ci:shard": "deno run --config=scripts/test.deno.json --allow-read --allow-write --allow-run --allow-env scripts/test/coverage-ci.ts shard", "coverage:ci:merge": "deno run --config=scripts/test.deno.json --no-npm --allow-read --allow-write --allow-run --allow-env scripts/test/coverage-ci.ts merge", - "coverage:ci:integration-client": "rm -rf coverage/integration-client && VF_RUNS_CLI_COVERAGE_DIR=coverage/integration-client deno task test:file --coverage=coverage/integration-client tests/integration/integrations/ tests/integration/cli/integration/ tests/integration/cli/project/ tests/integration/runs/ && deno run --config=scripts/test.deno.json --no-npm --allow-read --allow-write --allow-run --allow-env scripts/test/coverage-ci.ts report coverage/integration-client", + "coverage:ci:integration-client": "rm -rf coverage/integration-client && VF_RUNS_CLI_COVERAGE_DIR=coverage/integration-client deno task test:file --coverage=coverage/integration-client tests/integration/integrations/ tests/integration/cli/integration/ tests/integration/cli/project/ tests/integration/runs/ tests/integration/core/deno-http-cancellation.test.ts tests/integration/core/deno-http2-transport.test.ts tests/integration/core/deno-node-http-upgrade.test.ts tests/integration/core/deno-websocket-dom.test.ts && deno run --config=scripts/test.deno.json --no-npm --allow-read --allow-write --allow-run --allow-env scripts/test/coverage-ci.ts report coverage/integration-client", "coverage:ci:node-executor": "node scripts/test/coverage-node-executor.mjs", "coverage:ci": "deno task test:coverage:unit && deno task coverage:gate", "coverage:html": "deno coverage coverage --include=src/ --include=cli/ --exclude=/tests/ --exclude=/__tests__/ --html", diff --git a/src/platform/adapters/runtime/node/http-server.ts b/src/platform/adapters/runtime/node/http-server.ts index 6ef8cf6024..186dc08bf2 100644 --- a/src/platform/adapters/runtime/node/http-server.ts +++ b/src/platform/adapters/runtime/node/http-server.ts @@ -594,13 +594,15 @@ export function createNodeRequestListener( if ("flushHeaders" in _res) _res.flushHeaders(); else _res.writeHead(response.status); } + // Both protocols accept byte chunks despite incompatible overloaded signatures. + const writer: { write(chunk: Uint8Array): boolean } = _res; while (true) { const { done, value } = await responseReader.read(); if (done) break; if (requestAbort.signal.aborted || _res.destroyed) { throw requestAbort.signal.reason ?? clientDisconnectedError(); } - const writable = "stream" in _res ? _res.write(value) : _res.write(value); + const writable = writer.write(value); if (!writable) { await waitForResponseDrain(_res, requestAbort.signal); } From 9cc74fb8c8e04730dd85ecca0cbfce2a2de9d1ba Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 26 Oct 2026 16:00:56 +0100 Subject: [PATCH 10/22] Keep Deno persistent requests bound to their real HTTP listener Deno's Node HTTP implementation closes completed sockets when its Server is not listening. A separate TCP demultiplexer feeding an unbound HTTP server therefore advertised keep-alive but closed each response, racing the renderer's next pooled request. Let the actual HTTP1 server own the public listener and defer only its connection dispatch until fixed-prefix protocol detection completes. Reuse the captured public event dispatcher without removing parser listeners; retain h2c session ownership, upgrade events, shutdown and private collection discipline. Rejected: Fake listening flags or private handles | violates actual transport ownership Rejected: Additional hidden listeners or Connection close | unnecessary exposure or persistent-connection regression Tested: Deterministic same-socket POST204 then GET red/green; thirty-three wire tests and four steps; frozen checks; lint; forty Node transport tests; parallel renderer thirty-two steps Scope-risk: moderate --- .../runtime/node/dual-protocol-server.ts | 43 +++++++------- .../core/deno-http2-transport.test.ts | 59 +++++++++++++++++++ 2 files changed, 82 insertions(+), 20 deletions(-) diff --git a/src/platform/adapters/runtime/node/dual-protocol-server.ts b/src/platform/adapters/runtime/node/dual-protocol-server.ts index 7b55aaeff8..c1ef949a5f 100644 --- a/src/platform/adapters/runtime/node/dual-protocol-server.ts +++ b/src/platform/adapters/runtime/node/dual-protocol-server.ts @@ -1,9 +1,5 @@ -import { Server as TcpServer, type Socket } from "node:net"; -import { - createServer as createHttpServer, - type IncomingMessage, - type ServerResponse, -} from "node:http"; +import { Socket } from "node:net"; +import { type IncomingMessage, Server as HttpServer, type ServerResponse } from "node:http"; import { createServer as createHttp2Server, type Http2ServerRequest, @@ -12,17 +8,21 @@ import { } from "node:http2"; const apply = Reflect.apply; +const emitHttpServerEvent = HttpServer.prototype.emit; const setAdd = Set.prototype.add; const setDelete = Set.prototype.delete; const setForEach = Set.prototype.forEach; const HTTP2_PREFACE = new TextEncoder().encode("PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n"); -/** Public Node transports sharing Deno's existing cleartext HTTP/1 + h2c port. */ -export class DualProtocolServer extends TcpServer { +/** + * Public Node transports sharing Deno's existing cleartext HTTP/1 + h2c port. + * HTTP1 owns the real listener: Deno closes persistent responses from unbound + * HTTP servers, even when another TCP server handed their parser a socket. + */ +export class DualProtocolServer extends HttpServer { private readonly sockets = new Set(); private readonly sessions = new Set(); - private readonly http1; private readonly http2; constructor( @@ -32,21 +32,23 @@ export class DualProtocolServer extends TcpServer { ) => void, private readonly prefaceTimeoutMs = 10_000, ) { - super(); - this.http1 = createHttpServer(listener); + super(listener); this.http2 = createHttp2Server(listener); - this.http1.on( - "upgrade", - (request, socket, head) => this.emit("upgrade", request, socket, head), - ); - this.http1.on("error", (error) => this.emit("error", error)); this.http2.on("error", (error) => this.emit("error", error)); this.http2.on("session", (session) => { apply(setAdd, this.sessions, [session]); session.on("error", () => session.destroy()); session.once("close", () => apply(setDelete, this.sessions, [session])); }); - this.on("connection", (socket) => this.route(socket)); + } + + // Delay only connection dispatch until its public protocol parser is known. + override emit(event: string | symbol, ...args: unknown[]): boolean { + if (event === "connection" && args[0] instanceof Socket) { + this.route(args[0]); + return true; + } + return apply(emitHttpServerEvent, this, [event, ...args]); } private route(socket: Socket): void { @@ -83,7 +85,8 @@ export class DualProtocolServer extends TcpServer { } cleanup(); socket.unshift(bytes); - (matches ? this.http2 : this.http1).emit("connection", socket); + if (matches) this.http2.emit("connection", socket); + else apply(emitHttpServerEvent, this, ["connection", socket]); socket.resume(); }; socket.on("data", data); @@ -92,9 +95,9 @@ export class DualProtocolServer extends TcpServer { socket.once("error", cleanup); } - closeAllConnections(): void { + override closeAllConnections(): void { apply(setForEach, this.sessions, [(session: ServerHttp2Session) => session.destroy()]); - this.http1.closeAllConnections(); + super.closeAllConnections(); apply(setForEach, this.sockets, [(socket: Socket) => socket.destroy()]); } } diff --git a/tests/integration/core/deno-http2-transport.test.ts b/tests/integration/core/deno-http2-transport.test.ts index e45ece1d1d..88f86de3e3 100644 --- a/tests/integration/core/deno-http2-transport.test.ts +++ b/tests/integration/core/deno-http2-transport.test.ts @@ -1,5 +1,7 @@ import { it } from "#veryfront/testing/bdd.ts"; import { connect, constants } from "node:http2"; +import { connect as connectTcp } from "node:net"; +import type { EventEmitter } from "node:events"; import { DenoAdapter } from "#veryfront/platform/adapters/runtime/deno/adapter.ts"; import { DualProtocolServer } from "#veryfront/platform/adapters/runtime/node/dual-protocol-server.ts"; import { assert, assertEquals } from "#veryfront/testing/assert.ts"; @@ -327,3 +329,60 @@ for (const transport of ["native", "selected"] as const) { } } } + +it("Deno HTTP1 preserves keep-alive across bodyless POST and the next request", async () => { + const methods: string[] = []; + const server = await new DenoAdapter().serve((request) => { + methods.push(request.method); + return new Response(null, { status: 204 }); + }, { hostname: "127.0.0.1", port: 0, nodeWebSocketServerProvider: null }); + const socket = connectTcp(server.addr.port, "127.0.0.1"); + const completed = Promise.withResolvers(); + let responses = 0; + let buffer = ""; + socket.on("error", completed.reject); + socket.on("close", () => { + if (responses < 2) completed.reject(new Error("Keep-alive closed before the second response")); + }); + socket.on("connect", () => + socket.write( + "POST /admitted HTTP/1.1\r\nHost: localhost\r\nContent-Length: 0\r\nConnection: keep-alive\r\n\r\n", + )); + socket.on("data", (chunk) => { + buffer += chunk.toString(); + while (buffer.includes("\r\n\r\n")) { + const end = buffer.indexOf("\r\n\r\n"); + const headers = buffer.slice(0, end); + buffer = buffer.slice(end + 4); + if (!headers.startsWith("HTTP/1.1 204") || !/connection: keep-alive/i.test(headers)) { + completed.reject(new Error("Expected a persistent bodyless response")); + return; + } + if (++responses === 1) { + socket.write("GET /continue HTTP/1.1\r\nHost: localhost\r\nConnection: keep-alive\r\n\r\n"); + } else completed.resolve(); + } + }); + try { + await bounded(completed.promise); + assertEquals(methods, ["POST", "GET"]); + assertEquals(responses, 2); + } finally { + socket.destroy(); + await bounded(server.stop()); + } +}); + +it("dual protocol listening owner forwards secondary public events exactly once", async () => { + const server = new DualProtocolServer((_request, response) => response.end()); + const event = Symbol("synthetic event"); + const received: unknown[] = []; + server.on("synthetic", (value) => received.push(value)); + const events: EventEmitter = server; + events.on(event, (value: unknown) => received.push(value)); + assertEquals(server.emit("synthetic", "string"), true); + assertEquals(server.emit(event, "symbol"), true); + assertEquals(server.emit("unobserved"), false); + assertEquals(received, ["string", "symbol"]); + server.closeAllConnections(); +}); From a4c88131bca8e5279bbb211fcd1a44a0b7065b90 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 26 Oct 2026 16:05:45 +0100 Subject: [PATCH 11/22] Keep raw sockets outside authored instance observers Validate incoming transport sockets with the captured intrinsic hasInstance operation. A direct instanceof check would invoke a mutable Socket.hasInstance hook installed by project code, exposing the raw connection before request credential isolation. Keep the original exact Socket validation and cover the authored observer with a failing-first regression. Tested: Eighteen ownership and real wire tests; frozen typecheck; lint; mandatory uncommitted review Scope-risk: narrow --- .../runtime/node/dual-protocol-server.ts | 7 ++++- .../core/dual-protocol-ownership.test.ts | 27 +++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/src/platform/adapters/runtime/node/dual-protocol-server.ts b/src/platform/adapters/runtime/node/dual-protocol-server.ts index c1ef949a5f..68f57b1573 100644 --- a/src/platform/adapters/runtime/node/dual-protocol-server.ts +++ b/src/platform/adapters/runtime/node/dual-protocol-server.ts @@ -9,10 +9,15 @@ import { const apply = Reflect.apply; const emitHttpServerEvent = HttpServer.prototype.emit; +const socketHasInstance = Function.prototype[Symbol.hasInstance]; const setAdd = Set.prototype.add; const setDelete = Set.prototype.delete; const setForEach = Set.prototype.forEach; +function isSocket(value: unknown): value is Socket { + return apply(socketHasInstance, Socket, [value]); +} + const HTTP2_PREFACE = new TextEncoder().encode("PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n"); /** @@ -44,7 +49,7 @@ export class DualProtocolServer extends HttpServer { // Delay only connection dispatch until its public protocol parser is known. override emit(event: string | symbol, ...args: unknown[]): boolean { - if (event === "connection" && args[0] instanceof Socket) { + if (event === "connection" && isSocket(args[0])) { this.route(args[0]); return true; } diff --git a/tests/integration/core/dual-protocol-ownership.test.ts b/tests/integration/core/dual-protocol-ownership.test.ts index a573415806..e22ed12c7c 100644 --- a/tests/integration/core/dual-protocol-ownership.test.ts +++ b/tests/integration/core/dual-protocol-ownership.test.ts @@ -42,3 +42,30 @@ it("dual protocol ownership does not expose raw sockets to authored Set observer await closed; assertEquals(observations, 0); }); + +it("dual protocol dispatch does not expose sockets to authored instance observers", async () => { + const server = new DualProtocolServer(() => {}); + const socket = new Socket(); + const closed = new Promise((resolve) => socket.once("close", resolve)); + const descriptor = Object.getOwnPropertyDescriptor(Socket, Symbol.hasInstance); + const instanceOf = Function.prototype[Symbol.hasInstance]; + const apply = Reflect.apply; + let observations = 0; + Object.defineProperty(Socket, Symbol.hasInstance, { + configurable: true, + value(value: unknown) { + if (value === socket) observations++; + return apply(instanceOf, Socket, [value]); + }, + }); + try { + server.emit("connection", socket); + } finally { + if (descriptor) Object.defineProperty(Socket, Symbol.hasInstance, descriptor); + else Reflect.deleteProperty(Socket, Symbol.hasInstance); + server.closeAllConnections(); + socket.destroy(); + } + await closed; + assertEquals(observations, 0); +}); From 3e7a44a15a6189ada610f72ca5908e30d4a707f7 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 26 Oct 2026 16:24:27 +0100 Subject: [PATCH 12/22] Keep upgrade metadata within its precise transport contracts The native anti-slop gate rejects generic object parameters. Use the existing HTTP handler response and Node WebSocket connection contracts for private metadata keys, and construct a valid response and connection in the primordial-observer regression. Constraint: Preserve exact-object grants and captured collection intrinsics without adding lint exemptions. Confidence: high Scope-risk: narrow Tested: Pinned Deno 2.9.7 frozen typecheck, full lint, anti-slop baseline, and eight real WebSocket/provenance tests with two steps. Not-tested: Changed-head native Linux CI remains required. --- .../runtime/shared/http-upgrade-provenance.ts | 26 ++++++++++++------- .../core/http-upgrade-provenance.test.ts | 10 +++++-- 2 files changed, 25 insertions(+), 11 deletions(-) diff --git a/src/platform/adapters/runtime/shared/http-upgrade-provenance.ts b/src/platform/adapters/runtime/shared/http-upgrade-provenance.ts index f1da516fd6..6efbeb641a 100644 --- a/src/platform/adapters/runtime/shared/http-upgrade-provenance.ts +++ b/src/platform/adapters/runtime/shared/http-upgrade-provenance.ts @@ -1,3 +1,5 @@ +import type { HttpHandlerResponse } from "../../base.ts"; +import type { NodeWebSocketConnection } from "#veryfront/extensions/websocket/node-websocket-server-provider.ts"; import { INVALID_ARGUMENT } from "#veryfront/errors/error-registry/general.ts"; // Capture before authored code can replace collection methods and observe @@ -11,7 +13,7 @@ const setAdd = WeakSet.prototype.add; const setHas = WeakSet.prototype.has; const requests = new WeakMap(); -const idleTimeouts = new WeakMap(); +const idleTimeouts = new WeakMap(); /** @internal Only the listener grants upgrade ownership to its exact request. */ export function recordNodeWebSocketUpgradeRequest(request: Request, id: string): void { @@ -36,12 +38,15 @@ export function consumeNodeWebSocketUpgradeRequest(request: Request): string { } /** @internal Response policy never travels through caller-controlled headers. */ -export function recordWebSocketUpgradeIdleTimeout(response: object, seconds: number): void { +export function recordWebSocketUpgradeIdleTimeout( + response: HttpHandlerResponse, + seconds: number, +): void { apply(mapSet, idleTimeouts, [response, seconds]); } /** @internal Read the policy attached by the trusted upgrade adapter. */ -export function getWebSocketUpgradeIdleTimeout(response: object): number | undefined { +export function getWebSocketUpgradeIdleTimeout(response: HttpHandlerResponse): number | undefined { return apply(mapGet, idleTimeouts, [response]) as number | undefined; } @@ -50,19 +55,22 @@ export function revokeNodeWebSocketUpgradeRequest(request: Request): void { apply(mapDelete, requests, [request]); } -const domResponses = new WeakSet(); -const bufferedAmounts = new WeakMap number>(); +const domResponses = new WeakSet(); +const bufferedAmounts = new WeakMap number>(); -export function recordDenoWebSocketResponse(response: object): void { +export function recordDenoWebSocketResponse(response: HttpHandlerResponse): void { apply(setAdd, domResponses, [response]); } -export function isDenoWebSocketResponse(response: object): boolean { +export function isDenoWebSocketResponse(response: HttpHandlerResponse): boolean { return apply(setHas, domResponses, [response]); } -export function recordWebSocketBufferedAmount(connection: object, read: () => number): void { +export function recordWebSocketBufferedAmount( + connection: NodeWebSocketConnection, + read: () => number, +): void { apply(mapSet, bufferedAmounts, [connection, read]); } -export function readWebSocketBufferedAmount(connection: object): number { +export function readWebSocketBufferedAmount(connection: NodeWebSocketConnection): number { const read = apply(mapGet, bufferedAmounts, [connection]) as (() => number) | undefined; if (!read) { throw new TypeError("WebSocket provider does not expose its transport buffered amount"); diff --git a/tests/integration/core/http-upgrade-provenance.test.ts b/tests/integration/core/http-upgrade-provenance.test.ts index adb796f548..9bf6f03371 100644 --- a/tests/integration/core/http-upgrade-provenance.test.ts +++ b/tests/integration/core/http-upgrade-provenance.test.ts @@ -16,8 +16,14 @@ import { it("upgrade provenance does not expose requests to replaced collection methods", () => { const request = new Request("http://localhost", { headers: { authorization: "synthetic" } }); const clone = request.clone(); - const response = {}; - const connection = {}; + const response = new Response(null); + const connection = new class extends EventTarget { + on() { + return this; + } + send() {} + close() {} + }(); const map = Object.getOwnPropertyDescriptors(WeakMap.prototype); const set = Object.getOwnPropertyDescriptors(WeakSet.prototype); let observations = 0; From ba28746480c54e9763982f32682dcee004ca62f6 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 26 Oct 2026 17:08:08 +0100 Subject: [PATCH 13/22] Preserve cancellation and least privilege across Deno entry points Native review found a sibling service listener bypassing the affected-version transport and eager implicit WebSocket loading expanding HTTP-only permissions. Reuse the canonical service transport and resolve the default provider only after a registered upgrade, retaining captured disabled/custom policy, single-flight loading and shutdown ownership. Preserve callback error identity, reject malformed authorities as client errors, and copy SharedArrayBuffer WebSocket payloads as declared binary inputs. Constraint: Keep Node/Bun policy, valid WebSocket behavior, HTTP2, credential boundaries and native CI gates intact. Rejected: Reject SharedArrayBuffer to mimic pinned native behavior | That narrows the declared ArrayBufferLike surface. Confidence: high Scope-risk: moderate Tested: Pinned Deno 2.9.7 focused 61 tests and 35 steps, additional relocated least-privilege subprocess, six lazy-provider wire cases, full lint, affected frozen types, semantic/anti-slop/dependency/module audits, normal proxy compilation, clean mandatory uncommitted review. Not-tested: Changed-head native Linux CI and staging remain required. --- .../adapters/runtime/deno/http-server.ts | 17 +- .../runtime/deno/http-transport.test.ts | 16 +- .../adapters/runtime/deno/http-transport.ts | 19 +-- .../adapters/runtime/deno/node-websocket.ts | 6 +- .../adapters/runtime/node/http-server.ts | 40 ++++- src/platform/compat/http/deno-server.ts | 17 +- src/server/service-server.test.ts | 15 ++ src/server/service-server.ts | 14 +- .../core/deno-http-cancellation.test.ts | 57 ++++++- .../core/deno-http-permissions.test.ts | 37 +++++ .../core/deno-http2-transport.test.ts | 74 ++++++++- .../core/deno-node-http-upgrade.test.ts | 152 +++++++++++++++++- .../core/deno-websocket-dom.test.ts | 15 +- 13 files changed, 428 insertions(+), 51 deletions(-) create mode 100644 tests/integration/core/deno-http-permissions.test.ts diff --git a/src/platform/adapters/runtime/deno/http-server.ts b/src/platform/adapters/runtime/deno/http-server.ts index 59f5d6dc84..7779c4f847 100644 --- a/src/platform/adapters/runtime/deno/http-server.ts +++ b/src/platform/adapters/runtime/deno/http-server.ts @@ -1,7 +1,7 @@ import { createDenoNodeServerWithStartupOwner } from "../node/http-server.ts"; import { needsDenoNodeHttpTransport, - resolveDenoNodeHttpOptions, + resolveDefaultDenoNodeWebSocketProvider, scopeDenoNodeHttpHandler, } from "./http-transport.ts"; import type { HttpHandlerResponse, ServeOptions, Server } from "../../base.ts"; @@ -402,13 +402,24 @@ export async function createDenoServer( }); } if (needsDenoNodeHttpTransport()) { - const resolved = await resolveDenoNodeHttpOptions(options); + let didListen = false; try { return await createDenoNodeServerWithStartupOwner( scopeDenoNodeHttpHandler(handler), - resolved, + { + ...options, + onListen(address) { + didListen = true; + options.onListen?.(address); + }, + }, + undefined, + resolveDefaultDenoNodeWebSocketProvider, ); } catch (error) { + // Once bound, callback failures belong to the caller. Explicit abort + // reasons likewise must not be mistaken for errors from listen(). + if (didListen || options.signal?.aborted) throw error; throwDenoBindError(error, options.hostname ?? "localhost", options.port ?? DEFAULT_PORT); } } diff --git a/src/platform/adapters/runtime/deno/http-transport.test.ts b/src/platform/adapters/runtime/deno/http-transport.test.ts index 0c5a73ab95..3bdd5cadb6 100644 --- a/src/platform/adapters/runtime/deno/http-transport.test.ts +++ b/src/platform/adapters/runtime/deno/http-transport.test.ts @@ -1,7 +1,6 @@ import { it } from "#veryfront/testing/bdd.ts"; -import { assertEquals, assertStrictEquals } from "#veryfront/testing/assert.ts"; -import { needsDenoNodeHttpTransport, resolveDenoNodeHttpOptions } from "./http-transport.ts"; -import { WsNodeWebSocketServerProvider } from "../../../../../extensions/ext-node-websocket-ws/src/index.ts"; +import { assertEquals } from "#veryfront/testing/assert.ts"; +import { needsDenoNodeHttpTransport } from "./http-transport.ts"; it("Deno transport selection is bounded to source-proven affected versions", () => { for (const version of ["2.9.7", "2.9.8"]) assertEquals(needsDenoNodeHttpTransport(version), true); @@ -9,14 +8,3 @@ it("Deno transport selection is bounded to source-proven affected versions", () assertEquals(needsDenoNodeHttpTransport(version), false); } }); - -it("explicit disabled and custom provider intent bypasses the Deno default loader", async () => { - const disabled = { nodeWebSocketServerProvider: null }; - assertStrictEquals(await resolveDenoNodeHttpOptions(disabled), disabled); - const custom = { nodeWebSocketServerProvider: WsNodeWebSocketServerProvider }; - assertStrictEquals(await resolveDenoNodeHttpOptions(custom), custom); - assertEquals( - (await resolveDenoNodeHttpOptions({})).nodeWebSocketServerProvider?.supportsIdleTimeout, - true, - ); -}); diff --git a/src/platform/adapters/runtime/deno/http-transport.ts b/src/platform/adapters/runtime/deno/http-transport.ts index 14fc63cd83..9371e8de55 100644 --- a/src/platform/adapters/runtime/deno/http-transport.ts +++ b/src/platform/adapters/runtime/deno/http-transport.ts @@ -1,4 +1,3 @@ -import type { ServeOptions } from "../../base.ts"; import { getNativeDeno } from "../../../compat/http/native-response.ts"; import { scopeRequestAsyncContext } from "../../../compat/http/request-async-scope.ts"; import { getEnvOverlayStorage } from "../../../compat/process.ts"; @@ -28,20 +27,12 @@ export function scopeDenoNodeHttpHandler( }); } -/** - * Direct Deno listeners retain their implicit WebSocket support. A host that - * resolved extension policy passes null to explicitly disable the provider; - * that decision must never trigger a default import. - */ -export async function resolveDenoNodeHttpOptions(options: ServeOptions): Promise { - if (options.nodeWebSocketServerProvider !== undefined) return options; +/** Load the implicit provider only when an authorized WebSocket upgrade needs it. */ +export async function resolveDefaultDenoNodeWebSocketProvider(): Promise< + Readonly +> { const extension = await importFirstPartyExtensionModule<{ WsNodeWebSocketServerProvider: Readonly; }>("ext-node-websocket-ws", "@veryfront/ext-node-websocket-ws"); - return { - ...options, - nodeWebSocketServerProvider: snapshotNodeWebSocketServerProvider( - extension.WsNodeWebSocketServerProvider, - ), - }; + return snapshotNodeWebSocketServerProvider(extension.WsNodeWebSocketServerProvider); } diff --git a/src/platform/adapters/runtime/deno/node-websocket.ts b/src/platform/adapters/runtime/deno/node-websocket.ts index 901c9bfe89..ccc421ff5a 100644 --- a/src/platform/adapters/runtime/deno/node-websocket.ts +++ b/src/platform/adapters/runtime/deno/node-websocket.ts @@ -1,7 +1,10 @@ +import { types as nodeUtilTypes } from "node:util"; import { NodeServerAdapter, NodeWebSocket } from "../node/websocket-adapter.ts"; import type { HttpHandlerResponse, WebSocketUpgradeOptions } from "../../base.ts"; import { recordDenoWebSocketResponse } from "../shared/http-upgrade-provenance.ts"; +const isSharedArrayBuffer = nodeUtilTypes.isSharedArrayBuffer; + /** DOM surface retained by Deno when its HTTP listener uses Node transport. */ export class DenoNodeWebSocket extends EventTarget implements WebSocket { readonly CONNECTING = 0; @@ -119,7 +122,8 @@ export class DenoNodeWebSocket extends EventTarget implements WebSocket { if (typeof data === "string" || data instanceof Blob) normalized = data; else if (ArrayBuffer.isView(data)) { normalized = new Uint8Array(data.buffer, data.byteOffset, data.byteLength).slice().buffer; - } else if (data instanceof ArrayBuffer) normalized = data.slice(0); + } else if (isSharedArrayBuffer(data)) normalized = new Uint8Array(data).slice().buffer; + else if (data instanceof ArrayBuffer) normalized = data.slice(0); else normalized = String(data); if (!this.sending && !(normalized instanceof Blob)) { this.inner.send(normalized); diff --git a/src/platform/adapters/runtime/node/http-server.ts b/src/platform/adapters/runtime/node/http-server.ts index 186dc08bf2..0d111889db 100644 --- a/src/platform/adapters/runtime/node/http-server.ts +++ b/src/platform/adapters/runtime/node/http-server.ts @@ -12,6 +12,7 @@ import { captureNodeWebSocketServer, NODE_WEBSOCKET_SERVER_PROVIDER_MISSING_MESSAGE, type NodeWebSocketServerOptions, + type NodeWebSocketServerProvider, snapshotNodeWebSocketServerProvider, } from "#veryfront/extensions/websocket"; import { @@ -497,7 +498,16 @@ export function createNodeRequestListener( const http2 = _req.httpVersionMajor === 2; const authority = http2 ? _req.headers[":authority"] : _req.headers.host; const scheme = http2 && _req.headers[":scheme"] === "https" ? "https" : "http"; - const url = new URL(_req.url ?? "/", `${scheme}://${authority ?? fallbackHostname}`); + let url: URL; + try { + url = new URL(_req.url ?? "/", `${scheme}://${authority ?? fallbackHostname}`); + } catch { + // Reject malformed transport authority without logging private input or + // turning a client parse failure into an application handler error. + _res.statusCode = 400; + _res.end("Bad Request"); + return; + } const method = _req.method ?? "GET"; const body = method === "GET" || method === "HEAD" ? null : _req; const headers = new Headers(); @@ -654,8 +664,9 @@ export function createDenoNodeServerWithStartupOwner( handler: NodeRequestHandler, options: ServeOptions, owner?: (server: NodeServer) => void, + resolveDefaultProvider?: () => Promise>, ): Promise { - return createNodeServerInternal(handler, options, owner, true); + return createNodeServerInternal(handler, options, owner, true, resolveDefaultProvider); } async function createNodeServerInternal( @@ -663,6 +674,7 @@ async function createNodeServerInternal( options: ServeOptions, ownStartupServer?: (server: NodeServer) => void, dualProtocol = false, + resolveDefaultProvider?: () => Promise>, ): Promise { const { port = DEFAULT_PORT, @@ -671,9 +683,11 @@ async function createNodeServerInternal( onRuntimeError, signal, } = options; - const nodeWebSocketServerProvider = options.nodeWebSocketServerProvider == null + let nodeWebSocketServerProvider = options.nodeWebSocketServerProvider == null ? undefined : snapshotNodeWebSocketServerProvider(options.nodeWebSocketServerProvider); + const allowDefaultProvider = options.nodeWebSocketServerProvider === undefined; + let defaultProvider: Promise> | undefined; if (onRuntimeError !== undefined && typeof onRuntimeError !== "function") { throw new TypeError("Node server runtime error callback must be a function"); } @@ -884,16 +898,28 @@ async function createNodeServerInternal( failUpgrade(new Error("Node server stopped before WebSocket upgrade completed")); return; } + if ( + nodeWebSocketServerProvider === undefined && + allowDefaultProvider && resolveDefaultProvider + ) { + defaultProvider ??= resolveDefaultProvider().then(snapshotNodeWebSocketServerProvider); + nodeWebSocketServerProvider = await defaultProvider; + if (upgradesDisposed || transportSettled || requestAbort.signal.aborted) { + failUpgrade(new Error("Node server stopped before WebSocket provider resolved")); + return; + } + } if (nodeWebSocketServerProvider === undefined) { failUpgrade(new Error(NODE_WEBSOCKET_SERVER_PROVIDER_MISSING_MESSAGE)); return; } + const provider = nodeWebSocketServerProvider; const idleTimeout = getWebSocketUpgradeIdleTimeout(response) ?? 0; - if (idleTimeout > 0 && !nodeWebSocketServerProvider.supportsIdleTimeout) { + if (idleTimeout > 0 && !provider.supportsIdleTimeout) { failUpgrade(new Error("WebSocket provider does not support the requested idle timeout")); return; } - if (isDenoWebSocketResponse(response) && !nodeWebSocketServerProvider.getBufferedAmount) { + if (isDenoWebSocketResponse(response) && !provider.getBufferedAmount) { failUpgrade( new Error("Deno WebSocket provider must expose its transport buffered amount"), ); @@ -901,7 +927,7 @@ async function createNodeServerInternal( } if (!wsServer) { const candidate = captureNodeWebSocketServer( - nodeWebSocketServerProvider.createServer( + provider.createServer( handshakeController.serverOptions, ), ); @@ -931,7 +957,7 @@ async function createNodeServerInternal( } transportSettled = true; clearTransportState(); - const getBufferedAmount = nodeWebSocketServerProvider.getBufferedAmount; + const getBufferedAmount = provider.getBufferedAmount; if (getBufferedAmount) { recordWebSocketBufferedAmount(ws, () => getBufferedAmount(ws)); } diff --git a/src/platform/compat/http/deno-server.ts b/src/platform/compat/http/deno-server.ts index b3e0efcb62..715906d87e 100644 --- a/src/platform/compat/http/deno-server.ts +++ b/src/platform/compat/http/deno-server.ts @@ -2,7 +2,7 @@ import { createDenoNodeServerWithStartupOwner } from "../../adapters/runtime/nod import { NodeHttpServer } from "./node-server.ts"; import { needsDenoNodeHttpTransport, - resolveDenoNodeHttpOptions, + resolveDefaultDenoNodeWebSocketProvider, scopeDenoNodeHttpHandler, } from "../../adapters/runtime/deno/http-transport.ts"; import type { Handler, HttpServer, ServeOptions } from "./types.ts"; @@ -72,11 +72,18 @@ export class DenoHttpServer implements HttpServer { if (options.signal?.aborted) throw abortError(options.signal); if (needsDenoNodeHttpTransport()) { - const server = new NodeHttpServer(createDenoNodeServerWithStartupOwner); + const server = new NodeHttpServer((handler, options, owner) => + createDenoNodeServerWithStartupOwner( + handler, + options, + owner, + resolveDefaultDenoNodeWebSocketProvider, + ) + ); const controller = new AbortController(); const finished = Promise.withResolvers(); const state = { server, controller, finished, stopFailed: false }; - // Teardown may fail before asynchronous provider resolution finishes. + // Teardown may fail before asynchronous listener startup finishes. void finished.promise.catch(() => undefined); this.fallback = state; const abort = () => { @@ -86,12 +93,12 @@ export class DenoHttpServer implements HttpServer { options.signal?.addEventListener("abort", abort, { once: true }); if (options.signal?.aborted) abort(); try { - const resolved = await resolveDenoNodeHttpOptions({ + const resolved = { ...options, hostname: options.hostname ?? LOCALHOST.IPV4, port: options.port ?? DEFAULT_COMPAT_HTTP_PORT, signal: controller.signal, - }); + }; await server.serve(scopeDenoNodeHttpHandler(handler), resolved); await finished.promise; } catch (error) { diff --git a/src/server/service-server.test.ts b/src/server/service-server.test.ts index 46afa1f0f1..c783ff8e86 100644 --- a/src/server/service-server.test.ts +++ b/src/server/service-server.test.ts @@ -113,6 +113,8 @@ Deno.test("Deno service shutdown runs runtime stop when server shutdown rejects" removeSignalListener: typeof Deno.removeSignalListener; }; const originalServe = denoRuntime.serve; + const originalVersion = Deno.version; + Object.defineProperty(Deno, "version", { value: { ...originalVersion, deno: "2.9.9" } }); const originalAddSignalListener = denoRuntime.addSignalListener; const originalRemoveSignalListener = denoRuntime.removeSignalListener; const shutdownError = new Error("deno shutdown failed"); @@ -152,6 +154,7 @@ Deno.test("Deno service shutdown runs runtime stop when server shutdown rejects" assertEquals(events, ["runtime-shutdown", "server-shutdown", "runtime-stop"]); } finally { denoRuntime.serve = originalServe; + Object.defineProperty(Deno, "version", { value: originalVersion }); denoRuntime.addSignalListener = originalAddSignalListener; denoRuntime.removeSignalListener = originalRemoveSignalListener; } @@ -164,6 +167,8 @@ Deno.test("Deno service shutdown preserves undefined transport rejection", async removeSignalListener: typeof Deno.removeSignalListener; }; const originalServe = denoRuntime.serve; + const originalVersion = Deno.version; + Object.defineProperty(Deno, "version", { value: { ...originalVersion, deno: "2.9.9" } }); const originalAddSignalListener = denoRuntime.addSignalListener; const originalRemoveSignalListener = denoRuntime.removeSignalListener; const events: string[] = []; @@ -207,6 +212,7 @@ Deno.test("Deno service shutdown preserves undefined transport rejection", async assertEquals(events, ["runtime-shutdown", "server-shutdown", "runtime-stop"]); } finally { denoRuntime.serve = originalServe; + Object.defineProperty(Deno, "version", { value: originalVersion }); denoRuntime.addSignalListener = originalAddSignalListener; denoRuntime.removeSignalListener = originalRemoveSignalListener; } @@ -478,6 +484,8 @@ it("Deno service shutdown reports the transport failure when runtime stop also f removeSignalListener: typeof Deno.removeSignalListener; }; const originalServe = denoRuntime.serve; + const originalVersion = Deno.version; + Object.defineProperty(Deno, "version", { value: { ...originalVersion, deno: "2.9.9" } }); const originalAddSignalListener = denoRuntime.addSignalListener; const originalRemoveSignalListener = denoRuntime.removeSignalListener; const transportError = new Error("deno shutdown failed"); @@ -527,6 +535,7 @@ it("Deno service shutdown reports the transport failure when runtime stop also f ); } finally { denoRuntime.serve = originalServe; + Object.defineProperty(Deno, "version", { value: originalVersion }); denoRuntime.addSignalListener = originalAddSignalListener; denoRuntime.removeSignalListener = originalRemoveSignalListener; } @@ -576,6 +585,8 @@ it("Deno shutdown signal handler stops once and exits nonzero when stop fails", using _time = new FakeTime(); const denoRuntime = Deno as unknown as DenoSignalTestRuntime; const originalServe = denoRuntime.serve; + const originalVersion = Deno.version; + Object.defineProperty(Deno, "version", { value: { ...originalVersion, deno: "2.9.9" } }); const originalAddSignalListener = denoRuntime.addSignalListener; const originalRemoveSignalListener = denoRuntime.removeSignalListener; const originalExit = denoRuntime.exit; @@ -650,6 +661,7 @@ it("Deno shutdown signal handler stops once and exits nonzero when stop fails", ); } finally { denoRuntime.serve = originalServe; + Object.defineProperty(Deno, "version", { value: originalVersion }); denoRuntime.addSignalListener = originalAddSignalListener; denoRuntime.removeSignalListener = originalRemoveSignalListener; denoRuntime.exit = originalExit; @@ -660,6 +672,8 @@ it("Deno shutdown signal handler exits nonzero when graceful shutdown times out" using time = new FakeTime(); const denoRuntime = Deno as unknown as DenoSignalTestRuntime; const originalServe = denoRuntime.serve; + const originalVersion = Deno.version; + Object.defineProperty(Deno, "version", { value: { ...originalVersion, deno: "2.9.9" } }); const originalAddSignalListener = denoRuntime.addSignalListener; const originalRemoveSignalListener = denoRuntime.removeSignalListener; const originalExit = denoRuntime.exit; @@ -722,6 +736,7 @@ it("Deno shutdown signal handler exits nonzero when graceful shutdown times out" releaseStop(); await time.runMicrotasks(); denoRuntime.serve = originalServe; + Object.defineProperty(Deno, "version", { value: originalVersion }); denoRuntime.addSignalListener = originalAddSignalListener; denoRuntime.removeSignalListener = originalRemoveSignalListener; denoRuntime.exit = originalExit; diff --git a/src/server/service-server.ts b/src/server/service-server.ts index f713dd231b..1aacb1ea1e 100644 --- a/src/server/service-server.ts +++ b/src/server/service-server.ts @@ -4,6 +4,8 @@ import { createNodeServerWithStartupOwner, type NodeServer, } from "#veryfront/platform/adapters/runtime/node/http-server.ts"; +import { createDenoServer } from "#veryfront/platform/adapters/runtime/deno/http-server.ts"; +import { needsDenoNodeHttpTransport } from "#veryfront/platform/adapters/runtime/deno/http-transport.ts"; import type { NodeWebSocketServerProvider } from "#veryfront/extensions/websocket"; /** Public API contract for veryfront service server fetch. */ @@ -64,7 +66,7 @@ export type StartVeryfrontServerOptions = { logger?: VeryfrontServiceServerLogger; signals?: readonly NodeJS.Signals[]; hardShutdownTimeoutMs?: number; - /** Used only when the selected runtime is Node.js. */ + /** Used by Node.js and affected Deno HTTP transports. */ nodeWebSocketServerProvider?: Readonly | null; }; @@ -446,7 +448,15 @@ async function startDenoVeryfrontServer( const logger = options.logger ?? serverLogger.component("service-server"); const bindAddress = options.bindAddress ?? "0.0.0.0"; const abortController = new AbortController(); - const server = deno.serve({ + const fallback = needsDenoNodeHttpTransport() + ? await createDenoServer(options.runtime.fetch, { + port: options.port, + hostname: bindAddress, + signal: abortController.signal, + nodeWebSocketServerProvider: options.nodeWebSocketServerProvider, + }) + : undefined; + const server = fallback ? { addr: fallback.addr, shutdown: () => fallback.stop() } : deno.serve({ port: options.port, hostname: bindAddress, signal: abortController.signal, diff --git a/tests/integration/core/deno-http-cancellation.test.ts b/tests/integration/core/deno-http-cancellation.test.ts index 5d184b4768..4647d32756 100644 --- a/tests/integration/core/deno-http-cancellation.test.ts +++ b/tests/integration/core/deno-http-cancellation.test.ts @@ -186,7 +186,7 @@ it("settles serving on a failed fallback close and retains teardown for retry", } }); -it("close during async provider resolution prevents a listener from escaping", async () => { +it("close during asynchronous startup prevents a listener from escaping", async () => { const compatibility = new DenoHttpServer(); let listened = false; const serving = compatibility.serve(() => new Response("ok"), { @@ -249,3 +249,58 @@ it("Deno fallback retains startup ownership until cleanup can be retried", async NodeHttpServer.prototype.close = originalClose; } }); + +it("service listener observes disconnect after consuming the complete segmented body", async () => { + const { createVeryfrontServer, startVeryfrontServer } = await import( + "#veryfront/server/service-server.ts" + ); + const started = Promise.withResolvers(); + const aborted = Promise.withResolvers(); + const released = Promise.withResolvers(); + const server = await startVeryfrontServer({ + runtime: createVeryfrontServer({ + modules: [{ + name: "cancellation", + handle: async (request) => { + await request.text(); + request.signal.addEventListener("abort", () => aborted.resolve(), { once: true }); + started.resolve(); + await released.promise; + return new Response("complete"); + }, + }], + }), + port: 0, + bindAddress: "127.0.0.1", + signals: [], + logger: {}, + }); + const connection = await Deno.connect({ hostname: "127.0.0.1", port: server.port }); + let timeout: ReturnType | undefined; + try { + const body = "x".repeat(65536); + await writeAll( + connection, + `POST / HTTP/1.1\r\nHost: localhost\r\nContent-Length: ${body.length}\r\n\r\n${ + body.slice(0, 1024) + }`, + ); + await new Promise((resolve) => setTimeout(resolve, 50)); + await writeAll(connection, body.slice(1024)); + await started.promise; + connection.close(); + assertEquals( + await Promise.race([ + aborted.promise.then(() => true), + new Promise((resolve) => { + timeout = setTimeout(() => resolve(false), 1000); + }), + ]), + true, + ); + } finally { + if (timeout !== undefined) clearTimeout(timeout); + released.resolve(); + await server.stop(); + } +}); diff --git a/tests/integration/core/deno-http-permissions.test.ts b/tests/integration/core/deno-http-permissions.test.ts new file mode 100644 index 0000000000..4e81f20133 --- /dev/null +++ b/tests/integration/core/deno-http-permissions.test.ts @@ -0,0 +1,37 @@ +import { assertEquals } from "#veryfront/testing/assert.ts"; + +Deno.test("HTTP-only Deno listeners require network permission without env or read grants", async () => { + const runtime = + new URL("../../../src/platform/adapters/runtime/deno/http-server.ts", import.meta.url).href; + const compatibility = + new URL("../../../src/platform/compat/http/deno-server.ts", import.meta.url).href; + const service = new URL("../../../src/server/service-server.ts", import.meta.url).href; + const source = ` +import { createDenoServer } from ${JSON.stringify(runtime)}; +import { DenoHttpServer } from ${JSON.stringify(compatibility)}; +import { createVeryfrontServer, startVeryfrontServer } from ${JSON.stringify(service)}; +const handler = () => new Response("ok"); +const options = {hostname:"127.0.0.1",port:0}; +const listener = await createDenoServer(handler, options); +try { if(await (await fetch("http://127.0.0.1:"+listener.addr.port)).text()!=="ok")throw Error("runtime"); } +finally { await listener.stop(); } +const compat = new DenoHttpServer(); +const ready = Promise.withResolvers(); +const serving = compat.serve(handler,{...options,onListen:ready.resolve}); +try { const address=await ready.promise; if(await (await fetch("http://127.0.0.1:"+address.port)).text()!=="ok")throw Error("compat"); } +finally {await compat.close();await serving;} +const server = await startVeryfrontServer({runtime:createVeryfrontServer({modules:[{name:"http",handle:handler}]}),port:0,bindAddress:"127.0.0.1",signals:[],logger:{}}); +try {if(await(await fetch(server.url)).text()!=="ok")throw Error("service");}finally{await server.stop();} +`; + const child = new Deno.Command(Deno.execPath(), { + args: ["run", "--config=deno.json", "--allow-net=127.0.0.1", "--no-prompt", "-"], + stdin: "piped", + stdout: "piped", + stderr: "piped", + }).spawn(); + const writer = child.stdin.getWriter(); + await writer.write(new TextEncoder().encode(source)); + await writer.close(); + const output = await child.output(); + assertEquals(output.code, 0, new TextDecoder().decode(output.stderr)); +}); diff --git a/tests/integration/core/deno-http2-transport.test.ts b/tests/integration/core/deno-http2-transport.test.ts index 88f86de3e3..565f68c54d 100644 --- a/tests/integration/core/deno-http2-transport.test.ts +++ b/tests/integration/core/deno-http2-transport.test.ts @@ -247,7 +247,10 @@ for (const protocol of ["http1", "h2c"] as const) { let body = ""; const sibling = client.request({ ":path": "/sibling" }); sibling.on("error", finished.reject); - sibling.on("data", (chunk) => body += new TextDecoder().decode(chunk)); + sibling.on( + "data", + (chunk) => body += typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk), + ); sibling.on("end", () => finished.resolve(body)); sibling.end(); assertEquals(await bounded(finished.promise), "sibling"); @@ -386,3 +389,72 @@ it("dual protocol listening owner forwards secondary public events exactly once" assertEquals(received, ["string", "symbol"]); server.closeAllConnections(); }); + +it("Deno transport rejects malformed HTTP1 and h2 authorities before handlers", async () => { + const runtime = new DenoAdapter(); + let calls = 0; + const server = await runtime.serve(() => { + calls++; + return new Response("unexpected"); + }, { hostname: "127.0.0.1", port: 0, nodeWebSocketServerProvider: null }); + try { + const socket = await Deno.connect({ hostname: "127.0.0.1", port: server.addr.port }); + try { + await socket.write( + new TextEncoder().encode("GET / HTTP/1.1\r\nHost: [\r\nConnection: close\r\n\r\n"), + ); + const bytes = new Uint8Array(1024); + const count = await bounded(socket.read(bytes)); + assert(count !== null); + assert(new TextDecoder().decode(bytes.subarray(0, count)).startsWith("HTTP/1.1 400")); + } finally { + socket.close(); + } + const client = connect(`http://127.0.0.1:${server.addr.port}`); + try { + const status = Promise.withResolvers(); + const request = client.request({ ":path": "/", ":authority": "[" }); + request.on("response", (headers) => status.resolve(Number(headers[":status"]))); + request.on("error", status.reject); + request.resume(); + request.end(); + assertEquals(await bounded(status.promise), 400); + } finally { + client.destroy(); + } + assertEquals(calls, 0); + } finally { + await runtime.shutdown(); + } +}); + +for (const code of ["EADDRINUSE", "ENOTFOUND"]) { + it(`Deno onListen preserves callback ${code} error and releases listener`, async () => { + const runtime = new DenoAdapter(); + const failure = Object.assign(new Error("application callback failed"), { + code, + syscall: "getaddrinfo", + }); + let port = 0; + let caught: unknown; + try { + await runtime.serve(() => new Response("ok"), { + hostname: "127.0.0.1", + port: 0, + nodeWebSocketServerProvider: null, + onListen(address) { + port = address.port; + throw failure; + }, + }); + } catch (error) { + caught = error; + } finally { + await runtime.shutdown(); + } + assertEquals(caught === failure, true); + assert(port > 0); + const rebound = Deno.listen({ hostname: "127.0.0.1", port }); + rebound.close(); + }); +} diff --git a/tests/integration/core/deno-node-http-upgrade.test.ts b/tests/integration/core/deno-node-http-upgrade.test.ts index f0dd901e93..7d5d8af69f 100644 --- a/tests/integration/core/deno-node-http-upgrade.test.ts +++ b/tests/integration/core/deno-node-http-upgrade.test.ts @@ -1,6 +1,9 @@ import { describe, it } from "#veryfront/testing/bdd.ts"; import { assertEquals, assertStringIncludes, assertThrows } from "#veryfront/testing/assert.ts"; -import { createNodeServer } from "#veryfront/platform/adapters/runtime/node/http-server.ts"; +import { + createDenoNodeServerWithStartupOwner, + createNodeServer, +} from "#veryfront/platform/adapters/runtime/node/http-server.ts"; import { NodeServerAdapter } from "#veryfront/platform/adapters/runtime/node/websocket-adapter.ts"; import { WsNodeWebSocketServerProvider } from "../../../extensions/ext-node-websocket-ws/src/index.ts"; @@ -96,3 +99,150 @@ describe("Deno Node HTTP upgrade transport", () => { } }); }); + +it("Deno default WebSocket provider resolves once after concurrent authorized upgrades", async () => { + const adapter = new NodeServerAdapter(true); + const provider = Promise.withResolvers(); + const bothRequests = Promise.withResolvers(); + let requests = 0; + let loads = 0; + const server = await createDenoNodeServerWithStartupOwner( + (request) => { + if (++requests === 2) bothRequests.resolve(); + return adapter.upgradeWebSocket(request, { idleTimeout: 0 }).response; + }, + { hostname: "127.0.0.1", port: 0 }, + undefined, + () => { + loads++; + return provider.promise; + }, + ); + const clients = [ + new WebSocket(`ws://127.0.0.1:${server.addr.port}`), + new WebSocket(`ws://127.0.0.1:${server.addr.port}`), + ]; + const opened = clients.map((client) => + new Promise((resolve, reject) => { + client.onopen = () => resolve(); + client.onerror = () => reject(new Error("lazy provider handshake failed")); + }) + ); + try { + await bothRequests.promise; + assertEquals(loads, 1); + provider.resolve(WsNodeWebSocketServerProvider); + await Promise.all(opened); + assertEquals(loads, 1); + } finally { + await Promise.all(clients.map((client) => + new Promise((resolve) => { + client.onclose = () => resolve(); + client.close(); + }) + )); + await server.stop(); + } +}); + +for (const policy of ["disabled", "custom", "reject"] as const) { + it(`Deno lazy WebSocket provider honors ${policy} policy`, async () => { + const adapter = new NodeServerAdapter(true); + let loads = 0; + const failure = Promise.withResolvers(); + const options: Parameters[1] = { + hostname: "127.0.0.1", + port: 0, + nodeWebSocketServerProvider: policy === "disabled" + ? null + : policy === "custom" + ? WsNodeWebSocketServerProvider + : undefined, + }; + const server = await createDenoNodeServerWithStartupOwner( + (request) => { + const upgrade = adapter.upgradeWebSocket(request, { idleTimeout: 0 }); + upgrade.socket.addEventListener("error", () => failure.resolve()); + return upgrade.response; + }, + options, + undefined, + () => { + loads++; + return Promise.reject(new Error("synthetic provider load failure")); + }, + ); + // Caller mutation after startup cannot broaden the captured policy. + options.nodeWebSocketServerProvider = policy === "disabled" ? undefined : null; + const client = new WebSocket(`ws://127.0.0.1:${server.addr.port}`); + let opened = false; + const ready = Promise.withResolvers(); + const closed = Promise.withResolvers(); + client.onopen = () => { + opened = true; + ready.resolve(); + }; + client.onerror = () => {}; + client.onclose = () => { + closed.resolve(); + ready.resolve(); + }; + try { + await ready.promise; + assertEquals(opened, policy === "custom"); + assertEquals(loads, policy === "reject" ? 1 : 0); + if (!opened) await failure.promise; + } finally { + client.close(); + await closed.promise; + await server.stop(); + } + }); +} + +it("Deno lazy provider cannot create a server after listener shutdown", async () => { + const adapter = new NodeServerAdapter(true); + const loading = Promise.withResolvers(); + const provider = Promise.withResolvers(); + const failed = Promise.withResolvers(); + let servers = 0; + const server = await createDenoNodeServerWithStartupOwner( + (request) => { + const upgrade = adapter.upgradeWebSocket(request, { idleTimeout: 0 }); + upgrade.socket.addEventListener("error", () => failed.resolve()); + return upgrade.response; + }, + { hostname: "127.0.0.1", port: 0 }, + undefined, + () => { + loading.resolve(); + return provider.promise; + }, + ); + const client = new WebSocket(`ws://127.0.0.1:${server.addr.port}`); + const closed = Promise.withResolvers(); + let opened = false; + client.onopen = () => { + opened = true; + }; + client.onerror = () => {}; + client.onclose = () => closed.resolve(); + try { + await loading.promise; + await server.stop(); + provider.resolve({ + ...WsNodeWebSocketServerProvider, + createServer(options) { + servers++; + return WsNodeWebSocketServerProvider.createServer(options); + }, + }); + await closed.promise; + await failed.promise; + assertEquals(opened, false); + assertEquals(servers, 0); + } finally { + client.close(); + await server.stop(); + } +}); diff --git a/tests/integration/core/deno-websocket-dom.test.ts b/tests/integration/core/deno-websocket-dom.test.ts index 770bfddcac..f0065d861a 100644 --- a/tests/integration/core/deno-websocket-dom.test.ts +++ b/tests/integration/core/deno-websocket-dom.test.ts @@ -37,6 +37,16 @@ for (const transport of ["native", "selected"] as const) { assertEquals(dom.bufferedAmount >= 0, true); dom.send(new Blob(["first"])); dom.send(new Uint8Array([2])); + const shared = new SharedArrayBuffer(2); + new Uint8Array(shared).set([8, 9]); + if (transport === "native") { + // Deno 2.9.7 rejects direct SAB despite its ArrayBufferLike declaration. + assertThrows(() => dom.send(shared), TypeError); + assertThrows(() => dom.send(new Uint8Array(shared)), TypeError); + } else { + dom.send(shared); + dom.send(new Uint8Array(shared)); + } dom.send("third"); Reflect.apply(dom.send, dom, [42]); Reflect.apply(dom.send, dom, [{ toString: () => "object-string" }]); @@ -78,8 +88,8 @@ for (const transport of ["native", "selected"] as const) { Array.from(new Uint8Array(buffer)) ), ); - if (received.length === 5) client.send(new Uint8Array([7])); - if (received.length === 6) completed.resolve(); + if (received.length === (transport === "native" ? 5 : 7)) client.send(new Uint8Array([7])); + if (received.length === (transport === "native" ? 6 : 8)) completed.resolve(); }; client.onerror = () => completed.reject(new Error("DOM control handshake failed")); try { @@ -87,6 +97,7 @@ for (const transport of ["native", "selected"] as const) { assertEquals(await Promise.all(received), [ [102, 105, 114, 115, 116], [2], + ...(transport === "native" ? [] : [[8, 9], [8, 9]]), "third", "42", "object-string", From 9332e2ce14250013c51f1c838a55056a7e2df183 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 26 Oct 2026 17:19:32 +0100 Subject: [PATCH 14/22] Keep startup telemetry fixtures attached to their intended listener Four existing hosted-service tests replace native Deno.serve. Explicitly scope those fixtures to the unaffected native transport so the corrected affected-version service route cannot bypass their injected lifecycle. Separately occupy the actual service bind address and verify that affected-version failures are captured, flushed, reset and exit nonzero even when flushing rejects. Constraint: Preserve every existing telemetry assertion and verify the newly selected real listener without a production test injection or hook bypass. Confidence: high Scope-risk: narrow Tested: Pinned Deno 2.9.7 hosted suite 66 tests and five steps, frozen typecheck, lint, unchanged anti-slop baseline, clean mandatory uncommitted review. Not-tested: Full normal push hooks and changed-head native CI remain required. --- .../veryfront-cloud-agent-service.test.ts | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/src/agent/hosted/veryfront-cloud-agent-service.test.ts b/src/agent/hosted/veryfront-cloud-agent-service.test.ts index ba5eb9c38d..e127a4c6ad 100644 --- a/src/agent/hosted/veryfront-cloud-agent-service.test.ts +++ b/src/agent/hosted/veryfront-cloud-agent-service.test.ts @@ -358,6 +358,9 @@ async function withMockDenoServiceServer( ): Promise { const denoRuntime = Deno as unknown as TestDenoRuntime; const originalServe = denoRuntime.serve; + const originalVersion = Deno.version; + // These fixtures intentionally exercise the native Deno.serve seam. + Object.defineProperty(Deno, "version", { value: { ...originalVersion, deno: "2.7.12" } }); const originalAddSignalListener = denoRuntime.addSignalListener; const originalRemoveSignalListener = denoRuntime.removeSignalListener; const originalExit = denoRuntime.exit; @@ -396,6 +399,7 @@ async function withMockDenoServiceServer( }); } finally { denoRuntime.serve = originalServe; + Object.defineProperty(Deno, "version", { value: originalVersion }); denoRuntime.addSignalListener = originalAddSignalListener; denoRuntime.removeSignalListener = originalRemoveSignalListener; denoRuntime.exit = originalExit; @@ -1056,6 +1060,9 @@ Deno.test("startAgentService captures, flushes, and resets terminal startup fail try { const denoRuntime = Deno as unknown as TestDenoRuntime; const originalServe = denoRuntime.serve; + const originalVersion = Deno.version; + // These fixtures intentionally exercise the native Deno.serve seam. + Object.defineProperty(Deno, "version", { value: { ...originalVersion, deno: "2.7.12" } }); denoRuntime.serve = (() => { throw startupError; }) as typeof Deno.serve; @@ -1083,6 +1090,7 @@ Deno.test("startAgentService captures, flushes, and resets terminal startup fail }); } finally { denoRuntime.serve = originalServe; + Object.defineProperty(Deno, "version", { value: originalVersion }); } assertEquals(events, ["capture-startup", "flush:default", "reset"]); @@ -1124,6 +1132,9 @@ Deno.test("startAgentService resets and exits when startup error flush rejects", try { const denoRuntime = Deno as unknown as TestDenoRuntime; const originalServe = denoRuntime.serve; + const originalVersion = Deno.version; + // These fixtures intentionally exercise the native Deno.serve seam. + Object.defineProperty(Deno, "version", { value: { ...originalVersion, deno: "2.7.12" } }); denoRuntime.serve = (() => { throw startupError; }) as typeof Deno.serve; @@ -1151,6 +1162,7 @@ Deno.test("startAgentService resets and exits when startup error flush rejects", }); } finally { denoRuntime.serve = originalServe; + Object.defineProperty(Deno, "version", { value: originalVersion }); } assertEquals(events, ["capture-startup", "flush", "reset"]); @@ -2946,3 +2958,70 @@ it("hosted child explicit loader grants survive sibling spelling denials", () => assertEquals(tools, granted ? [granted] : []); } }); + +for (const rejectFlush of [false, true]) { + Deno.test(`selected Deno bind failure captures startup error and resets after flush rejection=${rejectFlush}`, async () => { + await withTempDir(async (rootDir) => { + writeMarkdownAgentDefinition(rootDir, "support"); + const occupied = Deno.listen({ hostname: "0.0.0.0", port: 0 }); + const originalVersion = Deno.version; + Object.defineProperty(Deno, "version", { value: { ...originalVersion, deno: "2.9.7" } }); + const events: string[] = []; + const captured: unknown[] = []; + const restore = veryfrontCloudAgentServiceInternals.setInitializeApplicationErrorsForTests( + () => ({ + enabled: true, + captureStartupError(error) { + captured.push(error); + events.push("capture-startup"); + }, + flush() { + events.push("flush"); + return rejectFlush + ? Promise.reject(new Error("synthetic flush failure")) + : Promise.resolve(true); + }, + reset() { + events.push("reset"); + }, + }), + ); + try { + await startAgentService({ + serviceName: "agent-selected-bind-failure-test", + agentId: "support", + entrypointUrl: pathToFileURL(resolve(rootDir, "main.ts")), + signals: [], + processTarget: { + env: {}, + on: () => {}, + off: () => {}, + exit: (code) => { + events.push(`exit:${code}`); + }, + }, + env: { + NODE_ENV: "test", + VERYFRONT_API_URL: "https://api.example.com", + VERYFRONT_AGENT_SERVICE_REGISTRATION: "disabled", + PORT: String(occupied.addr.port), + ALLOWED_ORIGINS: "https://studio.example.com", + }, + }); + assertEquals(events, ["capture-startup", "flush", "reset", "exit:1"]); + assertEquals(captured.length, 1); + const failure = captured[0]; + assert(failure instanceof Error); + assert(failure.message.includes("Address already in use")); + assert(failure.cause instanceof Error); + assertEquals(failure.cause.message, "Address already in use"); + } finally { + restore(); + Object.defineProperty(Deno, "version", { value: originalVersion }); + occupied.close(); + __resetLogRecordEmitterForTests(); + setApplicationErrorReporter(undefined); + } + }); + }); +} From 76a0a1753676f460c212b992efcc972b8c52032d Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 26 Oct 2026 17:37:01 +0100 Subject: [PATCH 15/22] Keep real startup listeners inside the integration boundary Move the selected-transport occupied-port regressions without changing telemetry or cleanup assertions. Native injected lifecycle fixtures remain unit tests. Constraint: Real server effects must not enter the semantic unit boundary. Rejected: Expand migration dispositions | conceals a real integration effect. Confidence: high Scope-risk: narrow Tested: Pinned Deno 2.9.7 hosted and integration tests, 66 tests and 5 steps; exact changed-base semantic audit; lint; mandatory uncommitted review. --- .../veryfront-cloud-agent-service.test.ts | 67 ----------- .../core/hosted-agent-startup-failure.test.ts | 111 ++++++++++++++++++ 2 files changed, 111 insertions(+), 67 deletions(-) create mode 100644 tests/integration/core/hosted-agent-startup-failure.test.ts diff --git a/src/agent/hosted/veryfront-cloud-agent-service.test.ts b/src/agent/hosted/veryfront-cloud-agent-service.test.ts index e127a4c6ad..871bacf144 100644 --- a/src/agent/hosted/veryfront-cloud-agent-service.test.ts +++ b/src/agent/hosted/veryfront-cloud-agent-service.test.ts @@ -2958,70 +2958,3 @@ it("hosted child explicit loader grants survive sibling spelling denials", () => assertEquals(tools, granted ? [granted] : []); } }); - -for (const rejectFlush of [false, true]) { - Deno.test(`selected Deno bind failure captures startup error and resets after flush rejection=${rejectFlush}`, async () => { - await withTempDir(async (rootDir) => { - writeMarkdownAgentDefinition(rootDir, "support"); - const occupied = Deno.listen({ hostname: "0.0.0.0", port: 0 }); - const originalVersion = Deno.version; - Object.defineProperty(Deno, "version", { value: { ...originalVersion, deno: "2.9.7" } }); - const events: string[] = []; - const captured: unknown[] = []; - const restore = veryfrontCloudAgentServiceInternals.setInitializeApplicationErrorsForTests( - () => ({ - enabled: true, - captureStartupError(error) { - captured.push(error); - events.push("capture-startup"); - }, - flush() { - events.push("flush"); - return rejectFlush - ? Promise.reject(new Error("synthetic flush failure")) - : Promise.resolve(true); - }, - reset() { - events.push("reset"); - }, - }), - ); - try { - await startAgentService({ - serviceName: "agent-selected-bind-failure-test", - agentId: "support", - entrypointUrl: pathToFileURL(resolve(rootDir, "main.ts")), - signals: [], - processTarget: { - env: {}, - on: () => {}, - off: () => {}, - exit: (code) => { - events.push(`exit:${code}`); - }, - }, - env: { - NODE_ENV: "test", - VERYFRONT_API_URL: "https://api.example.com", - VERYFRONT_AGENT_SERVICE_REGISTRATION: "disabled", - PORT: String(occupied.addr.port), - ALLOWED_ORIGINS: "https://studio.example.com", - }, - }); - assertEquals(events, ["capture-startup", "flush", "reset", "exit:1"]); - assertEquals(captured.length, 1); - const failure = captured[0]; - assert(failure instanceof Error); - assert(failure.message.includes("Address already in use")); - assert(failure.cause instanceof Error); - assertEquals(failure.cause.message, "Address already in use"); - } finally { - restore(); - Object.defineProperty(Deno, "version", { value: originalVersion }); - occupied.close(); - __resetLogRecordEmitterForTests(); - setApplicationErrorReporter(undefined); - } - }); - }); -} diff --git a/tests/integration/core/hosted-agent-startup-failure.test.ts b/tests/integration/core/hosted-agent-startup-failure.test.ts new file mode 100644 index 0000000000..2ac01fe624 --- /dev/null +++ b/tests/integration/core/hosted-agent-startup-failure.test.ts @@ -0,0 +1,111 @@ +import "#veryfront/schemas/_test-setup.ts"; +import { assert, assertEquals } from "#veryfront/testing/assert.ts"; +import { resolve } from "node:path"; +import { pathToFileURL } from "node:url"; +import { + startAgentService, + veryfrontCloudAgentServiceInternals, +} from "#veryfront/agent/hosted/veryfront-cloud-agent-service.ts"; +import { setApplicationErrorReporter } from "#veryfront/observability/application-errors.ts"; +import { __resetLogRecordEmitterForTests } from "#veryfront/utils/logger/logger.ts"; +import { register, unregister } from "#veryfront/extensions/contracts.ts"; +import { SandboxShellToolsProviderName } from "#veryfront/extensions/sandbox/index.ts"; +import { agentRegistry } from "#veryfront/agent/composition/index.ts"; +import { toolRegistryInternal } from "#veryfront/tool/registry.ts"; +import { stop as stopEsbuild } from "veryfront/extensions/bundler"; + +async function withTempDir(fn: (dir: string) => Promise): Promise { + const dir = Deno.makeTempDirSync(); + register(SandboxShellToolsProviderName, () => Promise.resolve({ tools: {} })); + try { + await fn(dir); + } finally { + await stopEsbuild(); + Deno.removeSync(dir, { recursive: true }); + agentRegistry.clearAll(); + toolRegistryInternal.clearAll(); + unregister(SandboxShellToolsProviderName); + } +} +function writeMarkdownAgentDefinition(rootDir: string, id: string): void { + const dir = resolve(rootDir, "agents"); + Deno.mkdirSync(dir, { recursive: true }); + Deno.writeTextFileSync( + resolve(dir, `${id}.md`), + `--- +name: Veryfront +model: openai/gpt-5.4 +max-steps: 12 +--- + +Help users build with Veryfront. +`, + ); +} + +for (const rejectFlush of [false, true]) { + Deno.test(`selected Deno bind failure captures startup error and resets after flush rejection=${rejectFlush}`, async () => { + await withTempDir(async (rootDir) => { + writeMarkdownAgentDefinition(rootDir, "support"); + const occupied = Deno.listen({ hostname: "0.0.0.0", port: 0 }); + const originalVersion = Deno.version; + Object.defineProperty(Deno, "version", { value: { ...originalVersion, deno: "2.9.7" } }); + const events: string[] = []; + const captured: unknown[] = []; + const restore = veryfrontCloudAgentServiceInternals.setInitializeApplicationErrorsForTests( + () => ({ + enabled: true, + captureStartupError(error) { + captured.push(error); + events.push("capture-startup"); + }, + flush() { + events.push("flush"); + return rejectFlush + ? Promise.reject(new Error("synthetic flush failure")) + : Promise.resolve(true); + }, + reset() { + events.push("reset"); + }, + }), + ); + try { + await startAgentService({ + serviceName: "agent-selected-bind-failure-test", + agentId: "support", + entrypointUrl: pathToFileURL(resolve(rootDir, "main.ts")), + signals: [], + processTarget: { + env: {}, + on: () => {}, + off: () => {}, + exit: (code) => { + events.push(`exit:${code}`); + }, + }, + env: { + NODE_ENV: "test", + VERYFRONT_API_URL: "https://api.example.com", + VERYFRONT_AGENT_SERVICE_REGISTRATION: "disabled", + PORT: String(occupied.addr.port), + ALLOWED_ORIGINS: "https://studio.example.com", + }, + }); + assertEquals(events, ["capture-startup", "flush", "reset", "exit:1"]); + assertEquals(captured.length, 1); + const failure = captured[0]; + assert(failure instanceof Error); + assert(failure.message.includes("Address already in use")); + assert(failure.cause instanceof Error); + assertEquals(failure.cause.message, "Address already in use"); + } finally { + restore(); + Object.defineProperty(Deno, "version", { value: originalVersion }); + occupied.close(); + __resetLogRecordEmitterForTests(); + setApplicationErrorReporter(undefined); + } + }); + }); +} From 720bfaf6318a9f5cb9b7970c09c8a4bc93ec4521 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 26 Oct 2026 17:57:11 +0100 Subject: [PATCH 16/22] Keep startup regression fixtures on the shared test primitives Reuse the shared temporary-directory helper while retaining agent registries and bundler cleanup. This removes copied primitive violations without widening lint baselines. Confidence: high Scope-risk: narrow Tested: Exact native composite lint chain and separate test typecheck; pinned integration tests; mandatory uncommitted review. --- .../core/hosted-agent-startup-failure.test.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/tests/integration/core/hosted-agent-startup-failure.test.ts b/tests/integration/core/hosted-agent-startup-failure.test.ts index 2ac01fe624..9457a0a6f9 100644 --- a/tests/integration/core/hosted-agent-startup-failure.test.ts +++ b/tests/integration/core/hosted-agent-startup-failure.test.ts @@ -12,16 +12,15 @@ import { register, unregister } from "#veryfront/extensions/contracts.ts"; import { SandboxShellToolsProviderName } from "#veryfront/extensions/sandbox/index.ts"; import { agentRegistry } from "#veryfront/agent/composition/index.ts"; import { toolRegistryInternal } from "#veryfront/tool/registry.ts"; +import { withTempDir } from "#veryfront/testing/deno-compat.ts"; import { stop as stopEsbuild } from "veryfront/extensions/bundler"; -async function withTempDir(fn: (dir: string) => Promise): Promise { - const dir = Deno.makeTempDirSync(); +async function withAgentFixture(fn: (dir: string) => Promise): Promise { register(SandboxShellToolsProviderName, () => Promise.resolve({ tools: {} })); try { - await fn(dir); + await withTempDir(fn); } finally { await stopEsbuild(); - Deno.removeSync(dir, { recursive: true }); agentRegistry.clearAll(); toolRegistryInternal.clearAll(); unregister(SandboxShellToolsProviderName); @@ -45,7 +44,7 @@ Help users build with Veryfront. for (const rejectFlush of [false, true]) { Deno.test(`selected Deno bind failure captures startup error and resets after flush rejection=${rejectFlush}`, async () => { - await withTempDir(async (rootDir) => { + await withAgentFixture(async (rootDir) => { writeMarkdownAgentDefinition(rootDir, "support"); const occupied = Deno.listen({ hostname: "0.0.0.0", port: 0 }); const originalVersion = Deno.version; From 092ad1c154a39e09c6388e23de52bc90362cd949 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 26 Oct 2026 18:15:23 +0100 Subject: [PATCH 17/22] Reject malformed upgrade authorities with an HTTP response Reuse the owned denial writer before invoking application handlers or loading WebSocket providers. Preserve ordinary denial cleanup and test HTTP1, h2c and upgrade authority rejection. Confidence: high Scope-risk: narrow Tested: Red wire regression, 32 passing wire tests and 2 steps; full changed-base lint:ci; mandatory uncommitted review. Not-tested: Changed-head native Linux CI and staging remain pending. --- .../adapters/runtime/node/http-server.ts | 54 +++++++++++-------- .../core/deno-http2-transport.test.ts | 26 +++++---- .../core/deno-node-http-upgrade.test.ts | 38 +++++++++++++ 3 files changed, 86 insertions(+), 32 deletions(-) diff --git a/src/platform/adapters/runtime/node/http-server.ts b/src/platform/adapters/runtime/node/http-server.ts index 0d111889db..362969cce2 100644 --- a/src/platform/adapters/runtime/node/http-server.ts +++ b/src/platform/adapters/runtime/node/http-server.ts @@ -816,6 +816,28 @@ async function createNodeServerInternal( failUpgrade(new DOMException("WebSocket client disconnected", "AbortError")); }; socket.once("close", onSocketClose); + const denyUpgrade = async (response: Response, denied: Error): Promise => { + rejectWebSocketUpgrade(requestId, denied); + const { ServerResponse } = await import("node:http"); + const denial = new ServerResponse(request); + denial.once("error", (error) => { + failUpgrade(error); + socket.destroy(); + }); + denial.shouldKeepAlive = false; + denial.assignSocket(socket as import("node:net").Socket); + denial.once("finish", () => socket.end()); + await createNodeRequestListener(() => response, hostname, dualProtocol)(request, denial); + transportSettled = true; + if (!requestAbort.signal.aborted) requestAbort.abort(denied); + clearTransportState(); + // A peer may leave a denial unread. Keep ownership until its socket + // actually closes so stop() can force-close this half-closed stream. + if (!socket.destroyed) { + rawUpgradeSockets.add(socket); + socket.once("close", () => rawUpgradeSockets.delete(socket)); + } + }; try { if (upgradesDisposed) { @@ -825,7 +847,16 @@ async function createNodeServerInternal( // On Node.js, upgrade events bypass the normal request callback. Run a // synthetic request through the handler as the authorization boundary. - const url = new URL(request.url ?? "/", `http://${request.headers.host ?? hostname}`); + let url: URL; + try { + url = new URL(request.url ?? "/", `http://${request.headers.host ?? hostname}`); + } catch { + await denyUpgrade( + new Response("Bad Request", { status: 400 }), + new Error("Malformed WebSocket request authority"), + ); + return; + } const headersRecord: Record = {}; for (const [key, value] of Object.entries(request.headers)) { if (typeof value === "string") headersRecord[key] = value; @@ -853,26 +884,7 @@ async function createNodeServerInternal( // Upgrade denials are ordinary HTTP responses. Reuse the same writer // as regular requests so status, cookies and streamed bodies survive. const denied = new Error("Request handler did not authorize a WebSocket upgrade"); - rejectWebSocketUpgrade(requestId, denied); - const { ServerResponse } = await import("node:http"); - const denial = new ServerResponse(request); - denial.once("error", (error) => { - failUpgrade(error); - socket.destroy(); - }); - denial.shouldKeepAlive = false; - denial.assignSocket(socket as import("node:net").Socket); - denial.once("finish", () => socket.end()); - await createNodeRequestListener(() => response, hostname, dualProtocol)(request, denial); - transportSettled = true; - if (!requestAbort.signal.aborted) requestAbort.abort(denied); - clearTransportState(); - // A peer may leave a denial unread. Keep ownership until its socket - // actually closes so stop() can force-close this half-closed stream. - if (!socket.destroyed) { - rawUpgradeSockets.add(socket); - socket.once("close", () => rawUpgradeSockets.delete(socket)); - } + await denyUpgrade(response, denied); return; } if (upgradesDisposed || !isWebSocketUpgradeResponse(response)) { diff --git a/tests/integration/core/deno-http2-transport.test.ts b/tests/integration/core/deno-http2-transport.test.ts index 565f68c54d..db957c0c2f 100644 --- a/tests/integration/core/deno-http2-transport.test.ts +++ b/tests/integration/core/deno-http2-transport.test.ts @@ -398,17 +398,21 @@ it("Deno transport rejects malformed HTTP1 and h2 authorities before handlers", return new Response("unexpected"); }, { hostname: "127.0.0.1", port: 0, nodeWebSocketServerProvider: null }); try { - const socket = await Deno.connect({ hostname: "127.0.0.1", port: server.addr.port }); - try { - await socket.write( - new TextEncoder().encode("GET / HTTP/1.1\r\nHost: [\r\nConnection: close\r\n\r\n"), - ); - const bytes = new Uint8Array(1024); - const count = await bounded(socket.read(bytes)); - assert(count !== null); - assert(new TextDecoder().decode(bytes.subarray(0, count)).startsWith("HTTP/1.1 400")); - } finally { - socket.close(); + for (const connection of ["close", "Upgrade\r\nUpgrade: websocket"]) { + const socket = await Deno.connect({ hostname: "127.0.0.1", port: server.addr.port }); + try { + await socket.write( + new TextEncoder().encode( + `GET / HTTP/1.1\r\nHost: [\r\nConnection: ${connection}\r\n\r\n`, + ), + ); + const bytes = new Uint8Array(1024); + const count = await bounded(socket.read(bytes)); + assert(count !== null); + assert(new TextDecoder().decode(bytes.subarray(0, count)).startsWith("HTTP/1.1 400")); + } finally { + socket.close(); + } } const client = connect(`http://127.0.0.1:${server.addr.port}`); try { diff --git a/tests/integration/core/deno-node-http-upgrade.test.ts b/tests/integration/core/deno-node-http-upgrade.test.ts index 7d5d8af69f..60fed723f4 100644 --- a/tests/integration/core/deno-node-http-upgrade.test.ts +++ b/tests/integration/core/deno-node-http-upgrade.test.ts @@ -246,3 +246,41 @@ it("Deno lazy provider cannot create a server after listener shutdown", async () await server.stop(); } }); + +it("malformed upgrade authority returns 400 before handler or default provider loading", async () => { + let calls = 0; + let loads = 0; + const server = await createDenoNodeServerWithStartupOwner( + () => { + calls++; + return new Response("unexpected"); + }, + { hostname: "127.0.0.1", port: 0 }, + undefined, + () => { + loads++; + return Promise.resolve(WsNodeWebSocketServerProvider); + }, + ); + const connection = await Deno.connect({ hostname: "127.0.0.1", port: server.addr.port }); + try { + await connection.write(new TextEncoder().encode( + "GET / HTTP/1.1\r\nHost: [\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\n", + )); + const chunks: string[] = []; + while (true) { + const bytes = new Uint8Array(1024); + const count = await connection.read(bytes); + if (count === null) break; + chunks.push(new TextDecoder().decode(bytes.subarray(0, count))); + } + const response = chunks.join(""); + assertStringIncludes(response, "HTTP/1.1 400 Bad Request"); + assertStringIncludes(response, "Bad Request"); + assertEquals(calls, 0); + assertEquals(loads, 0); + } finally { + connection.close(); + await server.stop(); + } +}); From a970198fbe571e7054008ee742a234fd9bd5bf2f Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 26 Oct 2026 18:25:53 +0100 Subject: [PATCH 18/22] Expose queued WebSocket payloads to application backpressure Count bytes owned by the Deno facade during Blob conversion and ordered sends, transferring ownership when the transport accepts them. Release accounting on failure or closure. Native Deno 2.9.7 omits these queued bytes; retain that observation separately while honoring the selected facade DOM contract. Constraint: Preserve the existing native closing and closed zero behavior without redesigning the state machine. Confidence: high Scope-risk: narrow Tested: Four deterministic red-to-green cases; 36 focused tests and 2 steps; full changed-base lint:ci; mandatory uncommitted review. --- .../runtime/deno/node-websocket.test.ts | 85 +++++++++++++++++++ .../adapters/runtime/deno/node-websocket.ts | 15 +++- .../core/deno-websocket-dom.test.ts | 4 +- 3 files changed, 102 insertions(+), 2 deletions(-) create mode 100644 src/platform/adapters/runtime/deno/node-websocket.test.ts diff --git a/src/platform/adapters/runtime/deno/node-websocket.test.ts b/src/platform/adapters/runtime/deno/node-websocket.test.ts new file mode 100644 index 0000000000..384276139d --- /dev/null +++ b/src/platform/adapters/runtime/deno/node-websocket.test.ts @@ -0,0 +1,85 @@ +import { assertEquals } from "#veryfront/testing/assert.ts"; +import { DenoNodeWebSocket } from "./node-websocket.ts"; +import { NodeWebSocket } from "../node/websocket-adapter.ts"; + +class ControlledTransport extends NodeWebSocket { + constructor() { + super(); + this.readyState = 1; + } + amount = 0; + failSend = false; + readonly sent: Array = []; + override _getBufferedAmount(): number { + return this.amount; + } + override send(data: string | ArrayBuffer): void { + if (this.failSend) throw new Error("synthetic send failure"); + this.sent.push(data); + this.amount += typeof data === "string" + ? new TextEncoder().encode(data).length + : data.byteLength; + } + override close(): void { + this.emitClose(1000, "", true); + } +} + +function heldBlob() { + const conversion = Promise.withResolvers(); + class HeldBlob extends Blob { + override slice(): Blob { + return this; + } + override arrayBuffer(): Promise { + return conversion.promise; + } + } + return { blob: new HeldBlob([new Uint8Array([1, 2])]), conversion }; +} + +Deno.test("Deno facade counts every queued payload and transfers ownership without double counting", async () => { + const transport = new ControlledTransport(); + const socket = new DenoNodeWebSocket(transport); + const { blob, conversion } = heldBlob(); + socket.send(blob); + assertEquals(socket.bufferedAmount, 2); + socket.send("é"); + socket.send(new Uint8Array([3, 4, 5]).buffer); + socket.send(new Uint8Array([6])); + socket.send(new SharedArrayBuffer(4)); + socket.send(new Uint8Array(new SharedArrayBuffer(5))); + assertEquals(socket.bufferedAmount, 17); + assertEquals(transport.sent.length, 0); + conversion.resolve(new Uint8Array([1, 2]).buffer); + await conversion.promise; + await Promise.resolve(); + assertEquals(transport.sent.length, 6); + assertEquals(socket.bufferedAmount, 17); + transport.amount = 0; + assertEquals(socket.bufferedAmount, 0); + socket.close(); + assertEquals(socket.bufferedAmount, 0); +}); + +for (const failure of ["conversion", "send", "close"] as const) { + Deno.test(`Deno facade releases queued byte accounting on ${failure}`, async () => { + const transport = new ControlledTransport(); + const socket = new DenoNodeWebSocket(transport); + const { blob, conversion } = heldBlob(); + const errors: number[] = []; + socket.onerror = () => errors.push(socket.bufferedAmount); + socket.send(blob); + socket.send("queued"); + assertEquals(socket.bufferedAmount, 8); + if (failure === "close") transport.close(); + if (failure === "send") transport.failSend = true; + if (failure === "conversion") conversion.reject(new Error("synthetic conversion failure")); + else conversion.resolve(new Uint8Array([1, 2]).buffer); + await conversion.promise.catch(() => {}); + await Promise.resolve(); + assertEquals(socket.bufferedAmount, 0); + assertEquals(transport.sent.length, 0); + assertEquals(errors, failure === "close" ? [] : [0]); + }); +} diff --git a/src/platform/adapters/runtime/deno/node-websocket.ts b/src/platform/adapters/runtime/deno/node-websocket.ts index ccc421ff5a..2b94105c25 100644 --- a/src/platform/adapters/runtime/deno/node-websocket.ts +++ b/src/platform/adapters/runtime/deno/node-websocket.ts @@ -17,6 +17,7 @@ export class DenoNodeWebSocket extends EventTarget implements WebSocket { private binary: BinaryType = "arraybuffer"; private readonly sendQueue: Array = []; private sending = false; + private pendingSendBytes = 0; private queuedClose?: { code?: number; reason?: string }; private openHandler: WebSocket["onopen"] = null; @@ -73,6 +74,7 @@ export class DenoNodeWebSocket extends EventTarget implements WebSocket { inner.addEventListener("open", () => this.deliver(new Event("open"))); inner.addEventListener("close", (event) => { this.sendQueue.length = 0; + this.pendingSendBytes = 0; const close = event as CloseEvent; this.deliver( new CloseEvent("close", { @@ -103,7 +105,9 @@ export class DenoNodeWebSocket extends EventTarget implements WebSocket { throw new TypeError("Invalid WebSocket transport state"); } get bufferedAmount(): number { - return this.readyState === this.OPEN ? this.inner._getBufferedAmount() : 0; + return this.readyState === this.OPEN + ? this.pendingSendBytes + this.inner._getBufferedAmount() + : 0; } get binaryType(): BinaryType { return this.binary; @@ -129,6 +133,7 @@ export class DenoNodeWebSocket extends EventTarget implements WebSocket { this.inner.send(normalized); return; } + this.pendingSendBytes += this.sendByteLength(normalized); this.sendQueue.push(normalized); if (!this.sending) void this.flushSends(); } @@ -172,12 +177,15 @@ export class DenoNodeWebSocket extends EventTarget implements WebSocket { const item = this.sendQueue.shift()!; const data = item instanceof Blob ? await item.slice().arrayBuffer() : item; if (this.inner.readyState !== this.OPEN) break; + this.pendingSendBytes -= this.sendByteLength(item); this.inner.send(data); } } catch (error) { + this.pendingSendBytes = 0; this.inner._emitError(error instanceof Error ? error : new Error(String(error))); } finally { this.sendQueue.length = 0; + this.pendingSendBytes = 0; this.sending = false; const close = this.queuedClose; this.queuedClose = undefined; @@ -191,6 +199,11 @@ export class DenoNodeWebSocket extends EventTarget implements WebSocket { } } + private sendByteLength(data: string | ArrayBuffer | Blob): number { + if (typeof data === "string") return new TextEncoder().encode(data).byteLength; + return data instanceof Blob ? data.size : data.byteLength; + } + private updateHandler( type: string, previous: boolean, diff --git a/tests/integration/core/deno-websocket-dom.test.ts b/tests/integration/core/deno-websocket-dom.test.ts index f0065d861a..d64946a760 100644 --- a/tests/integration/core/deno-websocket-dom.test.ts +++ b/tests/integration/core/deno-websocket-dom.test.ts @@ -204,7 +204,9 @@ for (const transport of ["native", "selected"] as const) { : runtime.server.upgradeWebSocket(request, { idleTimeout: 0 }); upgrade.socket.onopen = () => { upgrade.socket.send(new Blob(["before-close"])); - assertEquals(upgrade.socket.bufferedAmount, 0); + // Native Deno 2.9.7 omits its JS Blob queue; the selected facade counts + // owned unsent bytes as required by the DOM bufferedAmount contract. + assertEquals(upgrade.socket.bufferedAmount, transport === "native" ? 0 : 12); if (transport === "selected") { assertThrows(() => upgrade.socket.close(1005), TypeError); assertEquals(upgrade.socket.readyState, WebSocket.OPEN); From 2f0fabc3da82057fcc7d06bed3fad8b988abf90e Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 26 Oct 2026 22:17:12 +0100 Subject: [PATCH 19/22] Preserve transport ownership through public WebSocket handlers Both public createHandler wrappers convert ordinary responses to the native class. Preserve the exact registered upgrade signal before that conversion so DenoAdapter can attach the authorized socket instead of denying it. Confidence: high Scope-risk: narrow Tested: Real public development and production WebSocket connected frames fail before fix and pass after; pinned Deno 2.9.7 nine tests/nine steps; typecheck; lint; boundaries; required uncommitted and base Codex reviews Not-tested: No external endpoint or paid model run --- src/server/index.ts | 3 +- .../server/public-handler-websocket.test.ts | 62 +++++++++++++++++++ 2 files changed, 64 insertions(+), 1 deletion(-) create mode 100644 tests/integration/server/public-handler-websocket.test.ts diff --git a/src/server/index.ts b/src/server/index.ts index 9c4da92d28..c5429b3ebc 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -226,7 +226,8 @@ const _nativeGlobal = new Function("return this")() as typeof globalThis; const _NativeResponse: typeof Response = _nativeGlobal.Response; function toNativeResponse(res: Response): Response { - if (res instanceof _NativeResponse) return res; + // The serving transport must receive its exact registered upgrade signal. + if (isWebSocketUpgradeResponse(res) || res instanceof _NativeResponse) return res; // TS narrows to `never` after the instanceof check because it can't see the // runtime class divergence between DNT's polyfill Response and native Response. const src = res as Response; diff --git a/tests/integration/server/public-handler-websocket.test.ts b/tests/integration/server/public-handler-websocket.test.ts new file mode 100644 index 0000000000..ab7871c56a --- /dev/null +++ b/tests/integration/server/public-handler-websocket.test.ts @@ -0,0 +1,62 @@ +import { assertEquals } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import { createHandler } from "veryfront/server"; +import { DenoAdapter } from "#veryfront/platform/adapters/runtime/deno/adapter.ts"; +import { withTestContext } from "../../_helpers/context.ts"; + +// Both public wrappers must preserve the transport-owned upgrade object. +describe("public handler Deno WebSocket transport", { + sanitizeResources: false, + sanitizeOps: false, +}, () => { + for (const mode of ["development", "production"] as const) { + it(`upgrades through the ${mode} public handler`, async () => { + await withTestContext(`public-handler-websocket-${mode}`, async (context) => { + const handler = await createHandler({ + mode, + projectDir: context.projectDir, + port: await context.allocatePort(), + }); + const adapter = new DenoAdapter(); + const server = await adapter.serve(handler, { hostname: "127.0.0.1", port: 0 }); + const socket = new WebSocket(`ws://127.0.0.1:${server.addr.port}/_ws`); + try { + await new Promise((resolve, reject) => { + const timeout = setTimeout( + () => reject(new Error("WebSocket handshake timed out")), + 5000, + ); + socket.addEventListener("message", (event) => { + clearTimeout(timeout); + try { + assertEquals(JSON.parse(event.data), { type: "connected" }); + resolve(); + } catch (error) { + reject(error); + } + }, { once: true }); + socket.addEventListener("close", () => { + clearTimeout(timeout); + reject(new Error("WebSocket closed before the connected frame")); + }, { once: true }); + socket.addEventListener("error", () => { + clearTimeout(timeout); + reject(new Error("Public handler WebSocket handshake failed")); + }, { once: true }); + }); + assertEquals(socket.readyState, WebSocket.OPEN); + const closed = new Promise((resolve) => + socket.addEventListener("close", () => resolve(), { once: true }) + ); + socket.close(); + await closed; + } finally { + if (socket.readyState < WebSocket.CLOSING) socket.close(); + await server.stop(); + await handler.dispose(); + await adapter.shutdown(); + } + }); + }); + } +}); From e5c6c242ca05cd0acbe20907101d2fd26d11f659 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 26 Oct 2026 22:31:37 +0100 Subject: [PATCH 20/22] Keep public-handler WebSocket regressions under leak detection The integration fixture cleans up its sockets, server, handler and adapter. Remove unnecessary sanitizer opt-outs so the new test satisfies the existing CI baseline while exercising both public handler modes with leak checks. Constraint: Do not expand sanitizer exception baselines Confidence: high Scope-risk: narrow Tested: Pinned Deno public-handler WebSocket integration with default sanitizers --- tests/integration/server/public-handler-websocket.test.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/tests/integration/server/public-handler-websocket.test.ts b/tests/integration/server/public-handler-websocket.test.ts index ab7871c56a..21d53aefc9 100644 --- a/tests/integration/server/public-handler-websocket.test.ts +++ b/tests/integration/server/public-handler-websocket.test.ts @@ -5,10 +5,7 @@ import { DenoAdapter } from "#veryfront/platform/adapters/runtime/deno/adapter.t import { withTestContext } from "../../_helpers/context.ts"; // Both public wrappers must preserve the transport-owned upgrade object. -describe("public handler Deno WebSocket transport", { - sanitizeResources: false, - sanitizeOps: false, -}, () => { +describe("public handler Deno WebSocket transport", () => { for (const mode of ["development", "production"] as const) { it(`upgrades through the ${mode} public handler`, async () => { await withTestContext(`public-handler-websocket-${mode}`, async (context) => { From 89dcb0dd56952d94092bd0de47ecda0b261a6c7a Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Thu, 29 Oct 2026 14:18:33 +0100 Subject: [PATCH 21/22] Preserve Deno transport permissions and provider policy --- deno.json | 2 +- extensions/ext-node-websocket-ws/deno.json | 9 +- .../scripts/prebundle.ts | 152 ++++++++++++++++++ .../scripts/ws-source-integrity.json | 7 + extensions/ext-node-websocket-ws/src/index.ts | 18 +-- .../src/package-boundary.test.ts | 4 + .../src/portable-runtime.generated.js | 39 +++++ .../ext-node-websocket-ws/src/portable.ts | 6 + .../ext-node-websocket-ws/src/provider.ts | 23 +++ scripts/build/run-generate.test.ts | 1 + scripts/build/run-generate.ts | 7 + .../adapters/runtime/deno/http-transport.ts | 7 +- .../adapters/runtime/node/http-server.ts | 19 +++ .../production-server-bootstrap.test.ts | 26 +++ src/server/production-server.ts | 4 +- .../core/deno-http-cancellation.test.ts | 39 +++++ .../core/deno-http-permissions.test.ts | 38 +++++ .../core/deno-http2-transport.test.ts | 25 +++ 18 files changed, 401 insertions(+), 25 deletions(-) create mode 100644 extensions/ext-node-websocket-ws/scripts/prebundle.ts create mode 100644 extensions/ext-node-websocket-ws/scripts/ws-source-integrity.json create mode 100644 extensions/ext-node-websocket-ws/src/portable-runtime.generated.js create mode 100644 extensions/ext-node-websocket-ws/src/portable.ts create mode 100644 extensions/ext-node-websocket-ws/src/provider.ts diff --git a/deno.json b/deno.json index 40d4c51eaf..6f65d98ed1 100644 --- a/deno.json +++ b/deno.json @@ -521,7 +521,7 @@ "generate:force": "deno run -A scripts/build/run-generate.ts --force", "generate:dev-ui": "deno run -A extensions/ext-dev-ui-react/scripts/generate-styles.ts && deno run -A extensions/ext-dev-ui-react/scripts/prebundle.ts", "generate:dev-ui:check": "deno run -A extensions/ext-dev-ui-react/scripts/generate-styles.ts --check && deno run -A extensions/ext-dev-ui-react/scripts/prebundle.ts --check", - "generate:manifests:check": "deno run --frozen -A extensions/ext-yaml/scripts/prebundle.ts --check && deno run -A scripts/build/generate-embedded-npm-packages.ts --check && deno run -A scripts/build/generate-templates-manifest.ts --check && deno task generate:dev-ui:check && deno run -A scripts/build/prebundle-hydration-runtime.ts --check && deno run -A scripts/build/prebundle-client-scripts.ts --check && deno run -A scripts/build/prebundle-bridge.ts --check && deno run -A scripts/build/prebundle-rsc-scripts.ts --check && deno run -A scripts/build/generate-core-skills.ts --check", + "generate:manifests:check": "deno run --frozen -A extensions/ext-node-websocket-ws/scripts/prebundle.ts --check && deno run --frozen -A extensions/ext-yaml/scripts/prebundle.ts --check && deno run -A scripts/build/generate-embedded-npm-packages.ts --check && deno run -A scripts/build/generate-templates-manifest.ts --check && deno task generate:dev-ui:check && deno run -A scripts/build/prebundle-hydration-runtime.ts --check && deno run -A scripts/build/prebundle-client-scripts.ts --check && deno run -A scripts/build/prebundle-bridge.ts --check && deno run -A scripts/build/prebundle-rsc-scripts.ts --check && deno run -A scripts/build/generate-core-skills.ts --check", "start": "deno task generate && deno run --allow-read --allow-write --allow-net --allow-env --allow-run --allow-sys --unstable-worker-options --unstable-net cli/main.ts", "start:headless": "deno task generate && deno run --allow-read --allow-write --allow-net --allow-env --allow-run --allow-sys --unstable-worker-options --unstable-net cli/main.ts --headless", "proxy": "deno task generate && deno run --allow-read --allow-write --allow-net --allow-env --allow-run --allow-sys --unstable-worker-options --unstable-net cli/main.ts serve --mode=proxy", diff --git a/extensions/ext-node-websocket-ws/deno.json b/extensions/ext-node-websocket-ws/deno.json index 838b59b8b4..02fa76ba0a 100644 --- a/extensions/ext-node-websocket-ws/deno.json +++ b/extensions/ext-node-websocket-ws/deno.json @@ -1,7 +1,10 @@ { "name": "@veryfront/ext-node-websocket-ws", "version": "0.1.0", - "exports": "./src/index.ts", + "exports": { + ".": "./src/index.ts", + "./portable": "./src/portable.ts" + }, "veryfront": { "extension": true, "activation": "auto", @@ -29,6 +32,8 @@ }, "tasks": { "check": "deno check src/index.ts src/*.test.ts", - "test": "deno test --allow-env=WS_NO_BUFFER_UTIL,WS_NO_UTF_8_VALIDATE --allow-read=deno.json src/" + "test": "deno test --allow-env=WS_NO_BUFFER_UTIL,WS_NO_UTF_8_VALIDATE --allow-read=deno.json src/", + "generate": "deno run --frozen -A scripts/prebundle.ts", + "generate:check": "deno run --frozen -A scripts/prebundle.ts --check" } } diff --git a/extensions/ext-node-websocket-ws/scripts/prebundle.ts b/extensions/ext-node-websocket-ws/scripts/prebundle.ts new file mode 100644 index 0000000000..575489ac81 --- /dev/null +++ b/extensions/ext-node-websocket-ws/scripts/prebundle.ts @@ -0,0 +1,152 @@ +#!/usr/bin/env -S deno run --frozen -A +/** Bundle the pinned ws implementation with optional native accelerators disabled. */ +import { fileURLToPath } from "node:url"; +import { build, stop } from "npm:esbuild@0.28.1"; +import sourceIntegrity from "./ws-source-integrity.json" with { type: "json" }; +const checkOnly = Deno.args.length === 1 && Deno.args[0] === "--check"; +if (Deno.args.length !== (checkOnly ? 1 : 0)) throw new TypeError("Usage: prebundle.ts [--check]"); +const root = new URL("../../../", import.meta.url); +const resolved = new URL(import.meta.resolve("ws")); +if (resolved.protocol !== "file:") { + throw new TypeError("Expected a locally resolved frozen WebSocket package"); +} +const packageRoot = new URL("./", resolved); +async function captureSourceGraph(): Promise< + { digest: string; license: string; manifest: string } +> { + const files = new Map>(); + const readFile = async (relative: string): Promise => { + const url = new URL(relative, packageRoot); + if (!(await Deno.lstat(url)).isFile) { + throw new Error("WebSocket source inputs must be regular files"); + } + files.set(relative, await Deno.readFile(url)); + }; + const walk = async (relative: string): Promise => { + for await (const entry of Deno.readDir(new URL(relative, packageRoot))) { + const path = relative + entry.name; + if (entry.isDirectory) await walk(path + "/"); + else if (entry.isFile) await readFile(path); + else throw new Error("WebSocket source inputs must not contain symbolic links"); + } + }; + await readFile("package.json"); + await readFile("LICENSE"); + await readFile("index.js"); + await readFile("wrapper.mjs"); + await walk("lib/"); + const rows: string[] = []; + // Integrity rows use code-unit ordering, independent of the host locale. + const paths = [...files.keys()].sort((left, right) => left < right ? -1 : left > right ? 1 : 0); + for (const path of paths) { + const bytes = files.get(path)!; + const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", bytes)).toHex(); + rows.push(path + String.fromCharCode(0) + digest + String.fromCharCode(10)); + } + if (files.size !== sourceIntegrity.sourceFileCount) { + throw new Error("WebSocket source file count differs from the qualified package"); + } + const digest = new Uint8Array( + await crypto.subtle.digest("SHA-256", new TextEncoder().encode(rows.join(""))), + ).toHex(); + return { + digest, + license: new TextDecoder("utf-8", { fatal: true }).decode(files.get("LICENSE")!), + manifest: new TextDecoder("utf-8", { fatal: true }).decode(files.get("package.json")!), + }; +} +const source = await captureSourceGraph(); +if (source.digest !== sourceIntegrity.sourceGraphSha256) { + throw new Error("WebSocket source differs from the qualified published archive"); +} +const manifest = JSON.parse(source.manifest) as { + name: string; + version: string; + main: string; +}; +if ( + manifest.name !== sourceIntegrity.package || manifest.version !== sourceIntegrity.version || + manifest.main !== "index.js" +) { + throw new Error("The WebSocket package does not expose the qualified entry"); +} +const lock = JSON.parse(await Deno.readTextFile(new URL("deno.lock", root))) as { + npm: Record; +}; +const identity = `${manifest.name}@${manifest.version}`; +const integrity = lock.npm[identity]?.integrity; +if (integrity !== sourceIntegrity.integrity) { + throw new Error("WebSocket repository lock does not match the qualified package integrity"); +} +const entry = new URL("wrapper.mjs", packageRoot); +let bundle: string; +try { + const result = await build({ + entryPoints: [fileURLToPath(entry)], + bundle: true, + platform: "node", + format: "esm", + target: "es2022", + minify: true, + write: false, + legalComments: "inline", + define: { + "process.env.WS_NO_BUFFER_UTIL": "true", + "process.env.WS_NO_UTF_8_VALIDATE": "true", + }, + banner: { + js: + 'import { createRequire } from "node:module"; const require = createRequire(import.meta.url);', + }, + }); + if (result.outputFiles.length !== 1) throw new Error("Expected one WebSocket runtime bundle"); + bundle = result.outputFiles[0]!.text; +} finally { + await stop(); +} +const bundleBytes = new TextEncoder().encode(bundle); +if (bundle.trim().length === 0 || bundleBytes.length > 128 * 1024) { + throw new RangeError("WebSocket runtime must be nonempty and at most 128 KiB"); +} +if (/process\.env|Deno\.env|bufferutil|utf-8-validate/.test(bundle)) { + throw new Error("WebSocket runtime must not access host env or optional native modules"); +} +const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", bundleBytes)).toHex(); +if ((await captureSourceGraph()).digest !== source.digest) { + throw new Error("WebSocket source changed during bundling"); +} +const license = source.license; +if (license.includes("*/")) throw new Error("Unexpected license comment delimiter"); +const generated = + `// deno-fmt-ignore-file\n// deno-lint-ignore-file -- Verified generated upstream WebSocket bundle.\n/**\n * Official ${identity}, with optional native accelerators disabled at build time.\n * Package integrity: ${integrity}\n * Upstream source SHA-256: ${source.digest} + * Bundle SHA-256: ${digest}\n * Generated by extensions/ext-node-websocket-ws/scripts/prebundle.ts.\n *\n${ + license.split("\n").map((line) => line.length > 0 ? " * " + line.trimEnd() : " *").join("\n") + }\n */\n${bundle}`; +const output = new URL("../src/portable-runtime.generated.js", import.meta.url); +if (checkOnly) { + if (await Deno.readTextFile(output) !== generated) { + throw new Error("WebSocket portable bundle is stale; run its prebundle generator"); + } +} else { + const temporary = await Deno.makeTempFile({ + dir: fileURLToPath(new URL("../src/", import.meta.url)), + prefix: ".websocket-runtime-", + suffix: ".tmp", + }); + try { + await Deno.writeTextFile(temporary, generated); + await Deno.rename(temporary, output); + } catch (error) { + try { + await Deno.remove(temporary); + } catch (cleanupError) { + if (!(cleanupError instanceof Deno.errors.NotFound)) { + throw new AggregateError([error, cleanupError], "WebSocket generation and cleanup failed"); + } + } + throw error; + } + console.log( + `WebSocket portable runtime ${identity}: ${bundleBytes.length} bytes, sha256:${digest}`, + ); +} diff --git a/extensions/ext-node-websocket-ws/scripts/ws-source-integrity.json b/extensions/ext-node-websocket-ws/scripts/ws-source-integrity.json new file mode 100644 index 0000000000..c10ff619ac --- /dev/null +++ b/extensions/ext-node-websocket-ws/scripts/ws-source-integrity.json @@ -0,0 +1,7 @@ +{ + "package": "ws", + "version": "8.21.1", + "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==", + "sourceGraphSha256": "42934414004ba40e5be9f6b82aed4870b363e493bbbf5655d057c08bb02ac94e", + "sourceFileCount": 17 +} diff --git a/extensions/ext-node-websocket-ws/src/index.ts b/extensions/ext-node-websocket-ws/src/index.ts index b15dc5b993..6114e4bb29 100644 --- a/extensions/ext-node-websocket-ws/src/index.ts +++ b/extensions/ext-node-websocket-ws/src/index.ts @@ -1,24 +1,12 @@ /** Default `ws` implementation of Veryfront's Node WebSocket contract. */ import type { ExtensionFactory } from "veryfront/extensions"; -import { - captureNodeWebSocketServer, - createNodeWebSocketServerProvider, - NodeWebSocketServerProviderName, -} from "veryfront/extensions/websocket"; +import { NodeWebSocketServerProviderName } from "veryfront/extensions/websocket"; // @ts-types="@types/ws" import { WebSocket, WebSocketServer } from "ws"; +import { createWsProvider } from "./provider.ts"; -export const WsNodeWebSocketServerProvider = createNodeWebSocketServerProvider( - (options) => captureNodeWebSocketServer(new WebSocketServer(options)), - { - supportsIdleTimeout: true, - getBufferedAmount(connection) { - if (!(connection instanceof WebSocket)) throw new TypeError("Unknown ws connection"); - return connection.bufferedAmount; - }, - }, -); +export const WsNodeWebSocketServerProvider = createWsProvider(WebSocketServer, WebSocket); /** Create the standard Node.js `ws` transport extension. */ export const extNodeWebSocketWs: ExtensionFactory = () => { diff --git a/extensions/ext-node-websocket-ws/src/package-boundary.test.ts b/extensions/ext-node-websocket-ws/src/package-boundary.test.ts index 6499070663..e525a13a01 100644 --- a/extensions/ext-node-websocket-ws/src/package-boundary.test.ts +++ b/extensions/ext-node-websocket-ws/src/package-boundary.test.ts @@ -18,6 +18,10 @@ Deno.test("ws extension is auto-activated and owns every third-party dependency" }]); assertEquals(manifest.imports.ws, "npm:ws@8.21.1"); assertEquals(manifest.imports["@types/ws"], "npm:@types/ws@8.18.1"); + assertEquals(manifest.exports, { + ".": "./src/index.ts", + "./portable": "./src/portable.ts", + }); }); Deno.test("ws contracts stay behind the dedicated Node-only package subpath", () => { diff --git a/extensions/ext-node-websocket-ws/src/portable-runtime.generated.js b/extensions/ext-node-websocket-ws/src/portable-runtime.generated.js new file mode 100644 index 0000000000..ffaf5b3dd9 --- /dev/null +++ b/extensions/ext-node-websocket-ws/src/portable-runtime.generated.js @@ -0,0 +1,39 @@ +// deno-fmt-ignore-file +// deno-lint-ignore-file -- Verified generated upstream WebSocket bundle. +/** + * Official ws@8.21.1, with optional native accelerators disabled at build time. + * Package integrity: sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw== + * Upstream source SHA-256: 42934414004ba40e5be9f6b82aed4870b363e493bbbf5655d057c08bb02ac94e + * Bundle SHA-256: d85325b139a055afa6f1f9dd6ac94179461fdf264a2f548220037ed6f92c2fbb + * Generated by extensions/ext-node-websocket-ws/scripts/prebundle.ts. + * + * Copyright (c) 2011 Einar Otto Stangvik + * Copyright (c) 2013 Arnout Kazemier and contributors + * Copyright (c) 2016 Luigi Pinca and contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + * + */ +import { createRequire } from "node:module"; const require = createRequire(import.meta.url); +var Ht=Object.create;var qe=Object.defineProperty;var Yt=Object.getOwnPropertyDescriptor;var Kt=Object.getOwnPropertyNames;var Xt=Object.getPrototypeOf,Zt=Object.prototype.hasOwnProperty;var g=(t=>typeof require<"u"?require:typeof Proxy<"u"?new Proxy(t,{get:(e,s)=>(typeof require<"u"?require:e)[s]}):t)(function(t){if(typeof require<"u")return require.apply(this,arguments);throw Error('Dynamic require of "'+t+'" is not supported')});var S=(t,e)=>()=>{try{return e||t((e={exports:{}}).exports,e),e.exports}catch(s){throw e=0,s}};var Qt=(t,e,s,r)=>{if(e&&typeof e=="object"||typeof e=="function")for(let i of Kt(e))!Zt.call(t,i)&&i!==s&&qe(t,i,{get:()=>e[i],enumerable:!(r=Yt(e,i))||r.enumerable});return t};var N=(t,e,s)=>(s=t!=null?Ht(Xt(t)):{},Qt(e||!t||!t.__esModule?qe(s,"default",{value:t,enumerable:!0}):s,t));var O=S((Lr,je)=>{"use strict";var Ge=["nodebuffer","arraybuffer","fragments"],$e=typeof Blob<"u";$e&&Ge.push("blob");je.exports={BINARY_TYPES:Ge,CLOSE_TIMEOUT:3e4,EMPTY_BUFFER:Buffer.alloc(0),GUID:"258EAFA5-E914-47DA-95CA-C5AB0DC85B11",hasBlob:$e,kForOnEventAttribute:Symbol("kIsForOnEventAttribute"),kListener:Symbol("kListener"),kStatusCode:Symbol("status-code"),kWebSocket:Symbol("websocket"),NOOP:()=>{}}});var X=S((Nr,Ve)=>{"use strict";var{EMPTY_BUFFER:Jt}=O(),ge=Buffer[Symbol.species];function es(t,e){if(t.length===0)return Jt;if(t.length===1)return t[0];let s=Buffer.allocUnsafe(e),r=0;for(let i=0;i{"use strict";var ze=Symbol("kDone"),xe=Symbol("kRun"),Se=class{constructor(e){this[ze]=()=>{this.pending--,this[xe]()},this.concurrency=e||1/0,this.jobs=[],this.pending=0}add(e){this.jobs.push(e),this[xe]()}[xe](){if(this.pending!==this.concurrency&&this.jobs.length){let e=this.jobs.shift();this.pending++,e(this[ze])}}};He.exports=Se});var G=S((Br,Qe)=>{"use strict";var Z=g("zlib"),Ke=X(),is=Ye(),{kStatusCode:Xe}=O(),ns=Buffer[Symbol.species],os=Buffer.from([0,0,255,255]),ie=Symbol("permessage-deflate"),T=Symbol("total-length"),W=Symbol("callback"),P=Symbol("buffers"),q=Symbol("error"),re,Ee=class{constructor(e){if(this._options=e||{},this._threshold=this._options.threshold!==void 0?this._options.threshold:1024,this._maxPayload=this._options.maxPayload|0,this._isServer=!!this._options.isServer,this._deflate=null,this._inflate=null,this.params=null,!re){let s=this._options.concurrencyLimit!==void 0?this._options.concurrencyLimit:10;re=new is(s)}}static get extensionName(){return"permessage-deflate"}offer(){let e={};return this._options.serverNoContextTakeover&&(e.server_no_context_takeover=!0),this._options.clientNoContextTakeover&&(e.client_no_context_takeover=!0),this._options.serverMaxWindowBits&&(e.server_max_window_bits=this._options.serverMaxWindowBits),this._options.clientMaxWindowBits?e.client_max_window_bits=this._options.clientMaxWindowBits:this._options.clientMaxWindowBits==null&&(e.client_max_window_bits=!0),e}accept(e){return e=this.normalizeParams(e),this.params=this._isServer?this.acceptAsServer(e):this.acceptAsClient(e),this.params}cleanup(){if(this._inflate&&(this._inflate.close(),this._inflate=null),this._deflate){let e=this._deflate[W];this._deflate.close(),this._deflate=null,e&&e(new Error("The deflate stream was closed while data was being processed"))}}acceptAsServer(e){let s=this._options,r=e.find(i=>!(s.serverNoContextTakeover===!1&&i.server_no_context_takeover||i.server_max_window_bits&&(s.serverMaxWindowBits===!1||typeof s.serverMaxWindowBits=="number"&&s.serverMaxWindowBits>i.server_max_window_bits)||typeof s.clientMaxWindowBits=="number"&&!i.client_max_window_bits));if(!r)throw new Error("None of the extension offers can be accepted");return s.serverNoContextTakeover&&(r.server_no_context_takeover=!0),s.clientNoContextTakeover&&(r.client_no_context_takeover=!0),typeof s.serverMaxWindowBits=="number"&&(r.server_max_window_bits=s.serverMaxWindowBits),typeof s.clientMaxWindowBits=="number"?r.client_max_window_bits=s.clientMaxWindowBits:(r.client_max_window_bits===!0||s.clientMaxWindowBits===!1)&&delete r.client_max_window_bits,r}acceptAsClient(e){let s=e[0];if(this._options.clientNoContextTakeover===!1&&s.client_no_context_takeover)throw new Error('Unexpected parameter "client_no_context_takeover"');if(!s.client_max_window_bits)typeof this._options.clientMaxWindowBits=="number"&&(s.client_max_window_bits=this._options.clientMaxWindowBits);else if(this._options.clientMaxWindowBits===!1||typeof this._options.clientMaxWindowBits=="number"&&s.client_max_window_bits>this._options.clientMaxWindowBits)throw new Error('Unexpected or invalid parameter "client_max_window_bits"');return s}normalizeParams(e){return e.forEach(s=>{Object.keys(s).forEach(r=>{let i=s[r];if(i.length>1)throw new Error(`Parameter "${r}" must have only a single value`);if(i=i[0],r==="client_max_window_bits"){if(i!==!0){let n=+i;if(!Number.isInteger(n)||n<8||n>15)throw new TypeError(`Invalid value for parameter "${r}": ${i}`);i=n}else if(!this._isServer)throw new TypeError(`Invalid value for parameter "${r}": ${i}`)}else if(r==="server_max_window_bits"){let n=+i;if(!Number.isInteger(n)||n<8||n>15)throw new TypeError(`Invalid value for parameter "${r}": ${i}`);i=n}else if(r==="client_no_context_takeover"||r==="server_no_context_takeover"){if(i!==!0)throw new TypeError(`Invalid value for parameter "${r}": ${i}`)}else throw new Error(`Unknown parameter "${r}"`);s[r]=i})}),e}decompress(e,s,r){re.add(i=>{this._decompress(e,s,(n,o)=>{i(),r(n,o)})})}compress(e,s,r){re.add(i=>{this._compress(e,s,(n,o)=>{i(),r(n,o)})})}_decompress(e,s,r){let i=this._isServer?"client":"server";if(!this._inflate){let n=`${i}_max_window_bits`,o=typeof this.params[n]!="number"?Z.Z_DEFAULT_WINDOWBITS:this.params[n];this._inflate=Z.createInflateRaw({...this._options.zlibInflateOptions,windowBits:o}),this._inflate[ie]=this,this._inflate[T]=0,this._inflate[P]=[],this._inflate.on("error",fs),this._inflate.on("data",Ze)}this._inflate[W]=r,this._inflate.write(e),s&&this._inflate.write(os),this._inflate.flush(()=>{let n=this._inflate[q];if(n){this._inflate.close(),this._inflate=null,r(n);return}let o=Ke.concat(this._inflate[P],this._inflate[T]);this._inflate._readableState.endEmitted?(this._inflate.close(),this._inflate=null):(this._inflate[T]=0,this._inflate[P]=[],s&&this.params[`${i}_no_context_takeover`]&&this._inflate.reset()),r(null,o)})}_compress(e,s,r){let i=this._isServer?"server":"client";if(!this._deflate){let n=`${i}_max_window_bits`,o=typeof this.params[n]!="number"?Z.Z_DEFAULT_WINDOWBITS:this.params[n];this._deflate=Z.createDeflateRaw({...this._options.zlibDeflateOptions,windowBits:o}),this._deflate[T]=0,this._deflate[P]=[],this._deflate.on("data",as)}this._deflate[W]=r,this._deflate.write(e),this._deflate.flush(Z.Z_SYNC_FLUSH,()=>{if(!this._deflate)return;let n=Ke.concat(this._deflate[P],this._deflate[T]);s&&(n=new ns(n.buffer,n.byteOffset,n.length-4)),this._deflate[W]=null,this._deflate[T]=0,this._deflate[P]=[],s&&this.params[`${i}_no_context_takeover`]&&this._deflate.reset(),r(null,n)})}};Qe.exports=Ee;function as(t){this[P].push(t),this[T]+=t.length}function Ze(t){if(this[T]+=t.length,this[ie]._maxPayload<1||this[T]<=this[ie]._maxPayload){this[P].push(t);return}this[q]=new RangeError("Max payload size exceeded"),this[q].code="WS_ERR_UNSUPPORTED_MESSAGE_LENGTH",this[q][Xe]=1009,this.removeListener("data",Ze),this.reset()}function fs(t){if(this[ie]._inflate=null,this[q]){this[W](this[q]);return}t[Xe]=1007,this[W](t)}});var $=S((Rr,be)=>{"use strict";var{isUtf8:Je}=g("buffer"),{hasBlob:ls}=O(),hs=[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,1,1,1,1,1,0,0,1,1,0,1,1,0,1,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,1,0,1,0];function cs(t){return t>=1e3&&t<=1014&&t!==1004&&t!==1005&&t!==1006||t>=3e3&&t<=4999}function et(t){let e=t.length,s=0;for(;s=e||(t[s+1]&192)!==128||(t[s+2]&192)!==128||t[s]===224&&(t[s+1]&224)===128||t[s]===237&&(t[s+1]&224)===160)return!1;s+=3}else if((t[s]&248)===240){if(s+3>=e||(t[s+1]&192)!==128||(t[s+2]&192)!==128||(t[s+3]&192)!==128||t[s]===240&&(t[s+1]&240)===128||t[s]===244&&t[s+1]>143||t[s]>244)return!1;s+=4}else return!1;return!0}function us(t){return ls&&typeof t=="object"&&typeof t.arrayBuffer=="function"&&typeof t.type=="string"&&typeof t.stream=="function"&&(t[Symbol.toStringTag]==="Blob"||t[Symbol.toStringTag]==="File")}be.exports={isBlob:us,isValidStatusCode:cs,isValidUTF8:et,tokenChars:hs};Je&&(be.exports.isValidUTF8=function(t){return t.length<24?et(t):Je(t)})});var Te=S((Ur,at)=>{"use strict";var{Writable:ds}=g("stream"),tt=G(),{BINARY_TYPES:_s,EMPTY_BUFFER:st,kStatusCode:ms,kWebSocket:ps}=O(),{concat:ve,toArrayBuffer:gs,unmask:ys}=X(),{isValidStatusCode:xs,isValidUTF8:rt}=$(),ne=Buffer[Symbol.species],b=0,it=1,nt=2,ot=3,we=4,ke=5,oe=6,Oe=class extends ds{constructor(e={}){super(),this._allowSynchronousEvents=e.allowSynchronousEvents!==void 0?e.allowSynchronousEvents:!0,this._binaryType=e.binaryType||_s[0],this._extensions=e.extensions||{},this._isServer=!!e.isServer,this._maxBufferedChunks=e.maxBufferedChunks|0,this._maxFragments=e.maxFragments|0,this._maxPayload=e.maxPayload|0,this._skipUTF8Validation=!!e.skipUTF8Validation,this[ps]=void 0,this._bufferedBytes=0,this._buffers=[],this._compressed=!1,this._payloadLength=0,this._mask=void 0,this._fragmented=0,this._masked=!1,this._fin=!1,this._opcode=0,this._totalPayloadLength=0,this._messageLength=0,this._numFragments=0,this._fragments=[],this._errored=!1,this._loop=!1,this._state=b}_write(e,s,r){if(this._opcode===8&&this._state==b)return r();if(this._maxBufferedChunks>0&&this._buffers.length>=this._maxBufferedChunks){r(this.createError(RangeError,"Too many buffered chunks",!1,1008,"WS_ERR_TOO_MANY_BUFFERED_PARTS"));return}this._bufferedBytes+=e.length,this._buffers.push(e),this.startLoop(r)}consume(e){if(this._bufferedBytes-=e,e===this._buffers[0].length)return this._buffers.shift();if(e=r.length?s.set(this._buffers.shift(),i):(s.set(new Uint8Array(r.buffer,r.byteOffset,e),i),this._buffers[0]=new ne(r.buffer,r.byteOffset+e,r.length-e)),e-=r.length}while(e>0);return s}startLoop(e){this._loop=!0;do switch(this._state){case b:this.getInfo(e);break;case it:this.getPayloadLength16(e);break;case nt:this.getPayloadLength64(e);break;case ot:this.getMask();break;case we:this.getData(e);break;case ke:case oe:this._loop=!1;return}while(this._loop);this._errored||e()}getInfo(e){if(this._bufferedBytes<2){this._loop=!1;return}let s=this.consume(2);if((s[0]&48)!==0){let i=this.createError(RangeError,"RSV2 and RSV3 must be clear",!0,1002,"WS_ERR_UNEXPECTED_RSV_2_3");e(i);return}let r=(s[0]&64)===64;if(r&&!this._extensions[tt.extensionName]){let i=this.createError(RangeError,"RSV1 must be clear",!0,1002,"WS_ERR_UNEXPECTED_RSV_1");e(i);return}if(this._fin=(s[0]&128)===128,this._opcode=s[0]&15,this._payloadLength=s[1]&127,this._opcode===0){if(r){let i=this.createError(RangeError,"RSV1 must be clear",!0,1002,"WS_ERR_UNEXPECTED_RSV_1");e(i);return}if(!this._fragmented){let i=this.createError(RangeError,"invalid opcode 0",!0,1002,"WS_ERR_INVALID_OPCODE");e(i);return}this._opcode=this._fragmented}else if(this._opcode===1||this._opcode===2){if(this._fragmented){let i=this.createError(RangeError,`invalid opcode ${this._opcode}`,!0,1002,"WS_ERR_INVALID_OPCODE");e(i);return}this._compressed=r}else if(this._opcode>7&&this._opcode<11){if(!this._fin){let i=this.createError(RangeError,"FIN must be set",!0,1002,"WS_ERR_EXPECTED_FIN");e(i);return}if(r){let i=this.createError(RangeError,"RSV1 must be clear",!0,1002,"WS_ERR_UNEXPECTED_RSV_1");e(i);return}if(this._payloadLength>125||this._opcode===8&&this._payloadLength===1){let i=this.createError(RangeError,`invalid payload length ${this._payloadLength}`,!0,1002,"WS_ERR_INVALID_CONTROL_PAYLOAD_LENGTH");e(i);return}}else{let i=this.createError(RangeError,`invalid opcode ${this._opcode}`,!0,1002,"WS_ERR_INVALID_OPCODE");e(i);return}if(!this._fin&&!this._fragmented&&(this._fragmented=this._opcode),this._masked=(s[1]&128)===128,this._isServer){if(!this._masked){let i=this.createError(RangeError,"MASK must be set",!0,1002,"WS_ERR_EXPECTED_MASK");e(i);return}}else if(this._masked){let i=this.createError(RangeError,"MASK must be clear",!0,1002,"WS_ERR_UNEXPECTED_MASK");e(i);return}this._payloadLength===126?this._state=it:this._payloadLength===127?this._state=nt:this.haveLength(e)}getPayloadLength16(e){if(this._bufferedBytes<2){this._loop=!1;return}this._payloadLength=this.consume(2).readUInt16BE(0),this.haveLength(e)}getPayloadLength64(e){if(this._bufferedBytes<8){this._loop=!1;return}let s=this.consume(8),r=s.readUInt32BE(0);if(r>Math.pow(2,21)-1){let i=this.createError(RangeError,"Unsupported WebSocket frame: payload length > 2^53 - 1",!1,1009,"WS_ERR_UNSUPPORTED_DATA_PAYLOAD_LENGTH");e(i);return}this._payloadLength=r*Math.pow(2,32)+s.readUInt32BE(4),this.haveLength(e)}haveLength(e){if(this._payloadLength&&this._opcode<8&&(this._totalPayloadLength+=this._payloadLength,this._totalPayloadLength>this._maxPayload&&this._maxPayload>0)){let s=this.createError(RangeError,"Max payload size exceeded",!1,1009,"WS_ERR_UNSUPPORTED_MESSAGE_LENGTH");e(s);return}this._masked?this._state=ot:this._state=we}getMask(){if(this._bufferedBytes<4){this._loop=!1;return}this._mask=this.consume(4),this._state=we}getData(e){let s=st;if(this._payloadLength){if(this._bufferedBytes7){this.controlMessage(s,e);return}if(this._maxFragments>0&&++this._numFragments>this._maxFragments){let r=this.createError(RangeError,"Too many message fragments",!1,1008,"WS_ERR_TOO_MANY_BUFFERED_PARTS");e(r);return}if(this._compressed){this._state=ke,this.decompress(s,e);return}s.length&&(this._messageLength=this._totalPayloadLength,this._fragments.push(s)),this.dataMessage(e)}decompress(e,s){this._extensions[tt.extensionName].decompress(e,this._fin,(i,n)=>{if(i)return s(i);if(n.length){if(this._messageLength+=n.length,this._messageLength>this._maxPayload&&this._maxPayload>0){let o=this.createError(RangeError,"Max payload size exceeded",!1,1009,"WS_ERR_UNSUPPORTED_MESSAGE_LENGTH");s(o);return}this._fragments.push(n)}this.dataMessage(s),this._state===b&&this.startLoop(s)})}dataMessage(e){if(!this._fin){this._state=b;return}let s=this._messageLength,r=this._fragments;if(this._totalPayloadLength=0,this._messageLength=0,this._fragmented=0,this._numFragments=0,this._fragments=[],this._opcode===2){let i;this._binaryType==="nodebuffer"?i=ve(r,s):this._binaryType==="arraybuffer"?i=gs(ve(r,s)):this._binaryType==="blob"?i=new Blob(r):i=r,this._allowSynchronousEvents?(this.emit("message",i,!0),this._state=b):(this._state=oe,setImmediate(()=>{this.emit("message",i,!0),this._state=b,this.startLoop(e)}))}else{let i=ve(r,s);if(!this._skipUTF8Validation&&!rt(i)){let n=this.createError(Error,"invalid UTF-8 sequence",!0,1007,"WS_ERR_INVALID_UTF8");e(n);return}this._state===ke||this._allowSynchronousEvents?(this.emit("message",i,!1),this._state=b):(this._state=oe,setImmediate(()=>{this.emit("message",i,!1),this._state=b,this.startLoop(e)}))}}controlMessage(e,s){if(this._opcode===8){if(e.length===0)this._loop=!1,this.emit("conclude",1005,st),this.end();else{let r=e.readUInt16BE(0);if(!xs(r)){let n=this.createError(RangeError,`invalid status code ${r}`,!0,1002,"WS_ERR_INVALID_CLOSE_CODE");s(n);return}let i=new ne(e.buffer,e.byteOffset+2,e.length-2);if(!this._skipUTF8Validation&&!rt(i)){let n=this.createError(Error,"invalid UTF-8 sequence",!0,1007,"WS_ERR_INVALID_UTF8");s(n);return}this._loop=!1,this.emit("conclude",r,i),this.end()}this._state=b;return}this._allowSynchronousEvents?(this.emit(this._opcode===9?"ping":"pong",e),this._state=b):(this._state=oe,setImmediate(()=>{this.emit(this._opcode===9?"ping":"pong",e),this._state=b,this.startLoop(s)}))}createError(e,s,r,i,n){this._loop=!1,this._errored=!0;let o=new e(r?`Invalid WebSocket frame: ${s}`:s);return Error.captureStackTrace(o,this.createError),o.code=n,o[ms]=i,o}};at.exports=Oe});var Ne=S((Dr,ht)=>{"use strict";var{Duplex:Ir}=g("stream"),{randomFillSync:Ss}=g("crypto"),{types:{isUint8Array:Es}}=g("util"),ft=G(),{EMPTY_BUFFER:bs,kWebSocket:vs,NOOP:ws}=O(),{isBlob:j,isValidStatusCode:ks}=$(),{mask:lt,toBuffer:R}=X(),v=Symbol("kByteLength"),Os=Buffer.alloc(4),ae=8*1024,U,V=ae,w=0,Ts=1,Cs=2,Ce=class t{constructor(e,s,r){this._extensions=s||{},r&&(this._generateMask=r,this._maskBuffer=Buffer.alloc(4)),this._socket=e,this._firstFragment=!0,this._compress=!1,this._bufferedBytes=0,this._queue=[],this._state=w,this.onerror=ws,this[vs]=void 0}static frame(e,s){let r,i=!1,n=2,o=!1;s.mask&&(r=s.maskBuffer||Os,s.generateMask?s.generateMask(r):(V===ae&&(U===void 0&&(U=Buffer.alloc(ae)),Ss(U,0,ae),V=0),r[0]=U[V++],r[1]=U[V++],r[2]=U[V++],r[3]=U[V++]),o=(r[0]|r[1]|r[2]|r[3])===0,n=6);let f;typeof e=="string"?(!s.mask||o)&&s[v]!==void 0?f=s[v]:(e=Buffer.from(e),f=e.length):(f=e.length,i=s.mask&&s.readOnly&&!o);let l=f;f>=65536?(n+=8,l=127):f>125&&(n+=2,l=126);let a=Buffer.allocUnsafe(i?f+n:n);return a[0]=s.fin?s.opcode|128:s.opcode,s.rsv1&&(a[0]|=64),a[1]=l,l===126?a.writeUInt16BE(f,2):l===127&&(a[2]=a[3]=0,a.writeUIntBE(f,4,6)),s.mask?(a[1]|=128,a[n-4]=r[0],a[n-3]=r[1],a[n-2]=r[2],a[n-1]=r[3],o?[a,e]:i?(lt(e,r,a,n,f),[a]):(lt(e,r,e,0,f),[a,e])):[a,e]}close(e,s,r,i){let n;if(e===void 0)n=bs;else{if(typeof e!="number"||!ks(e))throw new TypeError("First argument must be a valid error code number");if(s===void 0||!s.length)n=Buffer.allocUnsafe(2),n.writeUInt16BE(e,0);else{let f=Buffer.byteLength(s);if(f>123)throw new RangeError("The message must not be greater than 123 bytes");if(n=Buffer.allocUnsafe(2+f),n.writeUInt16BE(e,0),typeof s=="string")n.write(s,2);else if(Es(s))n.set(s,2);else throw new TypeError("Second argument must be a string or a Uint8Array")}}let o={[v]:n.length,fin:!0,generateMask:this._generateMask,mask:r,maskBuffer:this._maskBuffer,opcode:8,readOnly:!1,rsv1:!1};this._state!==w?this.enqueue([this.dispatch,n,!1,o,i]):this.sendFrame(t.frame(n,o),i)}ping(e,s,r){let i,n;if(typeof e=="string"?(i=Buffer.byteLength(e),n=!1):j(e)?(i=e.size,n=!1):(e=R(e),i=e.length,n=R.readOnly),i>125)throw new RangeError("The data size must not be greater than 125 bytes");let o={[v]:i,fin:!0,generateMask:this._generateMask,mask:s,maskBuffer:this._maskBuffer,opcode:9,readOnly:n,rsv1:!1};j(e)?this._state!==w?this.enqueue([this.getBlobData,e,!1,o,r]):this.getBlobData(e,!1,o,r):this._state!==w?this.enqueue([this.dispatch,e,!1,o,r]):this.sendFrame(t.frame(e,o),r)}pong(e,s,r){let i,n;if(typeof e=="string"?(i=Buffer.byteLength(e),n=!1):j(e)?(i=e.size,n=!1):(e=R(e),i=e.length,n=R.readOnly),i>125)throw new RangeError("The data size must not be greater than 125 bytes");let o={[v]:i,fin:!0,generateMask:this._generateMask,mask:s,maskBuffer:this._maskBuffer,opcode:10,readOnly:n,rsv1:!1};j(e)?this._state!==w?this.enqueue([this.getBlobData,e,!1,o,r]):this.getBlobData(e,!1,o,r):this._state!==w?this.enqueue([this.dispatch,e,!1,o,r]):this.sendFrame(t.frame(e,o),r)}send(e,s,r){let i=this._extensions[ft.extensionName],n=s.binary?2:1,o=s.compress,f,l;typeof e=="string"?(f=Buffer.byteLength(e),l=!1):j(e)?(f=e.size,l=!1):(e=R(e),f=e.length,l=R.readOnly),this._firstFragment?(this._firstFragment=!1,o&&i&&i.params[i._isServer?"server_no_context_takeover":"client_no_context_takeover"]&&(o=f>=i._threshold),this._compress=o):(o=!1,n=0),s.fin&&(this._firstFragment=!0);let a={[v]:f,fin:s.fin,generateMask:this._generateMask,mask:s.mask,maskBuffer:this._maskBuffer,opcode:n,readOnly:l,rsv1:o};j(e)?this._state!==w?this.enqueue([this.getBlobData,e,this._compress,a,r]):this.getBlobData(e,this._compress,a,r):this._state!==w?this.enqueue([this.dispatch,e,this._compress,a,r]):this.dispatch(e,this._compress,a,r)}getBlobData(e,s,r,i){this._bufferedBytes+=r[v],this._state=Cs,e.arrayBuffer().then(n=>{if(this._socket.destroyed){let f=new Error("The socket was closed while the blob was being read");process.nextTick(Le,this,f,i);return}this._bufferedBytes-=r[v];let o=R(n);s?this.dispatch(o,s,r,i):(this._state=w,this.sendFrame(t.frame(o,r),i),this.dequeue())}).catch(n=>{process.nextTick(Ls,this,n,i)})}dispatch(e,s,r,i){if(!s){this.sendFrame(t.frame(e,r),i);return}let n=this._extensions[ft.extensionName];this._bufferedBytes+=r[v],this._state=Ts,n.compress(e,r.fin,(o,f)=>{if(this._socket.destroyed){let l=new Error("The socket was closed while data was being compressed");Le(this,l,i);return}this._bufferedBytes-=r[v],this._state=w,r.readOnly=!1,this.sendFrame(t.frame(f,r),i),this.dequeue()})}dequeue(){for(;this._state===w&&this._queue.length;){let e=this._queue.shift();this._bufferedBytes-=e[3][v],Reflect.apply(e[0],this,e.slice(1))}}enqueue(e){this._bufferedBytes+=e[3][v],this._queue.push(e)}sendFrame(e,s){e.length===2?(this._socket.cork(),this._socket.write(e[0]),this._socket.write(e[1],s),this._socket.uncork()):this._socket.write(e[0],s)}};ht.exports=Ce;function Le(t,e,s){typeof s=="function"&&s(e);for(let r=0;r{"use strict";var{kForOnEventAttribute:Q,kListener:Pe}=O(),ct=Symbol("kCode"),ut=Symbol("kData"),dt=Symbol("kError"),_t=Symbol("kMessage"),mt=Symbol("kReason"),z=Symbol("kTarget"),pt=Symbol("kType"),gt=Symbol("kWasClean"),C=class{constructor(e){this[z]=null,this[pt]=e}get target(){return this[z]}get type(){return this[pt]}};Object.defineProperty(C.prototype,"target",{enumerable:!0});Object.defineProperty(C.prototype,"type",{enumerable:!0});var I=class extends C{constructor(e,s={}){super(e),this[ct]=s.code===void 0?0:s.code,this[mt]=s.reason===void 0?"":s.reason,this[gt]=s.wasClean===void 0?!1:s.wasClean}get code(){return this[ct]}get reason(){return this[mt]}get wasClean(){return this[gt]}};Object.defineProperty(I.prototype,"code",{enumerable:!0});Object.defineProperty(I.prototype,"reason",{enumerable:!0});Object.defineProperty(I.prototype,"wasClean",{enumerable:!0});var H=class extends C{constructor(e,s={}){super(e),this[dt]=s.error===void 0?null:s.error,this[_t]=s.message===void 0?"":s.message}get error(){return this[dt]}get message(){return this[_t]}};Object.defineProperty(H.prototype,"error",{enumerable:!0});Object.defineProperty(H.prototype,"message",{enumerable:!0});var J=class extends C{constructor(e,s={}){super(e),this[ut]=s.data===void 0?null:s.data}get data(){return this[ut]}};Object.defineProperty(J.prototype,"data",{enumerable:!0});var Ns={addEventListener(t,e,s={}){for(let i of this.listeners(t))if(!s[Q]&&i[Pe]===e&&!i[Q])return;let r;if(t==="message")r=function(n,o){let f=new J("message",{data:o?n:n.toString()});f[z]=this,fe(e,this,f)};else if(t==="close")r=function(n,o){let f=new I("close",{code:n,reason:o.toString(),wasClean:this._closeFrameReceived&&this._closeFrameSent});f[z]=this,fe(e,this,f)};else if(t==="error")r=function(n){let o=new H("error",{error:n,message:n.message});o[z]=this,fe(e,this,o)};else if(t==="open")r=function(){let n=new C("open");n[z]=this,fe(e,this,n)};else return;r[Q]=!!s[Q],r[Pe]=e,s.once?this.once(t,r):this.on(t,r)},removeEventListener(t,e){for(let s of this.listeners(t))if(s[Pe]===e&&!s[Q]){this.removeListener(t,s);break}}};yt.exports={CloseEvent:I,ErrorEvent:H,Event:C,EventTarget:Ns,MessageEvent:J};function fe(t,e,s){typeof t=="object"&&t.handleEvent?t.handleEvent.call(t,s):t.call(e,s)}});var le=S((Mr,St)=>{"use strict";var{tokenChars:ee}=$();function k(t,e,s){t[e]===void 0?t[e]=[s]:t[e].push(s)}function Ps(t){let e=Object.create(null),s=Object.create(null),r=!1,i=!1,n=!1,o,f,l=-1,a=-1,h=-1,c=0;for(;c{let s=t[e];return Array.isArray(s)||(s=[s]),s.map(r=>[e].concat(Object.keys(r).map(i=>{let n=r[i];return Array.isArray(n)||(n=[n]),n.map(o=>o===!0?i:`${i}=${o}`).join("; ")})).join("; ")).join(", ")}).join(", ")}St.exports={format:Bs,parse:Ps}});var de=S((qr,Bt)=>{"use strict";var Rs=g("events"),Us=g("https"),Is=g("http"),vt=g("net"),Ds=g("tls"),{randomBytes:Fs,createHash:Ms}=g("crypto"),{Duplex:Ar,Readable:Wr}=g("stream"),{URL:Be}=g("url"),B=G(),As=Te(),Ws=Ne(),{isBlob:qs}=$(),{BINARY_TYPES:Et,CLOSE_TIMEOUT:Gs,EMPTY_BUFFER:he,GUID:$s,kForOnEventAttribute:Re,kListener:js,kStatusCode:Vs,kWebSocket:y,NOOP:wt}=O(),{EventTarget:{addEventListener:zs,removeEventListener:Hs}}=xt(),{format:Ys,parse:Ks}=le(),{toBuffer:Xs}=X(),kt=Symbol("kAborted"),Ue=[8,13],L=["CONNECTING","OPEN","CLOSING","CLOSED"],Zs=/^[!#$%&'*+\-.0-9A-Z^_`|a-z~]+$/,d=class t extends Rs{constructor(e,s,r){super(),this._binaryType=Et[0],this._closeCode=1006,this._closeFrameReceived=!1,this._closeFrameSent=!1,this._closeMessage=he,this._closeTimer=null,this._errorEmitted=!1,this._extensions={},this._paused=!1,this._protocol="",this._readyState=t.CONNECTING,this._receiver=null,this._sender=null,this._socket=null,e!==null?(this._bufferedAmount=0,this._isServer=!1,this._redirects=0,s===void 0?s=[]:Array.isArray(s)||(typeof s=="object"&&s!==null?(r=s,s=[]):s=[s]),Ot(this,e,s,r)):(this._autoPong=r.autoPong,this._closeTimeout=r.closeTimeout,this._isServer=!0)}get binaryType(){return this._binaryType}set binaryType(e){Et.includes(e)&&(this._binaryType=e,this._receiver&&(this._receiver._binaryType=e))}get bufferedAmount(){return this._socket?this._socket._writableState.length+this._sender._bufferedBytes:this._bufferedAmount}get extensions(){return Object.keys(this._extensions).join()}get isPaused(){return this._paused}get onclose(){return null}get onerror(){return null}get onopen(){return null}get onmessage(){return null}get protocol(){return this._protocol}get readyState(){return this._readyState}get url(){return this._url}setSocket(e,s,r){let i=new As({allowSynchronousEvents:r.allowSynchronousEvents,binaryType:this.binaryType,extensions:this._extensions,isServer:this._isServer,maxBufferedChunks:r.maxBufferedChunks,maxFragments:r.maxFragments,maxPayload:r.maxPayload,skipUTF8Validation:r.skipUTF8Validation}),n=new Ws(e,this._extensions,r.generateMask);this._receiver=i,this._sender=n,this._socket=e,i[y]=this,n[y]=this,e[y]=this,i.on("conclude",er),i.on("drain",tr),i.on("error",sr),i.on("message",rr),i.on("ping",ir),i.on("pong",nr),n.onerror=or,e.setTimeout&&e.setTimeout(0),e.setNoDelay&&e.setNoDelay(),s.length>0&&e.unshift(s),e.on("close",Lt),e.on("data",ue),e.on("end",Nt),e.on("error",Pt),this._readyState=t.OPEN,this.emit("open")}emitClose(){if(!this._socket){this._readyState=t.CLOSED,this.emit("close",this._closeCode,this._closeMessage);return}this._extensions[B.extensionName]&&this._extensions[B.extensionName].cleanup(),this._receiver.removeAllListeners(),this._readyState=t.CLOSED,this.emit("close",this._closeCode,this._closeMessage)}close(e,s){if(this.readyState!==t.CLOSED){if(this.readyState===t.CONNECTING){E(this,this._req,"WebSocket was closed before the connection was established");return}if(this.readyState===t.CLOSING){this._closeFrameSent&&(this._closeFrameReceived||this._receiver._writableState.errorEmitted)&&this._socket.end();return}this._readyState=t.CLOSING,this._sender.close(e,s,!this._isServer,r=>{r||(this._closeFrameSent=!0,(this._closeFrameReceived||this._receiver._writableState.errorEmitted)&&this._socket.end())}),Ct(this)}}pause(){this.readyState===t.CONNECTING||this.readyState===t.CLOSED||(this._paused=!0,this._socket.pause())}ping(e,s,r){if(this.readyState===t.CONNECTING)throw new Error("WebSocket is not open: readyState 0 (CONNECTING)");if(typeof e=="function"?(r=e,e=s=void 0):typeof s=="function"&&(r=s,s=void 0),typeof e=="number"&&(e=e.toString()),this.readyState!==t.OPEN){Ie(this,e,r);return}s===void 0&&(s=!this._isServer),this._sender.ping(e||he,s,r)}pong(e,s,r){if(this.readyState===t.CONNECTING)throw new Error("WebSocket is not open: readyState 0 (CONNECTING)");if(typeof e=="function"?(r=e,e=s=void 0):typeof s=="function"&&(r=s,s=void 0),typeof e=="number"&&(e=e.toString()),this.readyState!==t.OPEN){Ie(this,e,r);return}s===void 0&&(s=!this._isServer),this._sender.pong(e||he,s,r)}resume(){this.readyState===t.CONNECTING||this.readyState===t.CLOSED||(this._paused=!1,this._receiver._writableState.needDrain||this._socket.resume())}send(e,s,r){if(this.readyState===t.CONNECTING)throw new Error("WebSocket is not open: readyState 0 (CONNECTING)");if(typeof s=="function"&&(r=s,s={}),typeof e=="number"&&(e=e.toString()),this.readyState!==t.OPEN){Ie(this,e,r);return}let i={binary:typeof e!="string",mask:!this._isServer,compress:!0,fin:!0,...s};this._extensions[B.extensionName]||(i.compress=!1),this._sender.send(e||he,i,r)}terminate(){if(this.readyState!==t.CLOSED){if(this.readyState===t.CONNECTING){E(this,this._req,"WebSocket was closed before the connection was established");return}this._socket&&(this._readyState=t.CLOSING,this._socket.destroy())}}};Object.defineProperty(d,"CONNECTING",{enumerable:!0,value:L.indexOf("CONNECTING")});Object.defineProperty(d.prototype,"CONNECTING",{enumerable:!0,value:L.indexOf("CONNECTING")});Object.defineProperty(d,"OPEN",{enumerable:!0,value:L.indexOf("OPEN")});Object.defineProperty(d.prototype,"OPEN",{enumerable:!0,value:L.indexOf("OPEN")});Object.defineProperty(d,"CLOSING",{enumerable:!0,value:L.indexOf("CLOSING")});Object.defineProperty(d.prototype,"CLOSING",{enumerable:!0,value:L.indexOf("CLOSING")});Object.defineProperty(d,"CLOSED",{enumerable:!0,value:L.indexOf("CLOSED")});Object.defineProperty(d.prototype,"CLOSED",{enumerable:!0,value:L.indexOf("CLOSED")});["binaryType","bufferedAmount","extensions","isPaused","protocol","readyState","url"].forEach(t=>{Object.defineProperty(d.prototype,t,{enumerable:!0})});["open","error","close","message"].forEach(t=>{Object.defineProperty(d.prototype,`on${t}`,{enumerable:!0,get(){for(let e of this.listeners(t))if(e[Re])return e[js];return null},set(e){for(let s of this.listeners(t))if(s[Re]){this.removeListener(t,s);break}typeof e=="function"&&this.addEventListener(t,e,{[Re]:!0})}})});d.prototype.addEventListener=zs;d.prototype.removeEventListener=Hs;Bt.exports=d;function Ot(t,e,s,r){let i={allowSynchronousEvents:!0,autoPong:!0,closeTimeout:Gs,protocolVersion:Ue[1],maxBufferedChunks:262144,maxFragments:16384,maxPayload:104857600,skipUTF8Validation:!1,perMessageDeflate:!0,followRedirects:!1,maxRedirects:10,...r,socketPath:void 0,hostname:void 0,protocol:void 0,timeout:void 0,method:"GET",host:void 0,path:void 0,port:void 0};if(t._autoPong=i.autoPong,t._closeTimeout=i.closeTimeout,!Ue.includes(i.protocolVersion))throw new RangeError(`Unsupported protocol version: ${i.protocolVersion} (supported versions: ${Ue.join(", ")})`);let n;if(e instanceof Be)n=e;else try{n=new Be(e)}catch{throw new SyntaxError(`Invalid URL: ${e}`)}n.protocol==="http:"?n.protocol="ws:":n.protocol==="https:"&&(n.protocol="wss:"),t._url=n.href;let o=n.protocol==="wss:",f=n.protocol==="ws+unix:",l;if(n.protocol!=="ws:"&&!o&&!f?l=`The URL's protocol must be one of "ws:", "wss:", "http:", "https:", or "ws+unix:"`:f&&!n.pathname?l="The URL's pathname is empty":n.hash&&(l="The URL contains a fragment identifier"),l){let u=new SyntaxError(l);if(t._redirects===0)throw u;ce(t,u);return}let a=o?443:80,h=Fs(16).toString("base64"),c=o?Us.request:Is.request,_=new Set,p;if(i.createConnection=i.createConnection||(o?Js:Qs),i.defaultPort=i.defaultPort||a,i.port=n.port||a,i.host=n.hostname.startsWith("[")?n.hostname.slice(1,-1):n.hostname,i.headers={...i.headers,"Sec-WebSocket-Version":i.protocolVersion,"Sec-WebSocket-Key":h,Connection:"Upgrade",Upgrade:"websocket"},i.path=n.pathname+n.search,i.timeout=i.handshakeTimeout,i.perMessageDeflate&&(p=new B({...i.perMessageDeflate,isServer:!1,maxPayload:i.maxPayload}),i.headers["Sec-WebSocket-Extensions"]=Ys({[B.extensionName]:p.offer()})),s.length){for(let u of s){if(typeof u!="string"||!Zs.test(u)||_.has(u))throw new SyntaxError("An invalid or duplicated subprotocol was specified");_.add(u)}i.headers["Sec-WebSocket-Protocol"]=s.join(",")}if(i.origin&&(i.protocolVersion<13?i.headers["Sec-WebSocket-Origin"]=i.origin:i.headers.Origin=i.origin),(n.username||n.password)&&(i.auth=`${n.username}:${n.password}`),f){let u=i.path.split(":");i.socketPath=u[0],i.path=u[1]}let m;if(i.followRedirects){if(t._redirects===0){t._originalIpc=f,t._originalSecure=o,t._originalHostOrSocketPath=f?i.socketPath:n.host;let u=r&&r.headers;if(r={...r,headers:{}},u)for(let[x,M]of Object.entries(u))r.headers[x.toLowerCase()]=M}else if(t.listenerCount("redirect")===0){let u=f?t._originalIpc?i.socketPath===t._originalHostOrSocketPath:!1:t._originalIpc?!1:n.host===t._originalHostOrSocketPath;(!u||t._originalSecure&&!o)&&(delete i.headers.authorization,delete i.headers.cookie,u||delete i.headers.host,i.auth=void 0)}i.auth&&!r.headers.authorization&&(r.headers.authorization="Basic "+Buffer.from(i.auth).toString("base64")),m=t._req=c(i),t._redirects&&t.emit("redirect",t.url,m)}else m=t._req=c(i);i.timeout&&m.on("timeout",()=>{E(t,m,"Opening handshake has timed out")}),m.on("error",u=>{m===null||m[kt]||(m=t._req=null,ce(t,u))}),m.on("response",u=>{let x=u.headers.location,M=u.statusCode;if(x&&i.followRedirects&&M>=300&&M<400){if(++t._redirects>i.maxRedirects){E(t,m,"Maximum redirects exceeded");return}m.abort();let Y;try{Y=new Be(x,e)}catch{let A=new SyntaxError(`Invalid URL: ${x}`);ce(t,A);return}Ot(t,Y,s,r)}else t.emit("unexpected-response",m,u)||E(t,m,`Unexpected server response: ${u.statusCode}`)}),m.on("upgrade",(u,x,M)=>{if(t.emit("upgrade",u),t.readyState!==d.CONNECTING)return;m=t._req=null;let Y=u.headers.upgrade;if(Y===void 0||Y.toLowerCase()!=="websocket"){E(t,x,"Invalid Upgrade header");return}let Me=Ms("sha1").update(h+$s).digest("base64");if(u.headers["sec-websocket-accept"]!==Me){E(t,x,"Invalid Sec-WebSocket-Accept header");return}let A=u.headers["sec-websocket-protocol"],K;if(A!==void 0?_.size?_.has(A)||(K="Server sent an invalid subprotocol"):K="Server sent a subprotocol but none was requested":_.size&&(K="Server sent no subprotocol"),K){E(t,x,K);return}A&&(t._protocol=A);let Ae=u.headers["sec-websocket-extensions"];if(Ae!==void 0){if(!p){E(t,x,"Server sent a Sec-WebSocket-Extensions header but no extension was requested");return}let me;try{me=Ks(Ae)}catch{E(t,x,"Invalid Sec-WebSocket-Extensions header");return}let We=Object.keys(me);if(We.length!==1||We[0]!==B.extensionName){E(t,x,"Server indicated an extension that was not requested");return}try{p.accept(me[B.extensionName])}catch{E(t,x,"Invalid Sec-WebSocket-Extensions header");return}t._extensions[B.extensionName]=p}t.setSocket(x,M,{allowSynchronousEvents:i.allowSynchronousEvents,generateMask:i.generateMask,maxBufferedChunks:i.maxBufferedChunks,maxFragments:i.maxFragments,maxPayload:i.maxPayload,skipUTF8Validation:i.skipUTF8Validation})}),i.finishRequest?i.finishRequest(m,t):m.end()}function ce(t,e){t._readyState=d.CLOSING,t._errorEmitted=!0,t.emit("error",e),t.emitClose()}function Qs(t){return t.path=t.socketPath,vt.connect(t)}function Js(t){return t.path=void 0,!t.servername&&t.servername!==""&&(t.servername=vt.isIP(t.host)?"":t.host),Ds.connect(t)}function E(t,e,s){t._readyState=d.CLOSING;let r=new Error(s);Error.captureStackTrace(r,E),e.setHeader?(e[kt]=!0,e.abort(),e.socket&&!e.socket.destroyed&&e.socket.destroy(),process.nextTick(ce,t,r)):(e.destroy(r),e.once("error",t.emit.bind(t,"error")),e.once("close",t.emitClose.bind(t)))}function Ie(t,e,s){if(e){let r=qs(e)?e.size:Xs(e).length;t._socket?t._sender._bufferedBytes+=r:t._bufferedAmount+=r}if(s){let r=new Error(`WebSocket is not open: readyState ${t.readyState} (${L[t.readyState]})`);process.nextTick(s,r)}}function er(t,e){let s=this[y];s._closeFrameReceived=!0,s._closeMessage=e,s._closeCode=t,s._socket[y]!==void 0&&(s._socket.removeListener("data",ue),process.nextTick(Tt,s._socket),t===1005?s.close():s.close(t,e))}function tr(){let t=this[y];t.isPaused||t._socket.resume()}function sr(t){let e=this[y];e._socket[y]!==void 0&&(e._socket.removeListener("data",ue),process.nextTick(Tt,e._socket),e.close(t[Vs])),e._errorEmitted||(e._errorEmitted=!0,e.emit("error",t))}function bt(){this[y].emitClose()}function rr(t,e){this[y].emit("message",t,e)}function ir(t){let e=this[y];e._autoPong&&e.pong(t,!this._isServer,wt),e.emit("ping",t)}function nr(t){this[y].emit("pong",t)}function Tt(t){t.resume()}function or(t){let e=this[y];e.readyState!==d.CLOSED&&(e.readyState===d.OPEN&&(e._readyState=d.CLOSING,Ct(e)),this._socket.end(),e._errorEmitted||(e._errorEmitted=!0,e.emit("error",t)))}function Ct(t){t._closeTimer=setTimeout(t._socket.destroy.bind(t._socket),t._closeTimeout)}function Lt(){let t=this[y];if(this.removeListener("close",Lt),this.removeListener("data",ue),this.removeListener("end",Nt),t._readyState=d.CLOSING,!this._readableState.endEmitted&&!t._closeFrameReceived&&!t._receiver._writableState.errorEmitted&&this._readableState.length!==0){let e=this.read(this._readableState.length);t._receiver.write(e)}t._receiver.end(),this[y]=void 0,clearTimeout(t._closeTimer),t._receiver._writableState.finished||t._receiver._writableState.errorEmitted?t.emitClose():(t._receiver.on("error",bt),t._receiver.on("finish",bt))}function ue(t){this[y]._receiver.write(t)||this.pause()}function Nt(){let t=this[y];t._readyState=d.CLOSING,t._receiver.end(),this.end()}function Pt(){let t=this[y];this.removeListener("error",Pt),this.on("error",wt),t&&(t._readyState=d.CLOSING,this.destroy())}});var Dt=S(($r,It)=>{"use strict";var Gr=de(),{Duplex:ar}=g("stream");function Rt(t){t.emit("close")}function fr(){!this.destroyed&&this._writableState.finished&&this.destroy()}function Ut(t){this.removeListener("error",Ut),this.destroy(),this.listenerCount("error")===0&&this.emit("error",t)}function lr(t,e){let s=!0,r=new ar({...e,autoDestroy:!1,emitClose:!1,objectMode:!1,writableObjectMode:!1});return t.on("message",function(n,o){let f=!o&&r._readableState.objectMode?n.toString():n;r.push(f)||t.pause()}),t.once("error",function(n){r.destroyed||(s=!1,r.destroy(n))}),t.once("close",function(){r.destroyed||r.push(null)}),r._destroy=function(i,n){if(t.readyState===t.CLOSED){n(i),process.nextTick(Rt,r);return}let o=!1;t.once("error",function(l){o=!0,n(l)}),t.once("close",function(){o||n(i),process.nextTick(Rt,r)}),s&&t.terminate()},r._final=function(i){if(t.readyState===t.CONNECTING){t.once("open",function(){r._final(i)});return}t._socket!==null&&(t._socket._writableState.finished?(i(),r._readableState.endEmitted&&r.destroy()):(t._socket.once("finish",function(){i()}),t.close()))},r._read=function(){t.isPaused&&t.resume()},r._write=function(i,n,o){if(t.readyState===t.CONNECTING){t.once("open",function(){r._write(i,n,o)});return}t.send(i,o)},r.on("end",fr),r.on("error",Ut),r}It.exports=lr});var De=S((jr,Ft)=>{"use strict";var{tokenChars:hr}=$();function cr(t){let e=new Set,s=-1,r=-1,i=0;for(i;i{"use strict";var ur=g("events"),_e=g("http"),{Duplex:Vr}=g("stream"),{createHash:dr}=g("crypto"),Mt=le(),D=G(),_r=De(),mr=de(),{CLOSE_TIMEOUT:pr,GUID:gr,kWebSocket:yr}=O(),xr=/^[+/0-9A-Za-z]{22}==$/,At=0,Wt=1,Gt=2,Fe=class extends ur{constructor(e,s){if(super(),e={allowSynchronousEvents:!0,autoPong:!0,maxBufferedChunks:256*1024,maxFragments:16*1024,maxPayload:100*1024*1024,skipUTF8Validation:!1,perMessageDeflate:!1,handleProtocols:null,clientTracking:!0,closeTimeout:pr,verifyClient:null,noServer:!1,backlog:null,server:null,host:null,path:null,port:null,WebSocket:mr,...e},e.port==null&&!e.server&&!e.noServer||e.port!=null&&(e.server||e.noServer)||e.server&&e.noServer)throw new TypeError('One and only one of the "port", "server", or "noServer" options must be specified');if(e.port!=null?(this._server=_e.createServer((r,i)=>{let n=_e.STATUS_CODES[426];i.writeHead(426,{"Content-Length":n.length,"Content-Type":"text/plain"}),i.end(n)}),this._server.listen(e.port,e.host,e.backlog,s)):e.server&&(this._server=e.server),this._server){let r=this.emit.bind(this,"connection");this._removeListeners=Sr(this._server,{listening:this.emit.bind(this,"listening"),error:this.emit.bind(this,"error"),upgrade:(i,n,o)=>{this.handleUpgrade(i,n,o,r)}})}e.perMessageDeflate===!0&&(e.perMessageDeflate={}),e.clientTracking&&(this.clients=new Set,this._shouldEmitClose=!1),this.options=e,this._state=At}address(){if(this.options.noServer)throw new Error('The server is operating in "noServer" mode');return this._server?this._server.address():null}close(e){if(this._state===Gt){e&&this.once("close",()=>{e(new Error("The server is not running"))}),process.nextTick(te,this);return}if(e&&this.once("close",e),this._state!==Wt)if(this._state=Wt,this.options.noServer||this.options.server)this._server&&(this._removeListeners(),this._removeListeners=this._server=null),this.clients?this.clients.size?this._shouldEmitClose=!0:process.nextTick(te,this):process.nextTick(te,this);else{let s=this._server;this._removeListeners(),this._removeListeners=this._server=null,s.close(()=>{te(this)})}}shouldHandle(e){if(this.options.path){let s=e.url.indexOf("?");if((s!==-1?e.url.slice(0,s):e.url)!==this.options.path)return!1}return!0}handleUpgrade(e,s,r,i){s.on("error",qt);let n=e.headers["sec-websocket-key"],o=e.headers.upgrade,f=+e.headers["sec-websocket-version"];if(e.method!=="GET"){F(this,e,s,405,"Invalid HTTP method");return}if(o===void 0||o.toLowerCase()!=="websocket"){F(this,e,s,400,"Invalid Upgrade header");return}if(n===void 0||!xr.test(n)){F(this,e,s,400,"Missing or invalid Sec-WebSocket-Key header");return}if(f!==13&&f!==8){F(this,e,s,400,"Missing or invalid Sec-WebSocket-Version header",{"Sec-WebSocket-Version":"13, 8"});return}if(!this.shouldHandle(e)){se(s,400);return}let l=e.headers["sec-websocket-protocol"],a=new Set;if(l!==void 0)try{a=_r.parse(l)}catch{F(this,e,s,400,"Invalid Sec-WebSocket-Protocol header");return}let h=e.headers["sec-websocket-extensions"],c={};if(this.options.perMessageDeflate&&h!==void 0){let _=new D({...this.options.perMessageDeflate,isServer:!0,maxPayload:this.options.maxPayload});try{let p=Mt.parse(h);p[D.extensionName]&&(_.accept(p[D.extensionName]),c[D.extensionName]=_)}catch{F(this,e,s,400,"Invalid or unacceptable Sec-WebSocket-Extensions header");return}}if(this.options.verifyClient){let _={origin:e.headers[`${f===8?"sec-websocket-origin":"origin"}`],secure:!!(e.socket.authorized||e.socket.encrypted),req:e};if(this.options.verifyClient.length===2){this.options.verifyClient(_,(p,m,u,x)=>{if(!p)return se(s,m||401,u,x);this.completeUpgrade(c,n,a,e,s,r,i)});return}if(!this.options.verifyClient(_))return se(s,401)}this.completeUpgrade(c,n,a,e,s,r,i)}completeUpgrade(e,s,r,i,n,o,f){if(!n.readable||!n.writable)return n.destroy();if(n[yr])throw new Error("server.handleUpgrade() was called more than once with the same socket, possibly due to a misconfiguration");if(this._state>At)return se(n,503);let a=["HTTP/1.1 101 Switching Protocols","Upgrade: websocket","Connection: Upgrade",`Sec-WebSocket-Accept: ${dr("sha1").update(s+gr).digest("base64")}`],h=new this.options.WebSocket(null,void 0,this.options);if(r.size){let c=this.options.handleProtocols?this.options.handleProtocols(r,i):r.values().next().value;c&&(a.push(`Sec-WebSocket-Protocol: ${c}`),h._protocol=c)}if(e[D.extensionName]){let c=e[D.extensionName].params,_=Mt.format({[D.extensionName]:[c]});a.push(`Sec-WebSocket-Extensions: ${_}`),h._extensions=e}this.emit("headers",a,i),n.write(a.concat(`\r +`).join(`\r +`)),n.removeListener("error",qt),h.setSocket(n,o,{allowSynchronousEvents:this.options.allowSynchronousEvents,maxBufferedChunks:this.options.maxBufferedChunks,maxFragments:this.options.maxFragments,maxPayload:this.options.maxPayload,skipUTF8Validation:this.options.skipUTF8Validation}),this.clients&&(this.clients.add(h),h.on("close",()=>{this.clients.delete(h),this._shouldEmitClose&&!this.clients.size&&process.nextTick(te,this)})),f(h,i)}};$t.exports=Fe;function Sr(t,e){for(let s of Object.keys(e))t.on(s,e[s]);return function(){for(let r of Object.keys(e))t.removeListener(r,e[r])}}function te(t){t._state=Gt,t.emit("close")}function qt(){this.destroy()}function se(t,e,s,r){s=s||_e.STATUS_CODES[e],r={Connection:"close","Content-Type":"text/html","Content-Length":Buffer.byteLength(s),...r},t.once("finish",t.destroy),t.end(`HTTP/1.1 ${e} ${_e.STATUS_CODES[e]}\r +`+Object.keys(r).map(i=>`${i}: ${r[i]}`).join(`\r +`)+`\r +\r +`+s)}function F(t,e,s,r,i,n){if(t.listenerCount("wsClientError")){let o=new Error(i);Error.captureStackTrace(o,F),t.emit("wsClientError",o,s,e)}else se(s,r,i,n)}});var Er=N(Dt(),1),br=N(le(),1),vr=N(G(),1),wr=N(Te(),1),kr=N(Ne(),1),Or=N(De(),1),Vt=N(de(),1),Tr=N(jt(),1);var Hr=Vt.default;var export_PerMessageDeflate=vr.default;var export_Receiver=wr.default;var export_Sender=kr.default;var export_WebSocket=Vt.default;var export_WebSocketServer=Tr.default;var export_createWebSocketStream=Er.default;var export_extension=br.default;var export_subprotocol=Or.default;export{export_PerMessageDeflate as PerMessageDeflate,export_Receiver as Receiver,export_Sender as Sender,export_WebSocket as WebSocket,export_WebSocketServer as WebSocketServer,export_createWebSocketStream as createWebSocketStream,Hr as default,export_extension as extension,export_subprotocol as subprotocol}; diff --git a/extensions/ext-node-websocket-ws/src/portable.ts b/extensions/ext-node-websocket-ws/src/portable.ts new file mode 100644 index 0000000000..887a5d7909 --- /dev/null +++ b/extensions/ext-node-websocket-ws/src/portable.ts @@ -0,0 +1,6 @@ +/** Pinned pure-JavaScript ws transport with no host environment or native addon reads. */ +// @ts-types="@types/ws" +import { WebSocket, WebSocketServer } from "./portable-runtime.generated.js"; +import { createWsProvider } from "./provider.ts"; + +export const PortableWsNodeWebSocketServerProvider = createWsProvider(WebSocketServer, WebSocket); diff --git a/extensions/ext-node-websocket-ws/src/provider.ts b/extensions/ext-node-websocket-ws/src/provider.ts new file mode 100644 index 0000000000..32fa3605f7 --- /dev/null +++ b/extensions/ext-node-websocket-ws/src/provider.ts @@ -0,0 +1,23 @@ +// @ts-types="@types/ws" +import type { WebSocket, WebSocketServer } from "ws"; +import { + captureNodeWebSocketServer, + createNodeWebSocketServerProvider, +} from "veryfront/extensions/websocket"; + +/** Preserve the transport contract for both native-optimized and portable ws. */ +export function createWsProvider( + Server: typeof WebSocketServer, + Connection: typeof WebSocket, +) { + return createNodeWebSocketServerProvider( + (options) => captureNodeWebSocketServer(new Server(options)), + { + supportsIdleTimeout: true, + getBufferedAmount(connection) { + if (!(connection instanceof Connection)) throw new TypeError("Unknown ws connection"); + return connection.bufferedAmount; + }, + }, + ); +} diff --git a/scripts/build/run-generate.test.ts b/scripts/build/run-generate.test.ts index e687638a1e..0a11f25b7d 100644 --- a/scripts/build/run-generate.test.ts +++ b/scripts/build/run-generate.test.ts @@ -86,6 +86,7 @@ describe("UNITS", () => { "dev-ui", "embedded-npm-packages", "hydration-runtime", + "portable-websocket-runtime", "rsc-scripts", "templates-manifest", "yaml-runtime", diff --git a/scripts/build/run-generate.ts b/scripts/build/run-generate.ts index a3f8ef9049..3070d11f74 100644 --- a/scripts/build/run-generate.ts +++ b/scripts/build/run-generate.ts @@ -44,6 +44,13 @@ export interface GeneratorUnit { } export const UNITS: GeneratorUnit[] = [ + { + name: "portable-websocket-runtime", + commands: [["deno", "run", "--frozen", "-A", "extensions/ext-node-websocket-ws/scripts/prebundle.ts"]], + inputRoots: ["extensions/ext-node-websocket-ws"], + inputFiles: ["deno.lock", "deno.json"], + outputs: ["extensions/ext-node-websocket-ws/src/portable-runtime.generated.js"], + }, { name: "yaml-runtime", commands: [["deno", "run", "--frozen", "-A", "extensions/ext-yaml/scripts/prebundle.ts"]], diff --git a/src/platform/adapters/runtime/deno/http-transport.ts b/src/platform/adapters/runtime/deno/http-transport.ts index 9371e8de55..11dadf0abd 100644 --- a/src/platform/adapters/runtime/deno/http-transport.ts +++ b/src/platform/adapters/runtime/deno/http-transport.ts @@ -1,7 +1,6 @@ import { getNativeDeno } from "../../../compat/http/native-response.ts"; import { scopeRequestAsyncContext } from "../../../compat/http/request-async-scope.ts"; import { getEnvOverlayStorage } from "../../../compat/process.ts"; -import { importFirstPartyExtensionModule } from "#veryfront/extensions/first-party-import.ts"; import { type NodeWebSocketServerProvider, snapshotNodeWebSocketServerProvider, @@ -31,8 +30,6 @@ export function scopeDenoNodeHttpHandler( export async function resolveDefaultDenoNodeWebSocketProvider(): Promise< Readonly > { - const extension = await importFirstPartyExtensionModule<{ - WsNodeWebSocketServerProvider: Readonly; - }>("ext-node-websocket-ws", "@veryfront/ext-node-websocket-ws"); - return snapshotNodeWebSocketServerProvider(extension.WsNodeWebSocketServerProvider); + const extension = await import("@veryfront/ext-node-websocket-ws/portable"); + return snapshotNodeWebSocketServerProvider(extension.PortableWsNodeWebSocketServerProvider); } diff --git a/src/platform/adapters/runtime/node/http-server.ts b/src/platform/adapters/runtime/node/http-server.ts index 362969cce2..47e40fba03 100644 --- a/src/platform/adapters/runtime/node/http-server.ts +++ b/src/platform/adapters/runtime/node/http-server.ts @@ -44,6 +44,13 @@ const pendingWebSocketUpgrades = new Map< >(); const NODE_WEBSOCKET_UPGRADE_TIMEOUT_MS = 30_000; +function isUnsupportedIncomingMethod(method: string): boolean { + // Fetch Request cannot represent these methods. Reject them explicitly before + // construction, rather than reporting a client request as a handler failure. + const normalized = method.toUpperCase(); + return normalized === "TRACE" || normalized === "TRACK" || normalized === "CONNECT"; +} + function isServerNotRunningError(error: Error): boolean { let current: object | null = error; while (current && current !== Error.prototype && current !== Object.prototype) { @@ -509,6 +516,11 @@ export function createNodeRequestListener( return; } const method = _req.method ?? "GET"; + if (isUnsupportedIncomingMethod(method)) { + _res.statusCode = 501; + _res.end("Not Implemented"); + return; + } const body = method === "GET" || method === "HEAD" ? null : _req; const headers = new Headers(); for (const [key, value] of Object.entries(_req.headers)) { @@ -857,6 +869,13 @@ async function createNodeServerInternal( ); return; } + if (isUnsupportedIncomingMethod(request.method ?? "GET")) { + await denyUpgrade( + new Response("Not Implemented", { status: 501 }), + new Error("Unsupported WebSocket request method"), + ); + return; + } const headersRecord: Record = {}; for (const [key, value] of Object.entries(request.headers)) { if (typeof value === "string") headersRecord[key] = value; diff --git a/src/server/production-server-bootstrap.test.ts b/src/server/production-server-bootstrap.test.ts index d71e714048..aa5de322d4 100644 --- a/src/server/production-server-bootstrap.test.ts +++ b/src/server/production-server-bootstrap.test.ts @@ -43,6 +43,32 @@ function createBootstrap(adapter: RuntimeAdapter, dispose: () => void): Bootstra } describe("production server bootstrap ownership", () => { + for (const provider of [undefined, null]) { + it(`preserves the supplied WebSocket provider policy ${provider}`, async () => { + const adapter = createAdapter(); + const serve = adapter.serve; + let observed: unknown = "listener not called"; + adapter.serve = (handler, options) => { + observed = options.nodeWebSocketServerProvider; + return serve(handler, options); + }; + const bootstrapResult = createBootstrap(adapter, () => {}); + if (provider === null) bootstrapResult.nodeWebSocketServerProvider = null; + const server = await startProductionServerWithDependencies({ + projectDir: "/app", + port: 0, + adapter, + bootstrapResult, + unhandledRejectionGuard: false, + }, { bootstrap: () => Promise.reject(new Error("unexpected bootstrap")) }); + try { + assertEquals(observed, provider); + } finally { + await server.stop(); + } + }); + } + for (const enabled of [true, false]) { it(`uses supplied bootstrap recycle policy ${enabled} over the obsolete parent policy`, async () => { const parentAdapter = createAdapter(); diff --git a/src/server/production-server.ts b/src/server/production-server.ts index d8eee087d1..cc8347157a 100644 --- a/src/server/production-server.ts +++ b/src/server/production-server.ts @@ -243,7 +243,7 @@ export function startProductionServerWithDependencies( const suppliedBootstrap = options.bootstrapResult; const suppliedProviderSource = suppliedBootstrap?.nodeWebSocketServerProvider; const suppliedNodeWebSocketServerProvider = suppliedProviderSource == null - ? null + ? suppliedProviderSource : snapshotNodeWebSocketServerProvider(suppliedProviderSource); let ownedBootstrap: BootstrapResult | undefined; let ownedBootstrapDisposal: Promise | undefined; @@ -501,7 +501,7 @@ export function startProductionServerWithDependencies( port, hostname: bindAddress, // Deno uses "hostname" for bind address signal, - nodeWebSocketServerProvider: nodeWebSocketServerProvider ?? null, + nodeWebSocketServerProvider, onListen: (params) => { resolveListenReady?.(); logger.info("Production server listening", params); diff --git a/tests/integration/core/deno-http-cancellation.test.ts b/tests/integration/core/deno-http-cancellation.test.ts index 4647d32756..9055f0f1f9 100644 --- a/tests/integration/core/deno-http-cancellation.test.ts +++ b/tests/integration/core/deno-http-cancellation.test.ts @@ -9,6 +9,45 @@ async function writeAll(connection: Deno.Conn, text: string): Promise { while (offset < bytes.length) offset += await connection.write(bytes.subarray(offset)); } +for (const transport of ["native", "selected", "selected-upgrade"] as const) { + it(`handles TRACE deliberately through ${transport} HTTP`, async () => { + const runtime = new DenoAdapter(); + let calls = 0; + const handler = (request: Request) => { + calls++; + assertEquals(request.method, "TRACE"); + return new Response("Method Not Allowed", { status: 405 }); + }; + const options = { hostname: "127.0.0.1", port: 0, onListen() {} }; + const native = transport === "native" ? Deno.serve(options, handler) : undefined; + const selected = native ? undefined : await runtime.serve(handler, options); + const connection = await Deno.connect({ + hostname: "127.0.0.1", + port: (native?.addr ?? selected!.addr).port, + }); + try { + const upgrade = transport === "selected-upgrade" + ? "Connection: Upgrade\r\nUpgrade: websocket\r\n" + : "Connection: close\r\n"; + await writeAll(connection, `TRACE / HTTP/1.1\r\nHost: localhost\r\n${upgrade}\r\n`); + let response = ""; + while (true) { + const buffer = new Uint8Array(4096); + const read = await connection.read(buffer); + if (read === null) break; + response += new TextDecoder().decode(buffer.subarray(0, read)); + } + const expectedStatus = transport === "native" ? 405 : 501; + assertEquals(response.startsWith(`HTTP/1.1 ${expectedStatus}`), true, response); + assertEquals(calls, transport === "native" ? 1 : 0); + } finally { + connection.close(); + await native?.shutdown(); + await selected?.stop(); + } + }); +} + describe("Deno HTTP request cancellation", () => { for (const transport of ["compatibility", "runtime"] as const) { it(`observes disconnect after consuming a segmented body through ${transport}`, async () => { diff --git a/tests/integration/core/deno-http-permissions.test.ts b/tests/integration/core/deno-http-permissions.test.ts index 4e81f20133..7778304ae7 100644 --- a/tests/integration/core/deno-http-permissions.test.ts +++ b/tests/integration/core/deno-http-permissions.test.ts @@ -1,4 +1,42 @@ import { assertEquals } from "#veryfront/testing/assert.ts"; +import { it } from "#veryfront/testing/bdd.ts"; + +it("implicit Deno WebSocket upgrades work with network permission alone", async () => { + const runtime = + new URL("../../../src/platform/adapters/runtime/deno/http-server.ts", import.meta.url).href; + const adapter = + new URL("../../../src/platform/adapters/runtime/deno/websocket-adapter.ts", import.meta.url) + .href; + const source = ` +import { createDenoServer } from ${JSON.stringify(runtime)}; +import { DenoServerAdapter } from ${JSON.stringify(adapter)}; +const adapter = new DenoServerAdapter(); +const server = await createDenoServer((request) => { + const {socket,response} = adapter.upgradeWebSocket(request,{idleTimeout:0}); + socket.onmessage = (event) => socket.send(event.data); + return response; +},{hostname:"127.0.0.1",port:0}); +const client = new WebSocket("ws://127.0.0.1:"+server.addr.port); +const closed = new Promise((resolve) => client.onclose = resolve); +try { + await new Promise((resolve,reject) => {client.onopen=resolve;client.onerror=()=>reject(Error("upgrade failed"));}); + const reply = new Promise((resolve) => client.onmessage=(event)=>resolve(event.data)); + client.send("net-only"); + if(await reply!=="net-only")throw Error("echo mismatch"); +} finally {client.close();await closed;await server.stop();} +`; + const child = new Deno.Command(Deno.execPath(), { + args: ["run", "--config=deno.json", "--allow-net=127.0.0.1", "--no-prompt", "-"], + stdin: "piped", + stdout: "piped", + stderr: "piped", + }).spawn(); + const writer = child.stdin.getWriter(); + await writer.write(new TextEncoder().encode(source)); + await writer.close(); + const output = await child.output(); + assertEquals(output.code, 0, new TextDecoder().decode(output.stderr)); +}); Deno.test("HTTP-only Deno listeners require network permission without env or read grants", async () => { const runtime = diff --git a/tests/integration/core/deno-http2-transport.test.ts b/tests/integration/core/deno-http2-transport.test.ts index db957c0c2f..13521164c1 100644 --- a/tests/integration/core/deno-http2-transport.test.ts +++ b/tests/integration/core/deno-http2-transport.test.ts @@ -432,6 +432,31 @@ it("Deno transport rejects malformed HTTP1 and h2 authorities before handlers", } }); +it("Deno h2c rejects Fetch-forbidden methods before application dispatch", async () => { + const runtime = new DenoAdapter(); + let calls = 0; + const server = await runtime.serve(() => { + calls++; + return new Response("unexpected"); + }, { hostname: "127.0.0.1", port: 0, nodeWebSocketServerProvider: null }); + const client = connect(`http://127.0.0.1:${server.addr.port}`); + try { + for (const method of ["TRACE", "trace", "TRACK", "track"]) { + const status = Promise.withResolvers(); + const request = client.request({ ":path": "/", ":method": method }); + request.on("response", (headers) => status.resolve(Number(headers[":status"]))); + request.on("error", status.reject); + request.resume(); + request.end(); + assertEquals(await bounded(status.promise), 501, method); + } + assertEquals(calls, 0); + } finally { + client.destroy(); + await runtime.shutdown(); + } +}); + for (const code of ["EADDRINUSE", "ENOTFOUND"]) { it(`Deno onListen preserves callback ${code} error and releases listener`, async () => { const runtime = new DenoAdapter(); From e3cd1182442b3daf5a667180f67e8218a2283e4c Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Thu, 29 Oct 2026 14:32:36 +0100 Subject: [PATCH 22/22] Retire incomplete transports and patch the URI parser pin --- deno.lock | 4 +-- scripts/build/proxy-deno.lock | 4 +-- .../embedded-npm-packages.generated.ts | 4 +-- .../runtime/node/dual-protocol-server.ts | 10 +++++-- .../runtime/node/websocket-adapter.test.ts | 26 +++++++++++++++++++ .../runtime/node/websocket-adapter.ts | 12 ++++++--- .../core/deno-http2-transport.test.ts | 26 +++++++++++++++++++ 7 files changed, 74 insertions(+), 12 deletions(-) diff --git a/deno.lock b/deno.lock index c4a2232e23..fe72fb323b 100644 --- a/deno.lock +++ b/deno.lock @@ -2927,8 +2927,8 @@ "fast-deep-equal@3.1.3": { "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" }, - "fast-uri@3.1.8": { - "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==" + "fast-uri@3.1.9": { + "integrity": "sha512-hS/0Wt3Ya043oKXs0sIxrMpKQITXhMLIrVAKKXi4gozGC9v7YRWVnXIZzjNPTO02M9BdxLCWqK1yRXgNziVyJQ==" }, "fault@2.0.1": { "integrity": "sha512-WtySTkS4OKev5JtpHXnib4Gxiurzh5NCGvWrFaZ34m6JehfTUhKZvn9njTfw48t6JumVQOmrKqpmGcdwxnhqBQ==", diff --git a/scripts/build/proxy-deno.lock b/scripts/build/proxy-deno.lock index eda0d92559..b24e962931 100644 --- a/scripts/build/proxy-deno.lock +++ b/scripts/build/proxy-deno.lock @@ -1130,8 +1130,8 @@ "fast-deep-equal@3.1.3": { "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" }, - "fast-uri@3.1.8": { - "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==" + "fast-uri@3.1.9": { + "integrity": "sha512-hS/0Wt3Ya043oKXs0sIxrMpKQITXhMLIrVAKKXi4gozGC9v7YRWVnXIZzjNPTO02M9BdxLCWqK1yRXgNziVyJQ==" }, "fetch-blob@3.2.0": { "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", diff --git a/src/discovery/embedded-npm-packages.generated.ts b/src/discovery/embedded-npm-packages.generated.ts index 15dbf6632c..923f323b7b 100644 --- a/src/discovery/embedded-npm-packages.generated.ts +++ b/src/discovery/embedded-npm-packages.generated.ts @@ -388,7 +388,7 @@ export const EMBEDDED_NPM_PACKAGES: Readonly> "eventsource-parser": ["3.1.1"], "extend": ["3.0.2"], "fast-deep-equal": ["3.1.3"], - "fast-uri": ["3.1.8"], + "fast-uri": ["3.1.9"], "fault": ["2.0.1"], "fetch-blob": ["3.2.0"], "format": ["0.2.2"], @@ -798,7 +798,7 @@ export const PROXY_EMBEDDED_NPM_PACKAGES: Readonly { clearTimeout(deadline); socket.off("data", data); - socket.off("end", cleanup); + socket.off("end", retireIncomplete); socket.off("close", cleanup); socket.off("error", cleanup); }; + const retireIncomplete = () => { + cleanup(); + // The HTTP owner allows half-open sockets, but no protocol parser owns + // this connection yet. EOF must retire both sides rather than its timer. + socket.destroy(); + }; const data = (chunk: Uint8Array) => { socket.pause(); const bytes = new Uint8Array(buffered.length + chunk.length); @@ -95,7 +101,7 @@ export class DualProtocolServer extends HttpServer { socket.resume(); }; socket.on("data", data); - socket.once("end", cleanup); + socket.once("end", retireIncomplete); socket.once("close", cleanup); socket.once("error", cleanup); } diff --git a/src/platform/adapters/runtime/node/websocket-adapter.test.ts b/src/platform/adapters/runtime/node/websocket-adapter.test.ts index 025f9a49f4..c9c257493a 100644 --- a/src/platform/adapters/runtime/node/websocket-adapter.test.ts +++ b/src/platform/adapters/runtime/node/websocket-adapter.test.ts @@ -513,6 +513,32 @@ describe("NodeWebSocket EventTarget compatibility", () => { }); describe("NodeWebSocket Deno idle timeout parity", () => { + it("closes an incompatible provider transport without terminate support", () => { + const events: string[] = []; + const socket = new NodeWebSocket(30); + const ws = createMockWs(); + ws.close = (code) => events.push(`transport-close:${code}`); + socket.onerror = () => events.push("error"); + socket.onclose = () => events.push("close"); + socket._attachRealSocket(ws); + assertEquals(events, ["transport-close:1011", "error", "close"]); + assertEquals(socket.readyState, NodeWebSocket.CLOSED); + }); + + it("settles an incompatible provider even when its close callback fails", () => { + const socket = new NodeWebSocket(30); + const ws = createMockWs(); + const failure = new Error("provider close failed"); + let closed = false; + ws.close = () => { + throw failure; + }; + socket.onclose = () => closed = true; + assertStrictEquals(assertThrows(() => socket._attachRealSocket(ws)), failure); + assertEquals(closed, true); + assertEquals(socket.readyState, NodeWebSocket.CLOSED); + }); + it("terminates the transport once when heartbeat ping throws", async () => { const socket = new NodeWebSocket(0.02); const ws = createMockWs(); diff --git a/src/platform/adapters/runtime/node/websocket-adapter.ts b/src/platform/adapters/runtime/node/websocket-adapter.ts index 1ee60e6178..13739c6f67 100644 --- a/src/platform/adapters/runtime/node/websocket-adapter.ts +++ b/src/platform/adapters/runtime/node/websocket-adapter.ts @@ -163,10 +163,14 @@ export class NodeWebSocket extends DeferredWebSocket { if ( this.idleTimeout > 0 && (typeof ws.ping !== "function" || typeof ws.terminate !== "function") ) { - ws.terminate?.(); - this.fail( - new Error("WebSocket provider does not support idle timeout heartbeat and termination"), - ); + try { + if (typeof ws.terminate === "function") ws.terminate(); + else ws.close(1011, "WebSocket provider does not support idle timeout"); + } finally { + this.fail( + new Error("WebSocket provider does not support idle timeout heartbeat and termination"), + ); + } return; } const resetIdleTimer = (): void => { diff --git a/tests/integration/core/deno-http2-transport.test.ts b/tests/integration/core/deno-http2-transport.test.ts index 13521164c1..fedd66a82b 100644 --- a/tests/integration/core/deno-http2-transport.test.ts +++ b/tests/integration/core/deno-http2-transport.test.ts @@ -151,6 +151,32 @@ it("h2c reset after streamed body EOF aborts only its stream and shutdown owns s } }); +for (const prefix of ["", "PRI * HTTP/2.0\r\n"]) { + it(`closes a half-open connection ending during protocol detection (${prefix.length} bytes)`, async () => { + let requests = 0; + const server = new DualProtocolServer((_request, response) => { + requests++; + response.end("unexpected"); + }, 50); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + assert(address && typeof address !== "string"); + const client = connectTcp({ host: "127.0.0.1", port: address.port }); + const closed = new Promise((resolve) => client.once("close", resolve)); + client.on("error", () => {}); + try { + await new Promise((resolve) => client.once("connect", resolve)); + client.end(prefix); + await bounded(closed); + assertEquals(requests, 0); + } finally { + client.destroy(); + server.closeAllConnections(); + await new Promise((resolve) => server.close(() => resolve())); + } + }); +} + it("dual protocol preface handles fragmentation, EOF, malformed input and timeout", async () => { const server = new DualProtocolServer((_request, response) => response.end("ok"), 500); await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));