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/deno.json b/deno.json index 2732cb1886..04f6e53cbe 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", @@ -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/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/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 1db7c23263..6114e4bb29 100644 --- a/extensions/ext-node-websocket-ws/src/index.ts +++ b/extensions/ext-node-websocket-ws/src/index.ts @@ -1,17 +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 { WebSocketServer } from "ws"; +import { WebSocket, WebSocketServer } from "ws"; +import { createWsProvider } from "./provider.ts"; -export const WsNodeWebSocketServerProvider = createNodeWebSocketServerProvider( - (options) => captureNodeWebSocketServer(new WebSocketServer(options)), -); +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/proxy-deno.lock b/scripts/build/proxy-deno.lock index 6b5ef05d99..b24e962931 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==" }, @@ -1122,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==", @@ -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/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/agent/hosted/veryfront-cloud-agent-service.test.ts b/src/agent/hosted/veryfront-cloud-agent-service.test.ts index af976a9b60..619c9380f4 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; @@ -1074,6 +1078,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; @@ -1101,6 +1108,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"]); @@ -1142,6 +1150,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; @@ -1169,6 +1180,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"]); diff --git a/src/discovery/embedded-npm-packages.generated.ts b/src/discovery/embedded-npm-packages.generated.ts index 489a08be79..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"], @@ -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.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 c55dc1e621..7779c4f847 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, + resolveDefaultDenoNodeWebSocketProvider, + 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. @@ -52,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")); } /** @@ -87,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; @@ -268,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 @@ -374,7 +390,7 @@ export async function createDenoServerWithRuntime( return server; } -export function createDenoServer( +export async function createDenoServer( handler: DenoRequestHandler, options: ServeOptions = {}, ): Promise { @@ -385,6 +401,28 @@ export function createDenoServer( context: { platform: "deno", operation: "serve" }, }); } + if (needsDenoNodeHttpTransport()) { + let didListen = false; + try { + return await createDenoNodeServerWithStartupOwner( + scopeDenoNodeHttpHandler(handler), + { + ...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); + } + } 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..3bdd5cadb6 --- /dev/null +++ b/src/platform/adapters/runtime/deno/http-transport.test.ts @@ -0,0 +1,10 @@ +import { it } from "#veryfront/testing/bdd.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); + for (const version of ["2.7.12", "2.9.6", "2.9.9", "3.0.0"]) { + assertEquals(needsDenoNodeHttpTransport(version), false); + } +}); 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..11dadf0abd --- /dev/null +++ b/src/platform/adapters/runtime/deno/http-transport.ts @@ -0,0 +1,35 @@ +import { getNativeDeno } from "../../../compat/http/native-response.ts"; +import { scopeRequestAsyncContext } from "../../../compat/http/request-async-scope.ts"; +import { getEnvOverlayStorage } from "../../../compat/process.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); + }); +} + +/** Load the implicit provider only when an authorized WebSocket upgrade needs it. */ +export async function resolveDefaultDenoNodeWebSocketProvider(): Promise< + Readonly +> { + const extension = await import("@veryfront/ext-node-websocket-ws/portable"); + return snapshotNodeWebSocketServerProvider(extension.PortableWsNodeWebSocketServerProvider); +} 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 new file mode 100644 index 0000000000..2b94105c25 --- /dev/null +++ b/src/platform/adapters/runtime/deno/node-websocket.ts @@ -0,0 +1,232 @@ +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; + 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 pendingSendBytes = 0; + 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; + this.pendingSendBytes = 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.pendingSendBytes + 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 (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); + return; + } + this.pendingSendBytes += this.sendByteLength(normalized); + 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.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; + if (close) { + try { + this.inner.close(close.code, close.reason); + } catch (error) { + this.inner._emitError(error instanceof Error ? error : new Error(String(error))); + } + } + } + } + + 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, + 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.ts b/src/platform/adapters/runtime/node/dual-protocol-server.ts new file mode 100644 index 0000000000..336c3a2780 --- /dev/null +++ b/src/platform/adapters/runtime/node/dual-protocol-server.ts @@ -0,0 +1,114 @@ +import { Socket } from "node:net"; +import { type IncomingMessage, Server as HttpServer, type ServerResponse } from "node:http"; +import { + createServer as createHttp2Server, + type Http2ServerRequest, + type Http2ServerResponse, + type ServerHttp2Session, +} from "node:http2"; + +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"); + +/** + * 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 http2; + + constructor( + listener: ( + request: IncomingMessage | Http2ServerRequest, + response: ServerResponse | Http2ServerResponse, + ) => void, + private readonly prefaceTimeoutMs = 10_000, + ) { + super(listener); + this.http2 = createHttp2Server(listener); + 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])); + }); + } + + // Delay only connection dispatch until its public protocol parser is known. + override emit(event: string | symbol, ...args: unknown[]): boolean { + if (event === "connection" && isSocket(args[0])) { + this.route(args[0]); + return true; + } + return apply(emitHttpServerEvent, this, [event, ...args]); + } + + 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", 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); + 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); + if (matches) this.http2.emit("connection", socket); + else apply(emitHttpServerEvent, this, ["connection", socket]); + socket.resume(); + }; + socket.on("data", data); + socket.once("end", retireIncomplete); + socket.once("close", cleanup); + socket.once("error", cleanup); + } + + override closeAllConnections(): void { + apply(setForEach, this.sessions, [(session: ServerHttp2Session) => session.destroy()]); + super.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..47e40fba03 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 { @@ -19,6 +20,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< @@ -27,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) { @@ -266,7 +290,7 @@ function reportNodeRuntimeError( } function retireLateNodeListener( - server: import("node:http").Server, + server: NativeListener, onSettled: () => void, ): void { let settled = false; @@ -307,19 +331,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 +352,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()); @@ -445,9 +469,10 @@ export type NodeRequestHandler = ( export function createNodeRequestListener( handler: NodeRequestHandler, fallbackHostname = "localhost", + flushResponseHeaders = false, ): ( - 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 +502,37 @@ 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"; + 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"; + 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)) { + 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,27 +567,65 @@ 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 (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. + // Preserve that contract for its selected transport without changing Node. + if (flushResponseHeaders) { + 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(); } - if (!_res.write(value)) { + const writable = writer.write(value); + if (!writable) { await waitForResponseDrain(_res, requestAbort.signal); } } @@ -588,10 +671,22 @@ 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, + resolveDefaultProvider?: () => Promise>, +): Promise { + return createNodeServerInternal(handler, options, owner, true, resolveDefaultProvider); +} + async function createNodeServerInternal( handler: NodeRequestHandler, options: ServeOptions, ownStartupServer?: (server: NodeServer) => void, + dualProtocol = false, + resolveDefaultProvider?: () => Promise>, ): Promise { const { port = DEFAULT_PORT, @@ -600,9 +695,11 @@ async function createNodeServerInternal( onRuntimeError, signal, } = options; - const nodeWebSocketServerProvider = options.nodeWebSocketServerProvider === undefined + 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"); } @@ -692,7 +789,10 @@ async function createNodeServerInternal( return attempt; }; - const server = createServer(createNodeRequestListener(handler, hostname)); + const listener = createNodeRequestListener(handler, hostname, dualProtocol); + const server = dualProtocol + ? new (await import("./dual-protocol-server.ts")).DualProtocolServer(listener) + : createServer(listener); const upgradeListener = ( request: import("node:http").IncomingMessage, @@ -706,8 +806,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); @@ -726,6 +828,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) { @@ -735,7 +859,23 @@ 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; + } + 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; @@ -743,7 +883,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 +895,17 @@ 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"); + await denyUpgrade(response, denied); + return; + } if (upgradesDisposed || !isWebSocketUpgradeResponse(response)) { failUpgrade( new Error( @@ -780,13 +929,36 @@ 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 && !provider.supportsIdleTimeout) { + failUpgrade(new Error("WebSocket provider does not support the requested idle timeout")); + return; + } + if (isDenoWebSocketResponse(response) && !provider.getBufferedAmount) { + failUpgrade( + new Error("Deno WebSocket provider must expose its transport buffered amount"), + ); + return; + } if (!wsServer) { const candidate = captureNodeWebSocketServer( - nodeWebSocketServerProvider.createServer( + provider.createServer( handshakeController.serverOptions, ), ); @@ -816,6 +988,10 @@ async function createNodeServerInternal( } transportSettled = true; clearTransportState(); + const getBufferedAmount = provider.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..c9c257493a 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,155 @@ describe("NodeWebSocket EventTarget compatibility", () => { assertEquals(socket.readyState, NodeWebSocket.CLOSED); }); }); + +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(); + 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..13739c6f67 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,96 @@ 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") + ) { + 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 => { + 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 +229,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.ts b/src/platform/adapters/runtime/shared/http-upgrade-provenance.ts new file mode 100644 index 0000000000..6efbeb641a --- /dev/null +++ b/src/platform/adapters/runtime/shared/http-upgrade-provenance.ts @@ -0,0 +1,79 @@ +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 +// 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: HttpHandlerResponse, + seconds: number, +): void { + apply(mapSet, idleTimeouts, [response, seconds]); +} + +/** @internal Read the policy attached by the trusted upgrade adapter. */ +export function getWebSocketUpgradeIdleTimeout(response: HttpHandlerResponse): 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: HttpHandlerResponse): void { + apply(setAdd, domResponses, [response]); +} +export function isDenoWebSocketResponse(response: HttpHandlerResponse): boolean { + return apply(setHas, domResponses, [response]); +} +export function recordWebSocketBufferedAmount( + connection: NodeWebSocketConnection, + read: () => number, +): void { + apply(mapSet, bufferedAmounts, [connection, read]); +} +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"); + } + return read(); +} 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/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..715906d87e 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, + resolveDefaultDenoNodeWebSocketProvider, + 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,71 @@ 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((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 listener startup 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 = { + ...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 +199,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 +211,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.test.ts b/src/proxy/main.test.ts index 14011bbc18..4fee468e35 100644 --- a/src/proxy/main.test.ts +++ b/src/proxy/main.test.ts @@ -54,7 +54,10 @@ 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);"); + // The router's catch, not the WebSocket upgrade's early completion. + const catchIndex = source.indexOf( + "} catch (error) {\n proxyRequestDrainTracker.complete(requestId);", + ); assertEquals(catchIndex >= 0, true); // While draining, error responses close their connection too. assertStringIncludes( diff --git a/src/proxy/main.ts b/src/proxy/main.ts index e30e25159f..1dc081a1e7 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) * @@ -330,7 +334,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, @@ -848,7 +852,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); @@ -884,7 +888,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) { @@ -906,6 +910,10 @@ async function router(req: Request): Promise { response = await forwardToServer(req, url); } + if (isWebSocketUpgradeResponse(response)) { + proxyRequestDrainTracker.complete(requestId); + return response; + } // Read after the await: shutdown may have started while this request ran. response = drainAdmission.closeIfDraining(req, response, admittedDuringDrain); return proxyRequestDrainTracker.completeOnResponseEnd(requestId, req, response); @@ -1084,4 +1092,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 0b5876e6a8..50f72a3c8e 100644 --- a/src/server/bootstrap.ts +++ b/src/server/bootstrap.ts @@ -85,7 +85,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), @@ -386,7 +386,7 @@ export async function bootstrap( config, usingFSAdapter: false, extensionLoader, - ...(nodeWebSocketServerProvider === undefined ? {} : { nodeWebSocketServerProvider }), + nodeWebSocketServerProvider: nodeWebSocketServerProvider ?? null, dispose: combineDispose(extensionLoader, undefined, fileLog, telemetryDispose), }; } @@ -439,7 +439,7 @@ export async function bootstrap( config, usingFSAdapter: false, extensionLoader, - ...(nodeWebSocketServerProvider === undefined ? {} : { nodeWebSocketServerProvider }), + nodeWebSocketServerProvider: nodeWebSocketServerProvider ?? null, dispose: combineDispose(extensionLoader, undefined, fileLog, telemetryDispose), }; } @@ -524,7 +524,7 @@ export async function bootstrap( usingFSAdapter: true, fsAdapterType: fsType, extensionLoader, - ...(nodeWebSocketServerProvider === undefined ? {} : { nodeWebSocketServerProvider }), + nodeWebSocketServerProvider: nodeWebSocketServerProvider ?? null, dispose: combineDispose(extensionLoader, fsDispose, fileLog, telemetryDispose), }; } catch (err) { diff --git a/src/server/dev-server/server.ts b/src/server/dev-server/server.ts index 667e018897..f203e28cc0 100644 --- a/src/server/dev-server/server.ts +++ b/src/server/dev-server/server.ts @@ -85,7 +85,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; @@ -320,7 +320,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}`); @@ -352,7 +352,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..c5429b3ebc 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -21,6 +21,14 @@ * @see docs/security.md */ +import { + getWebSocketUpgradeIdleTimeout, + isDenoWebSocketResponse, + recordNodeWebSocketUpgradeRequest, + recordWebSocketBufferedAmount, + revokeNodeWebSocketUpgradeRequest, +} from "#veryfront/platform/adapters/runtime/shared/http-upgrade-provenance.ts"; + import { DevServer, type DevServerHandler, @@ -218,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; @@ -309,7 +318,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 +393,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 +430,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 +466,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-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-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/src/server/production-server.ts b/src/server/production-server.ts index 3080980b63..cc8347157a 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 + ? suppliedProviderSource : snapshotNodeWebSocketServerProvider(suppliedProviderSource); let ownedBootstrap: BootstrapResult | undefined; let ownedBootstrapDisposal: Promise | undefined; 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 2b9dc2ed85..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. */ @@ -53,7 +55,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. */ @@ -64,8 +66,8 @@ export type StartVeryfrontServerOptions = { logger?: VeryfrontServiceServerLogger; signals?: readonly NodeJS.Signals[]; hardShutdownTimeoutMs?: number; - /** Used only when the selected runtime is Node.js. */ - nodeWebSocketServerProvider?: Readonly; + /** Used by Node.js and affected Deno HTTP transports. */ + nodeWebSocketServerProvider?: Readonly | null; }; /** Public API contract for veryfront service server runtime kind. */ @@ -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/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 }); 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..9055f0f1f9 --- /dev/null +++ b/tests/integration/core/deno-http-cancellation.test.ts @@ -0,0 +1,345 @@ +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)); +} + +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 () => { + 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 asynchronous startup 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; + } +}); + +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..7778304ae7 --- /dev/null +++ b/tests/integration/core/deno-http-permissions.test.ts @@ -0,0 +1,75 @@ +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 = + 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 new file mode 100644 index 0000000000..fedd66a82b --- /dev/null +++ b/tests/integration/core/deno-http2-transport.test.ts @@ -0,0 +1,515 @@ +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"; + +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(); + } +}); + +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)); + 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()) + ), + ); + } +}); + +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 += typeof chunk === "string" ? chunk : 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()); + } + }); +} + +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(); + } + }); + } + } +} + +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(); +}); + +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 { + 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 { + 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(); + } +}); + +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(); + 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 new file mode 100644 index 0000000000..60fed723f4 --- /dev/null +++ b/tests/integration/core/deno-node-http-upgrade.test.ts @@ -0,0 +1,286 @@ +import { describe, it } from "#veryfront/testing/bdd.ts"; +import { assertEquals, assertStringIncludes, assertThrows } from "#veryfront/testing/assert.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"; + +// 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(); + } + }); +}); + +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(); + } +}); + +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(); + } +}); 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..d64946a760 --- /dev/null +++ b/tests/integration/core/deno-websocket-dom.test.ts @@ -0,0 +1,261 @@ +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])); + 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" }]); + 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 === (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 { + await completed.promise; + assertEquals(await Promise.all(received), [ + [102, 105, 114, 115, 116], + [2], + ...(transport === "native" ? [] : [[8, 9], [8, 9]]), + "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"])); + // 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); + } + 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; + } + }); +} diff --git a/tests/integration/core/dual-protocol-ownership.test.ts b/tests/integration/core/dual-protocol-ownership.test.ts new file mode 100644 index 0000000000..e22ed12c7c --- /dev/null +++ b/tests/integration/core/dual-protocol-ownership.test.ts @@ -0,0 +1,71 @@ +import { Socket } from "node:net"; +import { it } from "#veryfront/testing/bdd.ts"; +import { assertEquals } from "#veryfront/testing/assert.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(() => {}); + 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); +}); + +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); +}); 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..9457a0a6f9 --- /dev/null +++ b/tests/integration/core/hosted-agent-startup-failure.test.ts @@ -0,0 +1,110 @@ +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 { withTempDir } from "#veryfront/testing/deno-compat.ts"; +import { stop as stopEsbuild } from "veryfront/extensions/bundler"; + +async function withAgentFixture(fn: (dir: string) => Promise): Promise { + register(SandboxShellToolsProviderName, () => Promise.resolve({ tools: {} })); + try { + await withTempDir(fn); + } finally { + await stopEsbuild(); + 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 withAgentFixture(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/http-upgrade-provenance.test.ts b/tests/integration/core/http-upgrade-provenance.test.ts new file mode 100644 index 0000000000..9bf6f03371 --- /dev/null +++ b/tests/integration/core/http-upgrade-provenance.test.ts @@ -0,0 +1,76 @@ +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 "#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" } }); + const clone = request.clone(); + 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; + 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/tests/integration/server/public-handler-websocket.test.ts b/tests/integration/server/public-handler-websocket.test.ts new file mode 100644 index 0000000000..21d53aefc9 --- /dev/null +++ b/tests/integration/server/public-handler-websocket.test.ts @@ -0,0 +1,59 @@ +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", () => { + 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(); + } + }); + }); + } +});