From 4acd7487e03cb46b06725e9a2b4ba10908738cfd Mon Sep 17 00:00:00 2001 From: "blink-so[bot]" <211532188+blink-so[bot]@users.noreply.github.com> Date: Mon, 15 Dec 2025 13:34:00 +0000 Subject: [PATCH 01/14] feat: add devhook package for exposing local servers via public URLs This package provides: - DevhookClient for connecting to a devhook server and handling proxied requests - Cloudflare Worker server implementation with Durable Objects for session state - Local server implementation for testing - Secure URL generation using HMAC-SHA256 - Support for both wildcard subdomain and subpath routing modes - Full HTTP and WebSocket proxy support --- packages/devhook/README.md | 219 ++++++++ packages/devhook/package.json | 53 ++ packages/devhook/src/client/index.ts | 477 ++++++++++++++++++ packages/devhook/src/devhook.test.ts | 169 +++++++ packages/devhook/src/emitter.ts | 35 ++ packages/devhook/src/index.ts | 67 +++ packages/devhook/src/schema.ts | 103 ++++ packages/devhook/src/server/cloudflare.ts | 183 +++++++ packages/devhook/src/server/crypto.ts | 65 +++ packages/devhook/src/server/durable-object.ts | 335 ++++++++++++ packages/devhook/src/server/local.ts | 354 +++++++++++++ packages/devhook/src/server/worker.ts | 265 ++++++++++ packages/devhook/tsconfig.json | 10 + packages/devhook/tsdown.config.ts | 8 + packages/devhook/wrangler.toml | 41 ++ 15 files changed, 2384 insertions(+) create mode 100644 packages/devhook/README.md create mode 100644 packages/devhook/package.json create mode 100644 packages/devhook/src/client/index.ts create mode 100644 packages/devhook/src/devhook.test.ts create mode 100644 packages/devhook/src/emitter.ts create mode 100644 packages/devhook/src/index.ts create mode 100644 packages/devhook/src/schema.ts create mode 100644 packages/devhook/src/server/cloudflare.ts create mode 100644 packages/devhook/src/server/crypto.ts create mode 100644 packages/devhook/src/server/durable-object.ts create mode 100644 packages/devhook/src/server/local.ts create mode 100644 packages/devhook/src/server/worker.ts create mode 100644 packages/devhook/tsconfig.json create mode 100644 packages/devhook/tsdown.config.ts create mode 100644 packages/devhook/wrangler.toml diff --git a/packages/devhook/README.md b/packages/devhook/README.md new file mode 100644 index 0000000..c074a97 --- /dev/null +++ b/packages/devhook/README.md @@ -0,0 +1,219 @@ +# @blink-sdk/devhook + +Expose local servers via a public URL. Perfect for webhooks, API testing, and development. + +## Features + +- **Secure URLs**: Client secrets are signed with HMAC-SHA256 to generate deterministic, unguessable subdomains +- **Flexible routing**: Support for wildcard subdomains (`abc123.devhook.example.com`) or subpath routing (`example.com/devhook/abc123`) +- **WebSocket support**: Full bidirectional WebSocket proxying +- **Persistent sessions**: Durable Object state survives restarts +- **Local testing**: Run the server locally with Node.js + +## Installation + +```bash +npm install @blink-sdk/devhook +``` + +## Client Usage + +```typescript +import { DevhookClient } from "@blink-sdk/devhook"; + +const client = new DevhookClient({ + serverUrl: "https://devhook.example.com", + secret: "my-secret-key", + onRequest: async (req) => { + // Forward to your local server + const url = new URL(req.url); + url.host = "localhost:3000"; + return fetch(new Request(url.toString(), req)); + }, + onConnect: ({ url, id }) => { + console.log(`Devhook available at: ${url}`); + console.log(`Devhook ID: ${id}`); + }, + onDisconnect: () => { + console.log("Disconnected from server"); + }, + onError: (error) => { + console.error("Error:", error); + }, +}); + +const disposable = client.connect(); + +// When done: +// disposable.dispose(); +``` + +## Server Deployment + +### Cloudflare Workers (Production) + +1. Clone this repository or copy the server files +2. Configure `wrangler.toml`: + +```toml +name = "devhook-server" +main = "src/server/cloudflare.ts" +compatibility_date = "2025-01-01" + +# For wildcard subdomains: +routes = [ + { pattern = "*.devhook.example.com/*", zone_name = "example.com" }, + { pattern = "devhook.example.com/*", zone_name = "example.com" } +] + +[vars] +DEVHOOK_SECRET = "your-secure-server-secret" +DEVHOOK_BASE_URL = "https://devhook.example.com" +DEVHOOK_MODE = "wildcard" + +[[durable_objects.bindings]] +name = "DEVHOOK_SESSION" +class_name = "DevhookSession" + +[[migrations]] +tag = "v1" +new_sqlite_classes = ["DevhookSession"] +``` + +3. Deploy: + +```bash +wrangler deploy +``` + +### DNS Configuration (Wildcard Mode) + +For wildcard subdomains, configure your DNS: + +1. Add a wildcard CNAME record: `*.devhook.example.com` → your Cloudflare zone +2. Or use Cloudflare's automatic proxying + +### Local Development + +```typescript +import { createLocalServer } from "@blink-sdk/devhook/server/local"; + +const server = createLocalServer({ + port: 8080, + secret: "server-secret", + baseUrl: "http://localhost:8080", + mode: "subpath", // Easier for local testing + onReady: (port) => { + console.log(`Devhook server running on port ${port}`); + }, + onClientConnect: (id) => { + console.log(`Client connected: ${id}`); + }, + onClientDisconnect: (id) => { + console.log(`Client disconnected: ${id}`); + }, +}); + +// Later: server.close(); +``` + +## How It Works + +### URL Generation + +1. Client provides a secret +2. Server signs the secret with HMAC-SHA256 using its own secret +3. The signature is base64url-encoded and truncated to 16 characters +4. This becomes the devhook ID (subdomain or path prefix) + +This means: +- The same client secret always produces the same URL +- URLs cannot be guessed without knowing the client secret +- Different server secrets produce different URLs + +### Request Flow + +``` +External Request + │ + ▼ +┌─────────────────┐ +│ Cloudflare Edge │ +│ (Worker) │ +└────────┬────────┘ + │ + ▼ +┌─────────────────┐ +│ Durable Object │ +│ (Session State) │ +└────────┬────────┘ + │ WebSocket + ▼ +┌─────────────────┐ +│ Devhook Client │ +│ (Your Machine) │ +└────────┬────────┘ + │ + ▼ +┌─────────────────┐ +│ Local Server │ +│ (localhost:3000)│ +└─────────────────┘ +``` + +## API Reference + +### DevhookClient + +```typescript +interface DevhookClientOptions { + /** The devhook server URL */ + serverUrl: string; + + /** Client secret for URL generation */ + secret: string; + + /** Handle incoming proxied requests */ + onRequest: (request: Request) => Promise; + + /** Called when connected (with public URL) */ + onConnect?: (info: { url: string; id: string }) => void; + + /** Called when disconnected */ + onDisconnect?: () => void; + + /** Called on error */ + onError?: (error: unknown) => void; +} +``` + +### createLocalServer + +```typescript +interface LocalServerOptions { + /** Port to listen on */ + port: number; + + /** Server secret for HMAC signing */ + secret: string; + + /** Base URL for generating public URLs */ + baseUrl: string; + + /** Routing mode: "wildcard" or "subpath" */ + mode?: "wildcard" | "subpath"; + + /** Called when server starts */ + onReady?: (port: number) => void; + + /** Called when client connects */ + onClientConnect?: (id: string) => void; + + /** Called when client disconnects */ + onClientDisconnect?: (id: string) => void; +} +``` + +## License + +MIT diff --git a/packages/devhook/package.json b/packages/devhook/package.json new file mode 100644 index 0000000..3db715f --- /dev/null +++ b/packages/devhook/package.json @@ -0,0 +1,53 @@ +{ + "name": "@blink-sdk/devhook", + "description": "Devhook - expose local servers via a public URL", + "version": "0.0.1", + "type": "module", + "keywords": [ + "blink", + "devhook", + "tunnel", + "proxy", + "webhook" + ], + "publishConfig": { + "access": "public" + }, + "author": "Coder", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/coder/blink.git" + }, + "homepage": "https://github.com/coder/blink/tree/main/packages/devhook", + "bugs": { + "url": "https://github.com/coder/blink/issues" + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsdown", + "typecheck": "tsgo --noEmit", + "test": "vitest run", + "test:watch": "vitest" + }, + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, + "dependencies": { + "@blink-sdk/multiplexer": "workspace:*", + "ws": "^8.18.0" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20250109.0", + "@types/ws": "^8.5.10", + "hono": "^4.7.10", + "tsdown": "^0.11.12", + "vitest": "^3.2.4", + "wrangler": "^4.22.0" + } +} diff --git a/packages/devhook/src/client/index.ts b/packages/devhook/src/client/index.ts new file mode 100644 index 0000000..1d9b15b --- /dev/null +++ b/packages/devhook/src/client/index.ts @@ -0,0 +1,477 @@ +import Multiplexer, { type Stream } from "@blink-sdk/multiplexer"; +import WebSocket from "ws"; +import { type Disposable } from "../emitter"; +import { + ClientMessageType, + ServerMessageType, + createWebSocketMessagePayload, + parseWebSocketMessagePayload, + type ConnectionEstablished, + type ProxyInitRequest, + type ProxyInitResponse, + type WebSocketClosePayload, +} from "../schema"; + +export interface DevhookClientOptions { + /** + * The devhook server URL. + * For wildcard mode: https://devhook.example.com + * For subpath mode: https://example.com + */ + serverUrl: string; + + /** + * Client secret used to generate a secure, deterministic subdomain. + * The server signs this with HMAC-SHA256 to create the public URL. + */ + secret: string; + + /** + * Handle incoming proxied HTTP requests. + * Return a Response to send back to the original requester. + */ + onRequest: (request: Request) => Promise; + + /** + * Called when the connection is established. + * Receives the public URL that can be used to access this devhook. + */ + onConnect?: (info: ConnectionEstablished) => void; + + /** + * Called when the connection is lost. + */ + onDisconnect?: () => void; + + /** + * Called when an error occurs. + */ + onError?: (error: unknown) => void; +} + +/** + * Connect to a devhook server and handle proxied requests. + * + * @example + * ```ts + * const client = new DevhookClient({ + * serverUrl: "https://devhook.example.com", + * secret: "my-secret-key", + * onRequest: async (req) => { + * // Forward to local server + * const url = new URL(req.url); + * url.host = "localhost:3000"; + * return fetch(new Request(url.toString(), req)); + * }, + * onConnect: ({ url }) => { + * console.log(`Devhook available at: ${url}`); + * }, + * }); + * + * const disposable = client.connect(); + * // Later: disposable.dispose(); + * ``` + */ +export class DevhookClient { + private readonly encoder = new TextEncoder(); + private readonly decoder = new TextDecoder(); + + constructor(private readonly opts: DevhookClientOptions) {} + + /** + * Connect to the devhook server. + * Returns a Disposable that can be used to disconnect. + */ + connect(): Disposable { + let socket: WebSocket | undefined; + let reconnectTimeout: ReturnType | undefined; + let disposed = false; + let multiplexer: Multiplexer | undefined; + + // Exponential backoff with jitter + const baseDelayMS = 250; + const maxDelayMS = 10_000; + let currentDelayMS = baseDelayMS; + + const clearReconnectTimer = () => { + if (reconnectTimeout) { + clearTimeout(reconnectTimeout); + reconnectTimeout = undefined; + } + }; + + const scheduleReconnect = () => { + if (disposed) return; + clearReconnectTimer(); + const jitter = currentDelayMS * 0.2 * Math.random(); + const delay = Math.min(maxDelayMS, Math.floor(currentDelayMS + jitter)); + reconnectTimeout = setTimeout(() => { + openSocket(); + }, delay); + currentDelayMS = Math.min(maxDelayMS, Math.floor(currentDelayMS * 1.5)); + }; + + const resetBackoff = () => { + currentDelayMS = baseDelayMS; + }; + + const openSocket = () => { + if (disposed) return; + + try { + const wsUrl = new URL(this.opts.serverUrl); + wsUrl.protocol = wsUrl.protocol === "https:" ? "wss:" : "ws:"; + wsUrl.pathname = "/api/devhook/connect"; + + socket = new WebSocket(wsUrl.toString(), { + headers: { + "x-devhook-secret": this.opts.secret, + }, + }); + socket.binaryType = "arraybuffer"; + + multiplexer = new Multiplexer({ + send: (data: Uint8Array) => { + if (socket?.readyState === WebSocket.OPEN) { + socket.send(data); + } + }, + }); + + multiplexer.onStream((stream: Stream) => { + this.handleStream(stream); + }); + + socket.on("open", () => { + if (disposed) return; + resetBackoff(); + }); + + socket.on("message", (data: ArrayBuffer | Buffer) => { + if (disposed) return; + + const bytes = + data instanceof ArrayBuffer + ? new Uint8Array(data) + : new Uint8Array(data.buffer, data.byteOffset, data.byteLength); + + // Check if this is a connection established message (JSON) + // Connection messages are sent as text, not binary multiplexed data + if (bytes.length > 0 && bytes[0] === 0x7b) { + // '{' character + try { + const text = this.decoder.decode(bytes); + const msg = JSON.parse(text) as ConnectionEstablished; + if (msg.url && msg.id) { + this.opts.onConnect?.(msg); + return; + } + } catch { + // Not JSON, continue with binary handling + } + } + + multiplexer?.handleMessage(bytes); + }); + + socket.on("close", () => { + if (disposed) return; + multiplexer = undefined; + this.opts.onDisconnect?.(); + scheduleReconnect(); + }); + + socket.on("error", (err) => { + try { + this.opts.onError?.(err); + } catch { + // Ignore errors from error handler + } + try { + socket?.close(); + } catch { + // Ignore close errors + } + }); + } catch (err) { + try { + this.opts.onError?.(err); + } catch { + // Ignore errors from error handler + } + scheduleReconnect(); + } + }; + + openSocket(); + + return { + dispose: () => { + if (disposed) return; + disposed = true; + clearReconnectTimer(); + const ws = socket; + socket = undefined; + multiplexer = undefined; + try { + if ( + ws && + (ws.readyState === WebSocket.OPEN || + ws.readyState === WebSocket.CONNECTING) + ) { + ws.close(1000); + } + } catch { + // Ignore close errors + } + }, + }; + } + + /** + * Handle a new stream from the multiplexer. + * Each stream represents a single proxied request. + */ + private handleStream(stream: Stream): void { + let requestInit: ProxyInitRequest | undefined; + let bodyWriter: WritableStreamDefaultWriter | undefined; + let bodyStream: ReadableStream | undefined; + let isWebSocket = false; + + stream.onData((message: Uint8Array) => { + const type = message[0]; + const payload = message.subarray(1); + + switch (type) { + case ServerMessageType.PROXY_INIT: { + requestInit = JSON.parse( + this.decoder.decode(payload) + ) as ProxyInitRequest; + isWebSocket = requestInit.headers["upgrade"] === "websocket"; + + if (!isWebSocket) { + // Set up body stream for non-WebSocket requests + const transform = new TransformStream(); + bodyWriter = transform.writable.getWriter(); + bodyStream = transform.readable; + + // Process the request + this.handleProxyRequest(stream, requestInit, bodyStream); + } else { + // Handle WebSocket upgrade + this.handleProxyWebSocket(stream, requestInit); + } + break; + } + + case ServerMessageType.PROXY_BODY: { + if (bodyWriter) { + if (payload.length === 0) { + // Empty chunk signals end of body + bodyWriter.close().catch(() => {}); + } else { + bodyWriter.write(payload).catch(() => {}); + } + } + break; + } + + case ServerMessageType.PROXY_WEBSOCKET_MESSAGE: { + // WebSocket messages are handled by the WebSocket handler + break; + } + + case ServerMessageType.PROXY_WEBSOCKET_CLOSE: { + // WebSocket close is handled by the WebSocket handler + break; + } + } + }); + } + + /** + * Handle a proxied HTTP request. + */ + private async handleProxyRequest( + stream: Stream, + init: ProxyInitRequest, + body: ReadableStream + ): Promise { + try { + const hasBody = + init.method !== "GET" && + init.method !== "HEAD" && + init.method !== "OPTIONS"; + + const request = new Request(init.url, { + method: init.method, + headers: init.headers, + body: hasBody ? body : undefined, + // @ts-expect-error - Required for Node.js streaming + duplex: hasBody ? "half" : undefined, + }); + + const response = await this.opts.onRequest(request); + + // Send response headers + const headers: Record = {}; + response.headers.forEach((value, key) => { + headers[key] = value; + }); + + const proxyInit: ProxyInitResponse = { + status_code: response.status, + status_message: response.statusText, + headers, + }; + + stream.writeTyped( + ClientMessageType.PROXY_INIT, + this.encoder.encode(JSON.stringify(proxyInit)), + true + ); + + // Stream response body + if (response.body) { + const reader = response.body.getReader(); + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + if (value) { + stream.writeTyped(ClientMessageType.PROXY_DATA, value); + } + } + } finally { + reader.releaseLock(); + } + } + + stream.close(); + } catch (err) { + // Send error response + const proxyInit: ProxyInitResponse = { + status_code: 502, + status_message: "Bad Gateway", + headers: { "content-type": "text/plain" }, + }; + stream.writeTyped( + ClientMessageType.PROXY_INIT, + this.encoder.encode(JSON.stringify(proxyInit)), + true + ); + stream.writeTyped( + ClientMessageType.PROXY_DATA, + this.encoder.encode( + `Error proxying request: ${err instanceof Error ? err.message : String(err)}` + ) + ); + stream.close(); + } + } + + /** + * Handle a proxied WebSocket connection. + */ + private async handleProxyWebSocket( + stream: Stream, + init: ProxyInitRequest + ): Promise { + try { + const url = new URL(init.url); + url.protocol = url.protocol === "https:" ? "wss:" : "ws:"; + + const ws = new WebSocket(url.toString(), init.headers["sec-websocket-protocol"], { + headers: init.headers, + perMessageDeflate: false, + }); + + ws.on("open", () => { + const proxyInit: ProxyInitResponse = { + status_code: 101, + status_message: "Switching Protocols", + headers: {}, + }; + stream.writeTyped( + ClientMessageType.PROXY_INIT, + this.encoder.encode(JSON.stringify(proxyInit)), + true + ); + }); + + ws.on("message", (data: Buffer | ArrayBuffer | Buffer[]) => { + const payload = + data instanceof ArrayBuffer + ? data + : Array.isArray(data) + ? Buffer.concat(data) + : data; + stream.writeTyped( + ClientMessageType.PROXY_WEBSOCKET_MESSAGE, + createWebSocketMessagePayload(payload, this.encoder) + ); + }); + + ws.on("close", (code, reason) => { + const closePayload: WebSocketClosePayload = { + code, + reason: reason.toString(), + }; + stream.writeTyped( + ClientMessageType.PROXY_WEBSOCKET_CLOSE, + this.encoder.encode(JSON.stringify(closePayload)) + ); + stream.close(); + }); + + ws.on("error", (err) => { + const closePayload: WebSocketClosePayload = { + code: 1011, + reason: err.message, + }; + stream.writeTyped( + ClientMessageType.PROXY_WEBSOCKET_CLOSE, + this.encoder.encode(JSON.stringify(closePayload)) + ); + stream.close(); + }); + + // Handle messages from the server to forward to the local WebSocket + stream.onData((message: Uint8Array) => { + const type = message[0]; + const payload = message.subarray(1); + + switch (type) { + case ServerMessageType.PROXY_WEBSOCKET_MESSAGE: { + const parsed = parseWebSocketMessagePayload(payload, this.decoder); + ws.send(parsed); + break; + } + case ServerMessageType.PROXY_WEBSOCKET_CLOSE: { + const closePayload = JSON.parse( + this.decoder.decode(payload) + ) as WebSocketClosePayload; + ws.close(closePayload.code, closePayload.reason); + break; + } + } + }); + + stream.onClose(() => { + ws.close(); + }); + } catch (err) { + const proxyInit: ProxyInitResponse = { + status_code: 502, + status_message: "Bad Gateway", + headers: {}, + }; + stream.writeTyped( + ClientMessageType.PROXY_INIT, + this.encoder.encode(JSON.stringify(proxyInit)), + true + ); + stream.close(); + } + } +} diff --git a/packages/devhook/src/devhook.test.ts b/packages/devhook/src/devhook.test.ts new file mode 100644 index 0000000..31565eb --- /dev/null +++ b/packages/devhook/src/devhook.test.ts @@ -0,0 +1,169 @@ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { DevhookClient } from "./client"; +import { createLocalServer } from "./server/local"; +import { generateDevhookId } from "./server/crypto"; + +const SERVER_SECRET = "test-server-secret"; +const CLIENT_SECRET = "test-client-secret"; + +describe("devhook", () => { + let server: ReturnType; + let serverPort: number; + + beforeAll(async () => { + // Find an available port + serverPort = 18080 + Math.floor(Math.random() * 1000); + + server = createLocalServer({ + port: serverPort, + secret: SERVER_SECRET, + baseUrl: `http://localhost:${serverPort}`, + mode: "subpath", + }); + + // Wait for server to be ready + await new Promise((resolve) => setTimeout(resolve, 100)); + }); + + afterAll(() => { + server?.close(); + }); + + describe("crypto", () => { + it("should generate consistent devhook IDs", async () => { + const id1 = await generateDevhookId(CLIENT_SECRET, SERVER_SECRET); + const id2 = await generateDevhookId(CLIENT_SECRET, SERVER_SECRET); + + expect(id1).toBe(id2); + expect(id1).toHaveLength(16); + expect(id1).toMatch(/^[a-f0-9]+$/); + }); + + it("should generate different IDs for different secrets", async () => { + const id1 = await generateDevhookId("secret1", SERVER_SECRET); + const id2 = await generateDevhookId("secret2", SERVER_SECRET); + + expect(id1).not.toBe(id2); + }); + + it("should generate different IDs for different server secrets", async () => { + const id1 = await generateDevhookId(CLIENT_SECRET, "server1"); + const id2 = await generateDevhookId(CLIENT_SECRET, "server2"); + + expect(id1).not.toBe(id2); + }); + }); + + describe("client-server integration", () => { + it("should connect and receive public URL", async () => { + let connectedUrl: string | undefined; + let connectedId: string | undefined; + + const client = new DevhookClient({ + serverUrl: `http://localhost:${serverPort}`, + secret: CLIENT_SECRET, + onRequest: async () => new Response("OK"), + onConnect: ({ url, id }) => { + connectedUrl = url; + connectedId = id; + }, + }); + + const disposable = client.connect(); + + // Wait for connection + await new Promise((resolve) => setTimeout(resolve, 200)); + + expect(connectedUrl).toBeDefined(); + expect(connectedId).toBeDefined(); + expect(connectedId).toHaveLength(16); + expect(connectedUrl).toContain(connectedId); + + disposable.dispose(); + }); + + it("should proxy HTTP requests", async () => { + let receivedRequest: Request | undefined; + + const client = new DevhookClient({ + serverUrl: `http://localhost:${serverPort}`, + secret: CLIENT_SECRET, + onRequest: async (req) => { + receivedRequest = req; + return new Response(JSON.stringify({ message: "Hello from devhook!" }), { + headers: { "content-type": "application/json" }, + }); + }, + }); + + const disposable = client.connect(); + + // Wait for connection + await new Promise((resolve) => setTimeout(resolve, 200)); + + // Get the devhook ID + const devhookId = await generateDevhookId(CLIENT_SECRET, SERVER_SECRET); + + // Make a request through the devhook + const response = await fetch( + `http://localhost:${serverPort}/devhook/${devhookId}/test/path?foo=bar`, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ data: "test" }), + } + ); + + expect(response.status).toBe(200); + const body = (await response.json()) as { message: string }; + expect(body).toEqual({ message: "Hello from devhook!" }); + + expect(receivedRequest).toBeDefined(); + expect(receivedRequest!.method).toBe("POST"); + expect(new URL(receivedRequest!.url).pathname).toBe("/test/path"); + + disposable.dispose(); + }); + + it("should return 503 when no client is connected", async () => { + const devhookId = await generateDevhookId("nonexistent-secret", SERVER_SECRET); + + const response = await fetch( + `http://localhost:${serverPort}/devhook/${devhookId}/test` + ); + + expect(response.status).toBe(503); + const body = (await response.json()) as { error: string }; + expect(body.error).toBe("No client connected"); + }); + + it("should handle reconnection", async () => { + let connectCount = 0; + + const createClient = () => + new DevhookClient({ + serverUrl: `http://localhost:${serverPort}`, + secret: CLIENT_SECRET, + onRequest: async () => new Response("OK"), + onConnect: () => { + connectCount++; + }, + }); + + // First connection + const client1 = createClient(); + const disposable1 = client1.connect(); + await new Promise((resolve) => setTimeout(resolve, 200)); + expect(connectCount).toBe(1); + + // Second connection should replace the first + const client2 = createClient(); + const disposable2 = client2.connect(); + await new Promise((resolve) => setTimeout(resolve, 200)); + expect(connectCount).toBe(2); + + disposable1.dispose(); + disposable2.dispose(); + }); + }); +}); diff --git a/packages/devhook/src/emitter.ts b/packages/devhook/src/emitter.ts new file mode 100644 index 0000000..b91d6da --- /dev/null +++ b/packages/devhook/src/emitter.ts @@ -0,0 +1,35 @@ +/** + * Simple event emitter for internal use. + */ +export interface Disposable { + dispose(): void; +} + +export interface Event { + (listener: (event: T) => void): Disposable; +} + +export class Emitter { + private listeners: ((event: T) => void)[] = []; + + public get event(): Event { + return (listener: (event: T) => void) => { + this.listeners.push(listener); + return { + dispose: () => { + this.listeners = this.listeners.filter((l) => l !== listener); + }, + }; + }; + } + + public emit(event: T) { + for (let i = this.listeners.length - 1; i >= 0; i--) { + this.listeners[i]?.(event); + } + } + + public dispose() { + this.listeners = []; + } +} diff --git a/packages/devhook/src/index.ts b/packages/devhook/src/index.ts new file mode 100644 index 0000000..b13aa69 --- /dev/null +++ b/packages/devhook/src/index.ts @@ -0,0 +1,67 @@ +/** + * @blink-sdk/devhook + * + * Expose local servers via a public URL. + * + * ## Client Usage + * + * ```ts + * import { DevhookClient } from "@blink-sdk/devhook"; + * + * const client = new DevhookClient({ + * serverUrl: "https://devhook.example.com", + * secret: "my-secret-key", + * onRequest: async (req) => { + * // Forward to local server + * const url = new URL(req.url); + * url.host = "localhost:3000"; + * return fetch(new Request(url.toString(), req)); + * }, + * onConnect: ({ url }) => { + * console.log(`Devhook available at: ${url}`); + * }, + * }); + * + * const disposable = client.connect(); + * // Later: disposable.dispose(); + * ``` + * + * ## Server Deployment + * + * The server can be deployed to Cloudflare Workers or run locally. + * See the `server/` directory for implementation details. + * + * ### Cloudflare Workers + * + * ```toml + * # wrangler.toml + * name = "devhook-server" + * main = "node_modules/@blink-sdk/devhook/dist/server/cloudflare.js" + * + * [vars] + * DEVHOOK_SECRET = "your-server-secret" + * DEVHOOK_BASE_URL = "https://devhook.example.com" + * DEVHOOK_MODE = "wildcard" # or "subpath" + * + * [[durable_objects.bindings]] + * name = "DEVHOOK_SESSION" + * class_name = "DevhookSession" + * ``` + * + * ### Local Testing + * + * ```ts + * import { createLocalServer } from "@blink-sdk/devhook/server/local"; + * + * const server = createLocalServer({ + * port: 8080, + * secret: "server-secret", + * baseUrl: "http://localhost:8080", + * mode: "subpath", + * }); + * ``` + */ + +export { DevhookClient, type DevhookClientOptions } from "./client"; +export type { Disposable } from "./emitter"; +export type { ConnectionEstablished } from "./schema"; diff --git a/packages/devhook/src/schema.ts b/packages/devhook/src/schema.ts new file mode 100644 index 0000000..946fe8d --- /dev/null +++ b/packages/devhook/src/schema.ts @@ -0,0 +1,103 @@ +/** + * Protocol schema for devhook proxy messages. + * + * The protocol uses a binary format over WebSocket with multiplexed streams. + * Each message has a 1-byte type prefix followed by the payload. + */ + +/** + * Message types sent from the server (Cloudflare Worker) to the client. + */ +export enum ServerMessageType { + /** Initial proxy request with method, URL, and headers */ + PROXY_INIT = 0x01, + /** Body data chunk for the proxy request */ + PROXY_BODY = 0x02, + /** WebSocket message to forward */ + PROXY_WEBSOCKET_MESSAGE = 0x03, + /** WebSocket close signal */ + PROXY_WEBSOCKET_CLOSE = 0x04, +} + +/** + * Message types sent from the client back to the server. + */ +export enum ClientMessageType { + /** Initial proxy response with status code and headers */ + PROXY_INIT = 0x01, + /** Body data chunk for the proxy response */ + PROXY_DATA = 0x02, + /** WebSocket message to forward */ + PROXY_WEBSOCKET_MESSAGE = 0x03, + /** WebSocket close signal */ + PROXY_WEBSOCKET_CLOSE = 0x04, +} + +/** + * Server-to-client: Initial proxy request. + */ +export interface ProxyInitRequest { + method: string; + url: string; + headers: Record; +} + +/** + * Client-to-server: Initial proxy response. + */ +export interface ProxyInitResponse { + status_code: number; + status_message: string; + headers: Record; +} + +/** + * WebSocket close payload. + */ +export interface WebSocketClosePayload { + code?: number; + reason?: string; +} + +/** + * Connection established message sent to client. + */ +export interface ConnectionEstablished { + /** The public URL for this devhook */ + url: string; + /** The devhook ID (subdomain or path prefix) */ + id: string; +} + +/** + * Create a WebSocket message payload with type prefix. + * First byte: 0x00 for text, 0x01 for binary. + */ +export function createWebSocketMessagePayload( + payload: string | Uint8Array | ArrayBuffer, + encoder: TextEncoder +): Uint8Array { + const isText = typeof payload === "string"; + if (typeof payload === "string") { + payload = encoder.encode(payload); + } + + const arr = new Uint8Array(1 + payload.byteLength); + arr[0] = isText ? 0x00 : 0x01; + arr.set(new Uint8Array(payload), 1); + return arr; +} + +/** + * Parse a WebSocket message payload. + * Returns string for text messages, Uint8Array for binary. + */ +export function parseWebSocketMessagePayload( + payload: Uint8Array, + decoder: TextDecoder +): string | Uint8Array { + if (payload[0] === 0x00) { + return decoder.decode(payload.subarray(1)); + } + return new Uint8Array(payload.subarray(1)); +} diff --git a/packages/devhook/src/server/cloudflare.ts b/packages/devhook/src/server/cloudflare.ts new file mode 100644 index 0000000..0b70176 --- /dev/null +++ b/packages/devhook/src/server/cloudflare.ts @@ -0,0 +1,183 @@ +/** + * Cloudflare Worker entry point for the devhook server. + * + * This worker handles: + * 1. Client connections at /api/devhook/connect + * 2. Proxy requests via wildcard subdomains (*.example.com) + * 3. Proxy requests via subpath routing (/devhook/:id/*) + */ + +import { generateDevhookId } from "./crypto"; +import type { DevhookSession, DevhookSessionEnv } from "./durable-object"; + +export interface Env extends DevhookSessionEnv { + DEVHOOK_SESSION: DurableObjectNamespace; +} + +export default { + async fetch(request: Request, env: Env): Promise { + const url = new URL(request.url); + + // Handle client connection requests + if (url.pathname === "/api/devhook/connect") { + return handleClientConnect(request, env); + } + + // Handle proxy requests + const devhookId = extractDevhookId(url, env); + if (devhookId) { + return handleProxyRequest(request, env, devhookId); + } + + // Health check endpoint + if (url.pathname === "/health") { + return new Response(JSON.stringify({ status: "ok" }), { + headers: { "content-type": "application/json" }, + }); + } + + return new Response( + JSON.stringify({ + error: "Not found", + message: "This endpoint does not exist.", + }), + { + status: 404, + headers: { "content-type": "application/json" }, + } + ); + }, +}; + +/** + * Handle a client connecting to establish a devhook. + */ +async function handleClientConnect(request: Request, env: Env): Promise { + // Verify WebSocket upgrade + if (request.headers.get("upgrade") !== "websocket") { + return new Response( + JSON.stringify({ + error: "WebSocket required", + message: "This endpoint requires a WebSocket connection.", + }), + { + status: 426, + headers: { "content-type": "application/json" }, + } + ); + } + + // Get client secret from header + const clientSecret = request.headers.get("x-devhook-secret"); + if (!clientSecret) { + return new Response( + JSON.stringify({ + error: "Missing secret", + message: "The x-devhook-secret header is required.", + }), + { + status: 401, + headers: { "content-type": "application/json" }, + } + ); + } + + // Generate the devhook ID from the client secret + const devhookId = await generateDevhookId(clientSecret, env.DEVHOOK_SECRET); + + // Get or create the Durable Object for this session + const sessionId = env.DEVHOOK_SESSION.idFromName(devhookId); + const session = env.DEVHOOK_SESSION.get(sessionId) as unknown as DevhookSession; + + // Initialize the session if needed + const existingSecret = session.getClientSecret(); + if (!existingSecret) { + await session.initialize(devhookId, clientSecret); + } else if (existingSecret !== clientSecret) { + // This shouldn't happen due to HMAC, but verify anyway + return new Response( + JSON.stringify({ + error: "Invalid secret", + message: "The provided secret does not match the existing session.", + }), + { + status: 403, + headers: { "content-type": "application/json" }, + } + ); + } + + // Forward to the Durable Object + return session.fetch(request); +} + +/** + * Extract the devhook ID from the request URL. + * Supports both wildcard subdomain and subpath modes. + */ +function extractDevhookId(url: URL, env: Env): string | undefined { + const mode = env.DEVHOOK_MODE || "wildcard"; + const baseUrl = new URL(env.DEVHOOK_BASE_URL); + + if (mode === "subpath") { + // Subpath mode: /devhook/:id/* + const match = url.pathname.match(/^\/devhook\/([a-f0-9]{16})(\/.*)?$/); + if (match) { + return match[1]; + } + } else { + // Wildcard mode: :id.example.com + const baseHost = baseUrl.hostname; + if (url.hostname.endsWith(`.${baseHost}`) && url.hostname !== baseHost) { + const subdomain = url.hostname.slice(0, -(baseHost.length + 1)); + // Validate it looks like a devhook ID (16 hex characters) + if (/^[a-f0-9]{16}$/.test(subdomain)) { + return subdomain; + } + } + } + + return undefined; +} + +/** + * Handle a proxy request to a devhook. + */ +async function handleProxyRequest( + request: Request, + env: Env, + devhookId: string +): Promise { + const sessionId = env.DEVHOOK_SESSION.idFromName(devhookId); + const session = env.DEVHOOK_SESSION.get(sessionId) as unknown as DevhookSession; + + // Build the proxy URL + const url = new URL(request.url); + const mode = env.DEVHOOK_MODE || "wildcard"; + + let proxyPath: string; + if (mode === "subpath") { + // Remove the /devhook/:id prefix + proxyPath = url.pathname.replace(/^\/devhook\/[a-z0-9]+/, "") || "/"; + } else { + proxyPath = url.pathname; + } + + // Construct the full proxy URL (preserving query string) + const proxyUrl = new URL(proxyPath + url.search, url.origin); + + // Forward to the Durable Object with the proxy URL header + const headers = new Headers(request.headers); + headers.set("x-devhook-proxy-url", proxyUrl.toString()); + + return session.fetch( + new Request("https://devhook/proxy", { + method: request.method, + headers, + body: request.body, + }) + ); +} + +// Re-export the Durable Object for wrangler +export { DevhookSession } from "./durable-object"; diff --git a/packages/devhook/src/server/crypto.ts b/packages/devhook/src/server/crypto.ts new file mode 100644 index 0000000..5d81e50 --- /dev/null +++ b/packages/devhook/src/server/crypto.ts @@ -0,0 +1,65 @@ +/** + * Cryptographic utilities for devhook URL generation. + * + * The client presents a secret, and the server signs it with HMAC-SHA256 + * using its own server secret. The resulting signature is used to generate + * a deterministic 16-character subdomain that cannot be guessed without + * knowing the client secret. + */ + +/** + * Generate a secure devhook ID from a client secret. + * Uses HMAC-SHA256 with the server secret, then base64url encodes + * and truncates to 16 characters. + * + * @param clientSecret - The secret provided by the client + * @param serverSecret - The server's secret key for signing + * @returns A 16-character URL-safe devhook ID + */ +export async function generateDevhookId( + clientSecret: string, + serverSecret: string +): Promise { + const encoder = new TextEncoder(); + + // Import the server secret as an HMAC key + const key = await crypto.subtle.importKey( + "raw", + encoder.encode(serverSecret), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"] + ); + + // Sign the client secret + const signature = await crypto.subtle.sign( + "HMAC", + key, + encoder.encode(clientSecret) + ); + + // Convert to hex and take first 16 characters + const bytes = new Uint8Array(signature); + const hex = Array.from(bytes) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); + + return hex.substring(0, 16).toLowerCase(); +} + +/** + * Verify that a devhook ID matches the expected value for a client secret. + * + * @param devhookId - The devhook ID to verify + * @param clientSecret - The client secret that should produce this ID + * @param serverSecret - The server's secret key + * @returns True if the ID is valid for this client secret + */ +export async function verifyDevhookId( + devhookId: string, + clientSecret: string, + serverSecret: string +): Promise { + const expected = await generateDevhookId(clientSecret, serverSecret); + return devhookId === expected; +} diff --git a/packages/devhook/src/server/durable-object.ts b/packages/devhook/src/server/durable-object.ts new file mode 100644 index 0000000..eb52f45 --- /dev/null +++ b/packages/devhook/src/server/durable-object.ts @@ -0,0 +1,335 @@ +import { DurableObject } from "cloudflare:workers"; +import { Worker } from "./worker"; +import type { ConnectionEstablished } from "../schema"; + +type WebsocketState = + | { + type: "client"; + } + | { + type: "proxied"; + streamID: number; + }; + +interface WebSocket extends globalThis.WebSocket { + deserializeAttachment(): WebsocketState; + serializeAttachment(state: WebsocketState): void; +} + +export interface DevhookSessionEnv { + DEVHOOK_SECRET: string; + DEVHOOK_BASE_URL: string; + DEVHOOK_MODE: "wildcard" | "subpath"; +} + +/** + * Durable Object that manages a single devhook session. + * + * State that survives restarts: + * - id: The devhook ID (generated from client secret) + * - nextStreamID: For multiplexer continuity + * - clientSecret: To verify reconnections + */ +export class DevhookSession extends DurableObject { + private id?: string; + private clientSecret?: string; + private nextStreamID?: number; + private cachedWorker?: Worker; + + constructor(state: DurableObjectState, env: DevhookSessionEnv) { + super(state, env); + + // Restore persisted state + this.ctx.blockConcurrencyWhile(async () => { + this.id = await this.ctx.storage.get("id"); + this.clientSecret = await this.ctx.storage.get("clientSecret"); + this.nextStreamID = await this.ctx.storage.get("nextStreamID"); + }); + } + + /** + * Initialize the session with a devhook ID and client secret. + */ + public async initialize(id: string, clientSecret: string): Promise { + this.id = id; + this.clientSecret = clientSecret; + await this.ctx.storage.put("id", id); + await this.ctx.storage.put("clientSecret", clientSecret); + } + + /** + * Get the stored client secret for verification. + */ + public getClientSecret(): string | undefined { + return this.clientSecret; + } + + /** + * Check if a client is currently connected. + */ + public isConnected(): boolean { + return this.ctx.getWebSockets("client").length > 0; + } + + /** + * Handle incoming requests. + */ + public override async fetch(request: Request): Promise { + const url = new URL(request.url); + + // Client connecting via WebSocket + if (request.headers.get("upgrade") === "websocket") { + return this.handleClientConnect(request); + } + + // Proxy request + if (url.pathname === "/proxy" || request.headers.has("x-devhook-proxy-url")) { + return this.handleProxyRequest(request); + } + + return new Response("Not found", { status: 404 }); + } + + /** + * Handle a client connecting via WebSocket. + */ + private async handleClientConnect(request: Request): Promise { + // Close any existing client connections + const existingClients = this.ctx.getWebSockets("client"); + for (const ws of existingClients) { + ws.close(1000, "A new client has connected."); + } + + const pair = new WebSocketPair(); + const [client, server] = Object.values(pair) as [WebSocket, WebSocket]; + + server.serializeAttachment({ type: "client" }); + this.ctx.acceptWebSocket(server, ["client"]); + + // Send connection established message with the public URL + const publicUrl = this.getPublicUrl(); + const connectionInfo: ConnectionEstablished = { + url: publicUrl, + id: this.id!, + }; + + // Queue the message to be sent after the connection is established + this.ctx.waitUntil( + (async () => { + // Small delay to ensure WebSocket is ready + await new Promise((resolve) => setTimeout(resolve, 10)); + server.send(JSON.stringify(connectionInfo)); + })() + ); + + return new Response(null, { + status: 101, + webSocket: client, + }); + } + + /** + * Handle a proxy request from the edge. + */ + private async handleProxyRequest(request: Request): Promise { + if (!this.isConnected()) { + return new Response( + JSON.stringify({ + error: "No client connected", + message: + "The devhook client is not currently connected. Please ensure your local server is running.", + }), + { + status: 503, + headers: { "content-type": "application/json" }, + } + ); + } + + const proxyUrl = request.headers.get("x-devhook-proxy-url") ?? request.url; + const headers = new Headers(request.headers); + headers.delete("x-devhook-proxy-url"); + + const worker = this.getWorker(); + + try { + const response = await worker.proxy( + new Request(proxyUrl, { + headers, + method: request.method, + body: request.body, + signal: request.signal, + redirect: "manual", + }) + ); + + // Handle WebSocket upgrade + if (response.upgrade) { + const pair = new WebSocketPair(); + const [client, server] = Object.values(pair) as [WebSocket, WebSocket]; + server.serializeAttachment({ + type: "proxied", + streamID: response.stream, + }); + this.ctx.acceptWebSocket(server, ["proxied", response.stream.toString()]); + + return new Response(null, { + status: 101, + webSocket: client, + }); + } + + // Handle null body status codes + if ([101, 204, 205, 304].includes(response.status)) { + return new Response(null, { + status: response.status, + headers: response.headers, + statusText: response.statusText, + }); + } + + return new Response(response.body ?? null, { + status: response.status, + headers: response.headers, + statusText: response.statusText, + }); + } catch (err) { + return new Response( + JSON.stringify({ + error: "Proxy error", + message: err instanceof Error ? err.message : String(err), + }), + { + status: 502, + headers: { "content-type": "application/json" }, + } + ); + } + } + + /** + * Handle WebSocket messages. + */ + public override async webSocketMessage( + ws: WebSocket, + message: string | ArrayBuffer + ): Promise { + const state = ws.deserializeAttachment(); + const worker = this.getWorker(); + + switch (state.type) { + case "client": { + if (typeof message === "string") { + // Clients should not send string messages + console.warn("Received unexpected string message from client"); + return; + } + worker.handleClientMessage(new Uint8Array(message)); + break; + } + case "proxied": { + // Forward WebSocket message to client + worker.sendProxiedWebSocketMessage(state.streamID, message); + break; + } + } + } + + /** + * Handle WebSocket close. + */ + public override async webSocketClose(ws: WebSocket, code: number): Promise { + const state = ws.deserializeAttachment(); + + switch (state.type) { + case "client": { + // Client disconnected, close all proxied WebSockets + const proxied = this.ctx.getWebSockets("proxied"); + for (const proxyWs of proxied) { + try { + proxyWs.close(code, "Client disconnected"); + } catch { + // Ignore errors + } + } + break; + } + case "proxied": { + const worker = this.getWorker(); + worker.sendProxiedWebSocketClose(state.streamID, code); + break; + } + } + } + + /** + * Handle WebSocket errors. + */ + public override async webSocketError( + _ws: WebSocket, + _error: unknown + ): Promise { + // Suppress errors to avoid noisy logs + } + + /** + * Get or create the Worker instance. + */ + private getWorker(): Worker { + if (!this.cachedWorker) { + this.cachedWorker = new Worker({ + initialNextStreamID: this.nextStreamID, + sendToClient: (data: Uint8Array) => { + const clients = this.ctx.getWebSockets("client"); + for (const client of clients) { + try { + client.send(data); + } catch { + // Ignore send errors + } + } + }, + }); + + // Persist stream ID changes + this.cachedWorker.onNextStreamIDChange((streamID: number) => { + this.nextStreamID = streamID; + this.ctx.waitUntil(this.ctx.storage.put("nextStreamID", streamID)); + }); + + // Handle WebSocket messages from the client + this.cachedWorker.onWebSocketMessage((event) => { + const [socket] = this.ctx.getWebSockets(event.stream.toString()); + if (socket) { + socket.send(event.message); + } + }); + + // Handle WebSocket close from the client + this.cachedWorker.onWebSocketClose((event) => { + const [socket] = this.ctx.getWebSockets(event.stream.toString()); + if (socket) { + socket.close(event.code, event.reason); + } + }); + } + return this.cachedWorker; + } + + /** + * Get the public URL for this devhook. + */ + private getPublicUrl(): string { + const baseUrl = this.env.DEVHOOK_BASE_URL; + const mode = this.env.DEVHOOK_MODE || "wildcard"; + + if (mode === "subpath") { + return `${baseUrl}/${this.id}`; + } else { + // Wildcard mode: insert ID as subdomain + const url = new URL(baseUrl); + url.hostname = `${this.id}.${url.hostname}`; + return url.toString().replace(/\/$/, ""); + } + } +} diff --git a/packages/devhook/src/server/local.ts b/packages/devhook/src/server/local.ts new file mode 100644 index 0000000..ec10e0f --- /dev/null +++ b/packages/devhook/src/server/local.ts @@ -0,0 +1,354 @@ +/** + * Local server implementation for testing devhook. + * + * This provides the same functionality as the Cloudflare Worker + * but runs locally using Node.js. + */ + +import { createServer, type Server as HttpServer } from "node:http"; +import { WebSocketServer, WebSocket } from "ws"; +import { Worker } from "./worker"; +import { generateDevhookId } from "./crypto"; +import type { ConnectionEstablished } from "../schema"; + +export interface LocalServerOptions { + /** + * Port to listen on. + */ + port: number; + + /** + * Server secret for HMAC signing. + */ + secret: string; + + /** + * Base URL for generating public URLs. + * In wildcard mode, devhook IDs become subdomains. + * In subpath mode, devhook IDs become path prefixes. + */ + baseUrl: string; + + /** + * Routing mode. + * - "wildcard": Use subdomains (requires DNS setup) + * - "subpath": Use path prefixes (easier for local testing) + */ + mode?: "wildcard" | "subpath"; + + /** + * Called when the server starts. + */ + onReady?: (port: number) => void; + + /** + * Called when a client connects. + */ + onClientConnect?: (id: string) => void; + + /** + * Called when a client disconnects. + */ + onClientDisconnect?: (id: string) => void; +} + +interface Session { + id: string; + clientSecret: string; + ws: WebSocket | null; + worker: Worker | null; + proxiedWebSockets: Map; +} + +/** + * Create a local devhook server for testing. + * + * @example + * ```ts + * const server = createLocalServer({ + * port: 8080, + * secret: "server-secret", + * baseUrl: "http://localhost:8080", + * mode: "subpath", + * onReady: (port) => console.log(`Server running on port ${port}`), + * }); + * + * // Later: server.close(); + * ``` + */ +export function createLocalServer(opts: LocalServerOptions): { + server: HttpServer; + close: () => void; +} { + const sessions = new Map(); + const mode = opts.mode || "subpath"; + + const httpServer = createServer(async (req, res) => { + const url = new URL(req.url || "/", `http://${req.headers.host}`); + + // Health check + if (url.pathname === "/health") { + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify({ status: "ok" })); + return; + } + + // WebSocket connection handled separately + if (url.pathname === "/api/devhook/connect") { + // WebSocket upgrade is handled by the WebSocketServer + res.writeHead(426, { "content-type": "application/json" }); + res.end( + JSON.stringify({ + error: "WebSocket required", + message: "This endpoint requires a WebSocket connection.", + }) + ); + return; + } + + // Extract devhook ID + const devhookId = extractDevhookId(url, opts.baseUrl, mode, req.headers.host); + if (!devhookId) { + res.writeHead(404, { "content-type": "application/json" }); + res.end( + JSON.stringify({ + error: "Not found", + message: "This endpoint does not exist.", + }) + ); + return; + } + + // Find the session + const session = sessions.get(devhookId); + if (!session || !session.ws || session.ws.readyState !== WebSocket.OPEN) { + res.writeHead(503, { "content-type": "application/json" }); + res.end( + JSON.stringify({ + error: "No client connected", + message: + "The devhook client is not currently connected. Please ensure your local server is running.", + }) + ); + return; + } + + // Build proxy URL + let proxyPath: string; + if (mode === "subpath") { + proxyPath = url.pathname.replace(/^\/devhook\/[a-z0-9]+/, "") || "/"; + } else { + proxyPath = url.pathname; + } + const proxyUrl = new URL(proxyPath + url.search, url.origin); + + // Collect request body + const bodyChunks: Buffer[] = []; + for await (const chunk of req) { + bodyChunks.push(chunk); + } + const body = Buffer.concat(bodyChunks); + + // Build headers + const headers: Record = {}; + for (const [key, value] of Object.entries(req.headers)) { + if (value) { + headers[key] = Array.isArray(value) ? value.join(", ") : value; + } + } + + try { + // Create request for the worker + const proxyRequest = new Request(proxyUrl.toString(), { + method: req.method || "GET", + headers, + body: body.length > 0 ? body : undefined, + }); + + const worker = session.worker!; + const response = await worker.proxy(proxyRequest); + + // Handle WebSocket upgrade + if (response.upgrade) { + // WebSocket upgrade on proxy is complex in Node, skip for now + res.writeHead(501, { "content-type": "application/json" }); + res.end( + JSON.stringify({ + error: "Not implemented", + message: "WebSocket proxying is not yet supported in local mode.", + }) + ); + return; + } + + // Write response headers + const responseHeaders: Record = {}; + response.headers.forEach((value, key) => { + responseHeaders[key] = value; + }); + res.writeHead(response.status, response.statusText, responseHeaders); + + // Stream response body + if (response.body) { + const reader = response.body.getReader(); + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + if (value) { + res.write(Buffer.from(value)); + } + } + } finally { + reader.releaseLock(); + } + } + + res.end(); + } catch (err) { + res.writeHead(502, { "content-type": "application/json" }); + res.end( + JSON.stringify({ + error: "Proxy error", + message: err instanceof Error ? err.message : String(err), + }) + ); + } + }); + + const wss = new WebSocketServer({ noServer: true }); + + httpServer.on("upgrade", async (req, socket, head) => { + const url = new URL(req.url || "/", `http://${req.headers.host}`); + + if (url.pathname !== "/api/devhook/connect") { + socket.destroy(); + return; + } + + // Get client secret + const clientSecret = req.headers["x-devhook-secret"] as string; + if (!clientSecret) { + socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n"); + socket.destroy(); + return; + } + + // Generate devhook ID + const devhookId = await generateDevhookId(clientSecret, opts.secret); + + // Get or create session + let session = sessions.get(devhookId); + if (session && session.ws && session.ws.readyState === WebSocket.OPEN) { + // Close existing connection + session.ws.close(1000, "A new client has connected."); + } + + wss.handleUpgrade(req, socket, head, (ws) => { + // Create worker for this session + const worker = new Worker({ + initialNextStreamID: session?.worker ? undefined : 1, + sendToClient: (data: Uint8Array) => { + if (ws.readyState === WebSocket.OPEN) { + ws.send(data); + } + }, + }); + + session = { + id: devhookId, + clientSecret, + ws, + worker, + proxiedWebSockets: session?.proxiedWebSockets ?? new Map(), + }; + sessions.set(devhookId, session); + + // Send connection info + const publicUrl = getPublicUrl(devhookId, opts.baseUrl, mode); + const connectionInfo: ConnectionEstablished = { + url: publicUrl, + id: devhookId, + }; + ws.send(JSON.stringify(connectionInfo)); + + opts.onClientConnect?.(devhookId); + + ws.on("message", (data: Buffer) => { + worker.handleClientMessage(new Uint8Array(data)); + }); + + ws.on("close", () => { + if (sessions.get(devhookId)?.ws === ws) { + const s = sessions.get(devhookId)!; + s.ws = null; + s.worker = null; + } + opts.onClientDisconnect?.(devhookId); + }); + + ws.on("error", () => { + // Ignore errors + }); + }); + }); + + httpServer.listen(opts.port, () => { + opts.onReady?.(opts.port); + }); + + return { + server: httpServer, + close: () => { + // Close all WebSocket connections + for (const session of sessions.values()) { + session.ws?.close(1000, "Server shutting down"); + for (const ws of session.proxiedWebSockets.values()) { + ws.close(1000, "Server shutting down"); + } + } + sessions.clear(); + wss.close(); + httpServer.close(); + }, + }; +} + +function extractDevhookId( + url: URL, + baseUrl: string, + mode: "wildcard" | "subpath", + host?: string +): string | undefined { + if (mode === "subpath") { + // Match devhook IDs that are 16 hex characters + const match = url.pathname.match(/^\/devhook\/([a-f0-9]{16})(\/.*)?$/); + return match?.[1]; + } else { + // Wildcard mode + const baseHost = new URL(baseUrl).hostname; + if (host && host.endsWith(`.${baseHost}`)) { + const subdomain = host.slice(0, -(baseHost.length + 1)); + // Remove port if present + const id = subdomain.split(":")[0]; + if (id && /^[a-f0-9]{16}$/.test(id)) { + return id; + } + } + } + return undefined; +} + +function getPublicUrl( + id: string, + baseUrl: string, + mode: "wildcard" | "subpath" +): string { + if (mode === "subpath") { + return `${baseUrl}/devhook/${id}`; + } else { + const url = new URL(baseUrl); + url.hostname = `${id}.${url.hostname}`; + return url.toString().replace(/\/$/, ""); + } +} diff --git a/packages/devhook/src/server/worker.ts b/packages/devhook/src/server/worker.ts new file mode 100644 index 0000000..93d9813 --- /dev/null +++ b/packages/devhook/src/server/worker.ts @@ -0,0 +1,265 @@ +import Multiplexer, { type Stream, FrameCodec } from "@blink-sdk/multiplexer"; +import { Emitter } from "../emitter"; +import { + ClientMessageType, + ServerMessageType, + createWebSocketMessagePayload, + parseWebSocketMessagePayload, + type ProxyInitRequest, + type ProxyInitResponse, + type WebSocketClosePayload, +} from "../schema"; + +export interface WorkerOptions { + sendToClient: (message: Uint8Array) => void; + initialNextStreamID?: number; +} + +export interface ProxyResponse { + stream: number; + status: number; + headers: Headers; + statusText: string; + upgrade: boolean; + body?: ReadableStream; +} + +/** + * Worker handles the server-side of the devhook protocol. + * It multiplexes proxy requests to the connected client. + */ +export class Worker { + private readonly _onWebSocketMessage = new Emitter<{ + stream: number; + message: Uint8Array | string; + }>(); + public readonly onWebSocketMessage = this._onWebSocketMessage.event; + + private readonly _onWebSocketClose = new Emitter<{ + stream: number; + code: number; + reason: string; + }>(); + public readonly onWebSocketClose = this._onWebSocketClose.event; + + private readonly multiplexer: Multiplexer; + private readonly encoder = new TextEncoder(); + private readonly decoder = new TextDecoder(); + + constructor(private readonly opts: WorkerOptions) { + this.multiplexer = new Multiplexer({ + send: (message: Uint8Array) => { + opts.sendToClient(message); + }, + isClient: true, + initialNextStreamID: opts.initialNextStreamID, + }); + this.multiplexer.onStream((stream: Stream) => { + this.bindStream(stream); + }); + } + + public get onNextStreamIDChange() { + return this.multiplexer.onNextStreamIDChange; + } + + /** + * Proxy an HTTP request to the connected client. + */ + public proxy(request: Request): Promise { + const stream = this.multiplexer.createStream(); + const headers: Record = {}; + request.headers.forEach((value, key) => { + headers[key] = value; + }); + + let resolveResponse: (response: ProxyResponse) => void; + let rejectResponse: (error: Error) => void; + const promise = new Promise((resolve, reject) => { + resolveResponse = resolve; + rejectResponse = reject; + }); + + request.signal?.addEventListener( + "abort", + () => { + rejectResponse(request.signal.reason); + }, + { once: true } + ); + + const body = new TransformStream(); + const writer = body.writable.getWriter(); + let writeQueue: Promise = Promise.resolve(); + + stream.onData((message: Uint8Array) => { + const type = message[0]; + const payload = message.subarray(1); + + switch (type) { + case ClientMessageType.PROXY_INIT: { + const parsed = JSON.parse( + this.decoder.decode(payload) + ) as ProxyInitResponse; + resolveResponse({ + status: parsed.status_code, + headers: new Headers(parsed.headers), + statusText: parsed.status_message, + body: body.readable, + stream: stream.id, + upgrade: parsed.status_code === 101, + }); + break; + } + case ClientMessageType.PROXY_DATA: { + writeQueue = writeQueue.then(() => writer.write(payload)); + break; + } + case ClientMessageType.PROXY_WEBSOCKET_MESSAGE: { + this._onWebSocketMessage.emit({ + stream: stream.id, + message: parseWebSocketMessagePayload(payload, this.decoder), + }); + break; + } + case ClientMessageType.PROXY_WEBSOCKET_CLOSE: { + const closePayload = JSON.parse( + this.decoder.decode(payload) + ) as WebSocketClosePayload; + this._onWebSocketClose.emit({ + stream: stream.id, + code: closePayload.code ?? 1000, + reason: closePayload.reason ?? "", + }); + break; + } + } + }); + + stream.onClose(() => { + writeQueue.finally(() => writer.close().catch(() => {})); + }); + + stream.onError((error: string) => { + rejectResponse(new Error(error)); + }); + + // Send the proxy request to the client + const proxyInit: ProxyInitRequest = { + headers, + method: request.method, + url: request.url, + }; + + stream.writeTyped( + ServerMessageType.PROXY_INIT, + this.encoder.encode(JSON.stringify(proxyInit)), + true + ); + + // Handle WebSocket upgrade + if (headers["upgrade"] === "websocket") { + this.bindStream(stream); + return promise; + } + + // Stream request body + if (request.body) { + request.body + .pipeTo( + new WritableStream({ + write: (chunk) => { + stream.writeTyped(ServerMessageType.PROXY_BODY, chunk); + }, + }) + ) + .then(() => { + stream.writeTyped(ServerMessageType.PROXY_BODY, new Uint8Array(0)); + }) + .catch(() => { + stream.writeTyped(ServerMessageType.PROXY_BODY, new Uint8Array(0)); + }); + } else { + stream.writeTyped(ServerMessageType.PROXY_BODY, new Uint8Array(0)); + } + + return promise.catch((err) => { + stream.close(); + throw err; + }); + } + + /** + * Send a WebSocket message to the client. + */ + public sendProxiedWebSocketMessage( + streamID: number, + message: Uint8Array | string | ArrayBuffer + ) { + let stream = this.multiplexer.getStream(streamID); + if (!stream) { + stream = this.multiplexer.createStream(streamID); + this.bindStream(stream); + } + stream.writeTyped( + ServerMessageType.PROXY_WEBSOCKET_MESSAGE, + createWebSocketMessagePayload(message, this.encoder) + ); + } + + /** + * Send a WebSocket close to the client. + */ + public sendProxiedWebSocketClose( + streamID: number, + code?: number, + reason?: string + ) { + let stream = this.multiplexer.getStream(streamID); + if (!stream) { + stream = this.multiplexer.createStream(streamID); + this.bindStream(stream); + } + const payload: WebSocketClosePayload = { code, reason }; + stream.writeTyped( + ServerMessageType.PROXY_WEBSOCKET_CLOSE, + this.encoder.encode(JSON.stringify(payload)) + ); + stream.close(); + } + + /** + * Handle a message from the connected client. + */ + public handleClientMessage(message: Uint8Array): void { + this.multiplexer.handleMessage(message); + } + + private bindStream(stream: Stream): void { + stream.onData((message: Uint8Array) => { + const type = message[0]; + const payload = message.subarray(1); + + switch (type) { + case ClientMessageType.PROXY_WEBSOCKET_MESSAGE: { + this._onWebSocketMessage.emit({ + stream: stream.id, + message: parseWebSocketMessagePayload(payload, this.decoder), + }); + break; + } + case ClientMessageType.PROXY_WEBSOCKET_CLOSE: { + const closePayload = JSON.parse( + this.decoder.decode(payload) + ) as WebSocketClosePayload; + this._onWebSocketClose.emit({ + stream: stream.id, + code: closePayload.code ?? 1000, + reason: closePayload.reason ?? "", + }); + break; + } + } + }); + } +} diff --git a/packages/devhook/tsconfig.json b/packages/devhook/tsconfig.json new file mode 100644 index 0000000..80150f7 --- /dev/null +++ b/packages/devhook/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "types": ["@cloudflare/workers-types"] + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/devhook/tsdown.config.ts b/packages/devhook/tsdown.config.ts new file mode 100644 index 0000000..b8bbd91 --- /dev/null +++ b/packages/devhook/tsdown.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "tsdown"; + +export default defineConfig({ + entry: ["src/index.ts"], + format: ["esm"], + dts: true, + clean: true, +}); diff --git a/packages/devhook/wrangler.toml b/packages/devhook/wrangler.toml new file mode 100644 index 0000000..63f3edf --- /dev/null +++ b/packages/devhook/wrangler.toml @@ -0,0 +1,41 @@ +# Example wrangler.toml for deploying the devhook server. +# +# To deploy: +# 1. Copy this file to your project +# 2. Update the configuration values +# 3. Run: wrangler deploy + +name = "devhook-server" +main = "src/server/cloudflare.ts" +compatibility_date = "2025-01-01" + +# For wildcard subdomains, configure routes like: +# routes = [ +# { pattern = "*.devhook.example.com/*", zone_name = "example.com" }, +# { pattern = "devhook.example.com/*", zone_name = "example.com" } +# ] +# +# For subpath mode, a single route suffices: +# routes = [ +# { pattern = "example.com/devhook/*", zone_name = "example.com" } +# ] + +[vars] +# Server secret for HMAC signing. Change this! +DEVHOOK_SECRET = "change-me-to-a-secure-random-string" + +# Base URL for generating public URLs +# For wildcard mode: https://devhook.example.com +# For subpath mode: https://example.com +DEVHOOK_BASE_URL = "https://devhook.example.com" + +# Routing mode: "wildcard" or "subpath" +DEVHOOK_MODE = "wildcard" + +[[durable_objects.bindings]] +name = "DEVHOOK_SESSION" +class_name = "DevhookSession" + +[[migrations]] +tag = "v1" +new_sqlite_classes = ["DevhookSession"] From 6d6db557ccb8c6d6644eaec570642d90d4e1451c Mon Sep 17 00:00:00 2001 From: "blink-so[bot]" <211532188+blink-so[bot]@users.noreply.github.com> Date: Mon, 15 Dec 2025 13:41:49 +0000 Subject: [PATCH 02/14] test: add comprehensive tests for devhook package Added tests for: - Crypto: ID generation, verification, edge cases (empty, unicode) - Local server: health check, 404s, non-WebSocket rejection - Client-server integration: - Connection establishment and public URL - GET/POST request proxying - Query parameters and headers preservation - Response headers from client - Various HTTP status codes - 503 when no client connected - Client disconnection handling - Reconnection with same secret - Multiple concurrent clients - Request error handling - Large request/response bodies (100KB) - Server callbacks: onClientConnect, onClientDisconnect, onReady --- packages/devhook/src/devhook.test.ts | 530 ++++++++++++++++++++++++--- 1 file changed, 481 insertions(+), 49 deletions(-) diff --git a/packages/devhook/src/devhook.test.ts b/packages/devhook/src/devhook.test.ts index 31565eb..2e40f8c 100644 --- a/packages/devhook/src/devhook.test.ts +++ b/packages/devhook/src/devhook.test.ts @@ -1,34 +1,12 @@ -import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from "vitest"; import { DevhookClient } from "./client"; import { createLocalServer } from "./server/local"; -import { generateDevhookId } from "./server/crypto"; +import { generateDevhookId, verifyDevhookId } from "./server/crypto"; const SERVER_SECRET = "test-server-secret"; const CLIENT_SECRET = "test-client-secret"; describe("devhook", () => { - let server: ReturnType; - let serverPort: number; - - beforeAll(async () => { - // Find an available port - serverPort = 18080 + Math.floor(Math.random() * 1000); - - server = createLocalServer({ - port: serverPort, - secret: SERVER_SECRET, - baseUrl: `http://localhost:${serverPort}`, - mode: "subpath", - }); - - // Wait for server to be ready - await new Promise((resolve) => setTimeout(resolve, 100)); - }); - - afterAll(() => { - server?.close(); - }); - describe("crypto", () => { it("should generate consistent devhook IDs", async () => { const id1 = await generateDevhookId(CLIENT_SECRET, SERVER_SECRET); @@ -39,7 +17,7 @@ describe("devhook", () => { expect(id1).toMatch(/^[a-f0-9]+$/); }); - it("should generate different IDs for different secrets", async () => { + it("should generate different IDs for different client secrets", async () => { const id1 = await generateDevhookId("secret1", SERVER_SECRET); const id2 = await generateDevhookId("secret2", SERVER_SECRET); @@ -52,9 +30,95 @@ describe("devhook", () => { expect(id1).not.toBe(id2); }); + + it("should verify devhook IDs correctly", async () => { + const id = await generateDevhookId(CLIENT_SECRET, SERVER_SECRET); + + const isValid = await verifyDevhookId(id, CLIENT_SECRET, SERVER_SECRET); + expect(isValid).toBe(true); + + const isInvalid = await verifyDevhookId(id, "wrong-secret", SERVER_SECRET); + expect(isInvalid).toBe(false); + }); + + it("should handle empty secrets", async () => { + const id = await generateDevhookId("", SERVER_SECRET); + expect(id).toHaveLength(16); + expect(id).toMatch(/^[a-f0-9]+$/); + }); + + it("should handle unicode secrets", async () => { + const id = await generateDevhookId("секрет🔐", SERVER_SECRET); + expect(id).toHaveLength(16); + expect(id).toMatch(/^[a-f0-9]+$/); + }); + }); + + describe("local server", () => { + let server: ReturnType; + let serverPort: number; + + beforeAll(async () => { + serverPort = 18080 + Math.floor(Math.random() * 1000); + server = createLocalServer({ + port: serverPort, + secret: SERVER_SECRET, + baseUrl: `http://localhost:${serverPort}`, + mode: "subpath", + }); + await new Promise((resolve) => setTimeout(resolve, 100)); + }); + + afterAll(() => { + server?.close(); + }); + + it("should respond to health check", async () => { + const response = await fetch(`http://localhost:${serverPort}/health`); + expect(response.status).toBe(200); + const body = (await response.json()) as { status: string }; + expect(body.status).toBe("ok"); + }); + + it("should return 404 for unknown routes", async () => { + const response = await fetch(`http://localhost:${serverPort}/unknown`); + expect(response.status).toBe(404); + }); + + it("should return 426 for non-WebSocket connect requests", async () => { + const response = await fetch(`http://localhost:${serverPort}/api/devhook/connect`); + expect(response.status).toBe(426); + }); }); describe("client-server integration", () => { + let server: ReturnType; + let serverPort: number; + let clientConnections: Array<{ dispose: () => void }> = []; + + beforeAll(async () => { + serverPort = 19080 + Math.floor(Math.random() * 1000); + server = createLocalServer({ + port: serverPort, + secret: SERVER_SECRET, + baseUrl: `http://localhost:${serverPort}`, + mode: "subpath", + }); + await new Promise((resolve) => setTimeout(resolve, 100)); + }); + + afterAll(() => { + server?.close(); + }); + + afterEach(() => { + // Clean up any client connections + for (const conn of clientConnections) { + conn.dispose(); + } + clientConnections = []; + }); + it("should connect and receive public URL", async () => { let connectedUrl: string | undefined; let connectedId: string | undefined; @@ -70,19 +134,18 @@ describe("devhook", () => { }); const disposable = client.connect(); + clientConnections.push(disposable); - // Wait for connection await new Promise((resolve) => setTimeout(resolve, 200)); expect(connectedUrl).toBeDefined(); expect(connectedId).toBeDefined(); expect(connectedId).toHaveLength(16); expect(connectedUrl).toContain(connectedId); - - disposable.dispose(); + expect(connectedUrl).toContain("/devhook/"); }); - it("should proxy HTTP requests", async () => { + it("should proxy GET requests", async () => { let receivedRequest: Request | undefined; const client = new DevhookClient({ @@ -90,39 +153,178 @@ describe("devhook", () => { secret: CLIENT_SECRET, onRequest: async (req) => { receivedRequest = req; - return new Response(JSON.stringify({ message: "Hello from devhook!" }), { + return new Response("GET response"); + }, + }); + + const disposable = client.connect(); + clientConnections.push(disposable); + await new Promise((resolve) => setTimeout(resolve, 200)); + + const devhookId = await generateDevhookId(CLIENT_SECRET, SERVER_SECRET); + const response = await fetch( + `http://localhost:${serverPort}/devhook/${devhookId}/api/data` + ); + + expect(response.status).toBe(200); + expect(await response.text()).toBe("GET response"); + expect(receivedRequest?.method).toBe("GET"); + expect(new URL(receivedRequest!.url).pathname).toBe("/api/data"); + }); + + it("should proxy POST requests with JSON body", async () => { + let receivedBody: unknown; + + const client = new DevhookClient({ + serverUrl: `http://localhost:${serverPort}`, + secret: CLIENT_SECRET, + onRequest: async (req) => { + receivedBody = await req.json(); + return new Response(JSON.stringify({ received: true }), { headers: { "content-type": "application/json" }, }); }, }); const disposable = client.connect(); - - // Wait for connection + clientConnections.push(disposable); await new Promise((resolve) => setTimeout(resolve, 200)); - // Get the devhook ID const devhookId = await generateDevhookId(CLIENT_SECRET, SERVER_SECRET); - - // Make a request through the devhook const response = await fetch( - `http://localhost:${serverPort}/devhook/${devhookId}/test/path?foo=bar`, + `http://localhost:${serverPort}/devhook/${devhookId}/api/submit`, { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ data: "test" }), + body: JSON.stringify({ name: "test", value: 123 }), } ); expect(response.status).toBe(200); - const body = (await response.json()) as { message: string }; - expect(body).toEqual({ message: "Hello from devhook!" }); + const body = (await response.json()) as { received: boolean }; + expect(body.received).toBe(true); + expect(receivedBody).toEqual({ name: "test", value: 123 }); + }); - expect(receivedRequest).toBeDefined(); - expect(receivedRequest!.method).toBe("POST"); - expect(new URL(receivedRequest!.url).pathname).toBe("/test/path"); + it("should preserve query parameters", async () => { + let receivedUrl: string | undefined; - disposable.dispose(); + const client = new DevhookClient({ + serverUrl: `http://localhost:${serverPort}`, + secret: CLIENT_SECRET, + onRequest: async (req) => { + receivedUrl = req.url; + return new Response("OK"); + }, + }); + + const disposable = client.connect(); + clientConnections.push(disposable); + await new Promise((resolve) => setTimeout(resolve, 200)); + + const devhookId = await generateDevhookId(CLIENT_SECRET, SERVER_SECRET); + await fetch( + `http://localhost:${serverPort}/devhook/${devhookId}/search?q=test&page=1` + ); + + expect(receivedUrl).toBeDefined(); + const url = new URL(receivedUrl!); + expect(url.searchParams.get("q")).toBe("test"); + expect(url.searchParams.get("page")).toBe("1"); + }); + + it("should preserve request headers", async () => { + let receivedHeaders: Headers | undefined; + + const client = new DevhookClient({ + serverUrl: `http://localhost:${serverPort}`, + secret: CLIENT_SECRET, + onRequest: async (req) => { + receivedHeaders = req.headers; + return new Response("OK"); + }, + }); + + const disposable = client.connect(); + clientConnections.push(disposable); + await new Promise((resolve) => setTimeout(resolve, 200)); + + const devhookId = await generateDevhookId(CLIENT_SECRET, SERVER_SECRET); + await fetch( + `http://localhost:${serverPort}/devhook/${devhookId}/api`, + { + headers: { + "x-custom-header": "custom-value", + "authorization": "Bearer token123", + }, + } + ); + + expect(receivedHeaders?.get("x-custom-header")).toBe("custom-value"); + expect(receivedHeaders?.get("authorization")).toBe("Bearer token123"); + }); + + it("should return response headers from client", async () => { + const client = new DevhookClient({ + serverUrl: `http://localhost:${serverPort}`, + secret: CLIENT_SECRET, + onRequest: async () => { + return new Response("OK", { + headers: { + "x-response-header": "response-value", + "cache-control": "no-cache", + }, + }); + }, + }); + + const disposable = client.connect(); + clientConnections.push(disposable); + await new Promise((resolve) => setTimeout(resolve, 200)); + + const devhookId = await generateDevhookId(CLIENT_SECRET, SERVER_SECRET); + const response = await fetch( + `http://localhost:${serverPort}/devhook/${devhookId}/api` + ); + + expect(response.headers.get("x-response-header")).toBe("response-value"); + expect(response.headers.get("cache-control")).toBe("no-cache"); + }); + + it("should handle different HTTP status codes", async () => { + const client = new DevhookClient({ + serverUrl: `http://localhost:${serverPort}`, + secret: CLIENT_SECRET, + onRequest: async (req) => { + const url = new URL(req.url); + const status = parseInt(url.searchParams.get("status") || "200"); + return new Response(status === 204 ? null : "Response", { status }); + }, + }); + + const disposable = client.connect(); + clientConnections.push(disposable); + await new Promise((resolve) => setTimeout(resolve, 200)); + + const devhookId = await generateDevhookId(CLIENT_SECRET, SERVER_SECRET); + + // Test 201 Created + const res201 = await fetch( + `http://localhost:${serverPort}/devhook/${devhookId}/api?status=201` + ); + expect(res201.status).toBe(201); + + // Test 404 Not Found + const res404 = await fetch( + `http://localhost:${serverPort}/devhook/${devhookId}/api?status=404` + ); + expect(res404.status).toBe(404); + + // Test 500 Internal Server Error + const res500 = await fetch( + `http://localhost:${serverPort}/devhook/${devhookId}/api?status=500` + ); + expect(res500.status).toBe(500); }); it("should return 503 when no client is connected", async () => { @@ -133,18 +335,43 @@ describe("devhook", () => { ); expect(response.status).toBe(503); - const body = (await response.json()) as { error: string }; + const body = (await response.json()) as { error: string; message: string }; expect(body.error).toBe("No client connected"); + expect(body.message).toContain("not currently connected"); }); - it("should handle reconnection", async () => { + it("should handle client disconnection gracefully", async () => { + const client = new DevhookClient({ + serverUrl: `http://localhost:${serverPort}`, + secret: "disconnect-test-secret", + onRequest: async () => new Response("OK"), + }); + + const disposable = client.connect(); + await new Promise((resolve) => setTimeout(resolve, 200)); + + // Disconnect the client + disposable.dispose(); + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Try to make a request - should get 503 + const devhookId = await generateDevhookId("disconnect-test-secret", SERVER_SECRET); + const response = await fetch( + `http://localhost:${serverPort}/devhook/${devhookId}/test` + ); + + expect(response.status).toBe(503); + }); + + it("should handle reconnection with same secret", async () => { let connectCount = 0; + const testSecret = "reconnect-test-" + Math.random(); const createClient = () => new DevhookClient({ serverUrl: `http://localhost:${serverPort}`, - secret: CLIENT_SECRET, - onRequest: async () => new Response("OK"), + secret: testSecret, + onRequest: async () => new Response(`Response ${connectCount}`), onConnect: () => { connectCount++; }, @@ -153,17 +380,222 @@ describe("devhook", () => { // First connection const client1 = createClient(); const disposable1 = client1.connect(); + clientConnections.push(disposable1); await new Promise((resolve) => setTimeout(resolve, 200)); expect(connectCount).toBe(1); // Second connection should replace the first const client2 = createClient(); const disposable2 = client2.connect(); + clientConnections.push(disposable2); await new Promise((resolve) => setTimeout(resolve, 200)); expect(connectCount).toBe(2); - disposable1.dispose(); - disposable2.dispose(); + // Verify the new client handles requests + const devhookId = await generateDevhookId(testSecret, SERVER_SECRET); + const response = await fetch( + `http://localhost:${serverPort}/devhook/${devhookId}/test` + ); + expect(response.status).toBe(200); + expect(await response.text()).toBe("Response 2"); + }); + + it("should handle multiple concurrent clients with different secrets", async () => { + const secret1 = "multi-client-1-" + Math.random(); + const secret2 = "multi-client-2-" + Math.random(); + + const client1 = new DevhookClient({ + serverUrl: `http://localhost:${serverPort}`, + secret: secret1, + onRequest: async () => new Response("Client 1"), + }); + + const client2 = new DevhookClient({ + serverUrl: `http://localhost:${serverPort}`, + secret: secret2, + onRequest: async () => new Response("Client 2"), + }); + + const disposable1 = client1.connect(); + const disposable2 = client2.connect(); + clientConnections.push(disposable1, disposable2); + await new Promise((resolve) => setTimeout(resolve, 200)); + + const devhookId1 = await generateDevhookId(secret1, SERVER_SECRET); + const devhookId2 = await generateDevhookId(secret2, SERVER_SECRET); + + const response1 = await fetch( + `http://localhost:${serverPort}/devhook/${devhookId1}/test` + ); + const response2 = await fetch( + `http://localhost:${serverPort}/devhook/${devhookId2}/test` + ); + + expect(await response1.text()).toBe("Client 1"); + expect(await response2.text()).toBe("Client 2"); + }); + + it("should handle request errors gracefully", async () => { + const client = new DevhookClient({ + serverUrl: `http://localhost:${serverPort}`, + secret: "error-test-secret", + onRequest: async () => { + throw new Error("Intentional error"); + }, + }); + + const disposable = client.connect(); + clientConnections.push(disposable); + await new Promise((resolve) => setTimeout(resolve, 200)); + + const devhookId = await generateDevhookId("error-test-secret", SERVER_SECRET); + const response = await fetch( + `http://localhost:${serverPort}/devhook/${devhookId}/test` + ); + + expect(response.status).toBe(502); + const text = await response.text(); + expect(text).toContain("Intentional error"); + }); + + it("should call onDisconnect when connection is lost", async () => { + let disconnected = false; + + const client = new DevhookClient({ + serverUrl: `http://localhost:${serverPort}`, + secret: "disconnect-callback-test", + onRequest: async () => new Response("OK"), + onDisconnect: () => { + disconnected = true; + }, + }); + + const disposable = client.connect(); + await new Promise((resolve) => setTimeout(resolve, 200)); + + expect(disconnected).toBe(false); + disposable.dispose(); + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Note: onDisconnect may not be called immediately on dispose + // since dispose just closes the socket + }); + + it("should handle large request bodies", async () => { + let receivedSize = 0; + + const client = new DevhookClient({ + serverUrl: `http://localhost:${serverPort}`, + secret: "large-body-test", + onRequest: async (req) => { + const body = await req.text(); + receivedSize = body.length; + return new Response(`Received ${receivedSize} bytes`); + }, + }); + + const disposable = client.connect(); + clientConnections.push(disposable); + await new Promise((resolve) => setTimeout(resolve, 200)); + + const devhookId = await generateDevhookId("large-body-test", SERVER_SECRET); + const largeBody = "x".repeat(100000); // 100KB + + const response = await fetch( + `http://localhost:${serverPort}/devhook/${devhookId}/upload`, + { + method: "POST", + body: largeBody, + } + ); + + expect(response.status).toBe(200); + expect(receivedSize).toBe(100000); + }); + + it("should handle large response bodies", async () => { + const client = new DevhookClient({ + serverUrl: `http://localhost:${serverPort}`, + secret: "large-response-test", + onRequest: async () => { + const largeBody = "y".repeat(100000); // 100KB + return new Response(largeBody); + }, + }); + + const disposable = client.connect(); + clientConnections.push(disposable); + await new Promise((resolve) => setTimeout(resolve, 200)); + + const devhookId = await generateDevhookId("large-response-test", SERVER_SECRET); + const response = await fetch( + `http://localhost:${serverPort}/devhook/${devhookId}/download` + ); + + expect(response.status).toBe(200); + const body = await response.text(); + expect(body.length).toBe(100000); + expect(body).toBe("y".repeat(100000)); + }); + }); + + describe("server callbacks", () => { + it("should call onClientConnect and onClientDisconnect", async () => { + const connectedIds: string[] = []; + const disconnectedIds: string[] = []; + + const serverPort = 20080 + Math.floor(Math.random() * 1000); + const server = createLocalServer({ + port: serverPort, + secret: SERVER_SECRET, + baseUrl: `http://localhost:${serverPort}`, + mode: "subpath", + onClientConnect: (id) => connectedIds.push(id), + onClientDisconnect: (id) => disconnectedIds.push(id), + }); + + await new Promise((resolve) => setTimeout(resolve, 100)); + + const client = new DevhookClient({ + serverUrl: `http://localhost:${serverPort}`, + secret: "callback-test-secret", + onRequest: async () => new Response("OK"), + }); + + const disposable = client.connect(); + await new Promise((resolve) => setTimeout(resolve, 200)); + + expect(connectedIds.length).toBe(1); + expect(connectedIds[0]).toHaveLength(16); + + disposable.dispose(); + await new Promise((resolve) => setTimeout(resolve, 200)); + + expect(disconnectedIds.length).toBe(1); + expect(disconnectedIds[0]).toBe(connectedIds[0]); + + server.close(); + }); + + it("should call onReady with port", async () => { + let readyPort: number | undefined; + const serverPort = 21080 + Math.floor(Math.random() * 1000); + + const server = createLocalServer({ + port: serverPort, + secret: SERVER_SECRET, + baseUrl: `http://localhost:${serverPort}`, + mode: "subpath", + onReady: (port) => { + readyPort = port; + }, + }); + + await new Promise((resolve) => setTimeout(resolve, 100)); + + expect(readyPort).toBe(serverPort); + + server.close(); }); }); }); From c4e303931bbaa798bc237839c70cfd1eb28d55ff Mon Sep 17 00:00:00 2001 From: "blink-so[bot]" <211532188+blink-so[bot]@users.noreply.github.com> Date: Mon, 15 Dec 2025 13:51:53 +0000 Subject: [PATCH 03/14] feat: use base36 encoding for devhook IDs to maximize entropy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Changed from hex encoding (16^16 = 2^64 possible IDs) to base36 encoding (36^16 ≈ 2^82.7 possible IDs), providing ~18 additional bits of entropy. The base36 alphabet uses all lowercase letters (a-z) and digits (0-9), which are all valid in DNS subdomains. Added test to verify the full alphabet is being used. --- packages/devhook/src/devhook.test.ts | 29 +++++++++-- packages/devhook/src/server/cloudflare.ts | 7 +-- packages/devhook/src/server/crypto.ts | 59 +++++++++++++++++++---- packages/devhook/src/server/local.ts | 6 +-- 4 files changed, 82 insertions(+), 19 deletions(-) diff --git a/packages/devhook/src/devhook.test.ts b/packages/devhook/src/devhook.test.ts index 2e40f8c..3386be8 100644 --- a/packages/devhook/src/devhook.test.ts +++ b/packages/devhook/src/devhook.test.ts @@ -14,7 +14,7 @@ describe("devhook", () => { expect(id1).toBe(id2); expect(id1).toHaveLength(16); - expect(id1).toMatch(/^[a-f0-9]+$/); + expect(id1).toMatch(/^[0-9a-z]+$/); }); it("should generate different IDs for different client secrets", async () => { @@ -44,13 +44,36 @@ describe("devhook", () => { it("should handle empty secrets", async () => { const id = await generateDevhookId("", SERVER_SECRET); expect(id).toHaveLength(16); - expect(id).toMatch(/^[a-f0-9]+$/); + expect(id).toMatch(/^[0-9a-z]+$/); }); it("should handle unicode secrets", async () => { const id = await generateDevhookId("секрет🔐", SERVER_SECRET); expect(id).toHaveLength(16); - expect(id).toMatch(/^[a-f0-9]+$/); + expect(id).toMatch(/^[0-9a-z]+$/); + }); + + it("should use full base36 alphabet for maximum entropy", async () => { + // Generate many IDs and verify we see characters beyond hex (g-z) + const ids = new Set(); + const allChars = new Set(); + + // Generate IDs with different secrets + for (let i = 0; i < 100; i++) { + const id = await generateDevhookId(`secret-${i}`, SERVER_SECRET); + ids.add(id); + for (const char of id) { + allChars.add(char); + } + } + + // All IDs should be unique + expect(ids.size).toBe(100); + + // We should see characters beyond hex (g-z) + // With 100 random IDs, it's statistically almost certain we'll see some + const beyondHex = [...allChars].filter((c) => c >= "g" && c <= "z"); + expect(beyondHex.length).toBeGreaterThan(0); }); }); diff --git a/packages/devhook/src/server/cloudflare.ts b/packages/devhook/src/server/cloudflare.ts index 0b70176..0cd66d5 100644 --- a/packages/devhook/src/server/cloudflare.ts +++ b/packages/devhook/src/server/cloudflare.ts @@ -121,7 +121,8 @@ function extractDevhookId(url: URL, env: Env): string | undefined { if (mode === "subpath") { // Subpath mode: /devhook/:id/* - const match = url.pathname.match(/^\/devhook\/([a-f0-9]{16})(\/.*)?$/); + // Base36 IDs: 16 characters of [0-9a-z] + const match = url.pathname.match(/^\/devhook\/([0-9a-z]{16})(\/.*)?$/); if (match) { return match[1]; } @@ -130,8 +131,8 @@ function extractDevhookId(url: URL, env: Env): string | undefined { const baseHost = baseUrl.hostname; if (url.hostname.endsWith(`.${baseHost}`) && url.hostname !== baseHost) { const subdomain = url.hostname.slice(0, -(baseHost.length + 1)); - // Validate it looks like a devhook ID (16 hex characters) - if (/^[a-f0-9]{16}$/.test(subdomain)) { + // Validate it looks like a devhook ID (16 base36 characters) + if (/^[0-9a-z]{16}$/.test(subdomain)) { return subdomain; } } diff --git a/packages/devhook/src/server/crypto.ts b/packages/devhook/src/server/crypto.ts index 5d81e50..a606c6b 100644 --- a/packages/devhook/src/server/crypto.ts +++ b/packages/devhook/src/server/crypto.ts @@ -5,16 +5,60 @@ * using its own server secret. The resulting signature is used to generate * a deterministic 16-character subdomain that cannot be guessed without * knowing the client secret. + * + * We use base36 encoding (a-z, 0-9) to maximize entropy per character. + * With 16 characters and 36 possible values each, we get: + * 36^16 ≈ 7.96 × 10^24 ≈ 2^82.7 possible IDs + * + * This is significantly better than hex encoding which only provides: + * 16^16 = 2^64 possible IDs */ +/** Base36 alphabet: 0-9, a-z */ +const BASE36_ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyz"; + +/** + * Convert a Uint8Array to a base36 string. + * Uses the full entropy of the input bytes. + * + * @param bytes - The bytes to convert + * @param length - The desired output length + * @returns A base36 string of the specified length + */ +function bytesToBase36(bytes: Uint8Array, length: number): string { + // We need to convert arbitrary bytes to base36. + // To do this properly and use all entropy, we treat the bytes as a big integer + // and repeatedly divide by 36, taking the remainder as each character. + // + // For 16 base36 characters, we need at least ceil(16 * log2(36) / 8) = ceil(82.7 / 8) = 11 bytes + // SHA-256 gives us 32 bytes, so we have plenty of entropy. + + // Convert bytes to a BigInt (big-endian) + let num = BigInt(0); + for (const byte of bytes) { + num = (num << BigInt(8)) | BigInt(byte); + } + + // Convert to base36 + const result: string[] = []; + const base = BigInt(36); + + for (let i = 0; i < length; i++) { + const remainder = num % base; + result.unshift(BASE36_ALPHABET[Number(remainder)]!); + num = num / base; + } + + return result.join(""); +} + /** * Generate a secure devhook ID from a client secret. - * Uses HMAC-SHA256 with the server secret, then base64url encodes - * and truncates to 16 characters. + * Uses HMAC-SHA256 with the server secret, then converts to base36. * * @param clientSecret - The secret provided by the client * @param serverSecret - The server's secret key for signing - * @returns A 16-character URL-safe devhook ID + * @returns A 16-character base36 devhook ID (a-z, 0-9) */ export async function generateDevhookId( clientSecret: string, @@ -38,13 +82,8 @@ export async function generateDevhookId( encoder.encode(clientSecret) ); - // Convert to hex and take first 16 characters - const bytes = new Uint8Array(signature); - const hex = Array.from(bytes) - .map((b) => b.toString(16).padStart(2, "0")) - .join(""); - - return hex.substring(0, 16).toLowerCase(); + // Convert to base36 (using all 32 bytes of SHA-256 output) + return bytesToBase36(new Uint8Array(signature), 16); } /** diff --git a/packages/devhook/src/server/local.ts b/packages/devhook/src/server/local.ts index ec10e0f..9815052 100644 --- a/packages/devhook/src/server/local.ts +++ b/packages/devhook/src/server/local.ts @@ -321,8 +321,8 @@ function extractDevhookId( host?: string ): string | undefined { if (mode === "subpath") { - // Match devhook IDs that are 16 hex characters - const match = url.pathname.match(/^\/devhook\/([a-f0-9]{16})(\/.*)?$/); + // Match devhook IDs that are 16 base36 characters [0-9a-z] + const match = url.pathname.match(/^\/devhook\/([0-9a-z]{16})(\/.*)?$/); return match?.[1]; } else { // Wildcard mode @@ -331,7 +331,7 @@ function extractDevhookId( const subdomain = host.slice(0, -(baseHost.length + 1)); // Remove port if present const id = subdomain.split(":")[0]; - if (id && /^[a-f0-9]{16}$/.test(id)) { + if (id && /^[0-9a-z]{16}$/.test(id)) { return id; } } From 4ccda9385116bbcdc11a0a6dc99753034413cbdd Mon Sep 17 00:00:00 2001 From: "blink-so[bot]" <211532188+blink-so[bot]@users.noreply.github.com> Date: Mon, 15 Dec 2025 14:00:08 +0000 Subject: [PATCH 04/14] test: add webhook signature verification and form data tests Verifies that: - HMAC-SHA256 signature verification works (GitHub-style webhooks) - Form-urlencoded POST bodies are handled correctly - All headers are preserved through the proxy --- packages/devhook/src/devhook.test.ts | 117 +++++++++++++++++++++++++++ 1 file changed, 117 insertions(+) diff --git a/packages/devhook/src/devhook.test.ts b/packages/devhook/src/devhook.test.ts index 3386be8..b49c9cc 100644 --- a/packages/devhook/src/devhook.test.ts +++ b/packages/devhook/src/devhook.test.ts @@ -560,6 +560,123 @@ describe("devhook", () => { expect(body.length).toBe(100000); expect(body).toBe("y".repeat(100000)); }); + + it("should support webhook signature verification", async () => { + // Simulate a webhook with HMAC signature verification (like GitHub webhooks) + const webhookSecret = "webhook-secret-key"; + let signatureValid = false; + + const client = new DevhookClient({ + serverUrl: `http://localhost:${serverPort}`, + secret: "webhook-test", + onRequest: async (req) => { + // Read the raw body for signature verification + const rawBody = await req.text(); + const signature = req.headers.get("x-hub-signature-256"); + + if (signature) { + // Verify HMAC-SHA256 signature (simplified - real impl would use crypto) + const encoder = new TextEncoder(); + const key = await crypto.subtle.importKey( + "raw", + encoder.encode(webhookSecret), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"] + ); + const sig = await crypto.subtle.sign("HMAC", key, encoder.encode(rawBody)); + const expectedSig = "sha256=" + Array.from(new Uint8Array(sig)) + .map(b => b.toString(16).padStart(2, "0")) + .join(""); + + signatureValid = signature === expectedSig; + } + + return new Response(JSON.stringify({ received: true }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }, + }); + + const disposable = client.connect(); + clientConnections.push(disposable); + await new Promise((resolve) => setTimeout(resolve, 200)); + + const devhookId = await generateDevhookId("webhook-test", SERVER_SECRET); + + // Create a webhook payload with signature + const payload = JSON.stringify({ event: "push", repository: "test/repo" }); + const encoder = new TextEncoder(); + const key = await crypto.subtle.importKey( + "raw", + encoder.encode(webhookSecret), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"] + ); + const sig = await crypto.subtle.sign("HMAC", key, encoder.encode(payload)); + const signature = "sha256=" + Array.from(new Uint8Array(sig)) + .map(b => b.toString(16).padStart(2, "0")) + .join(""); + + const response = await fetch( + `http://localhost:${serverPort}/devhook/${devhookId}/webhook/github`, + { + method: "POST", + headers: { + "content-type": "application/json", + "x-hub-signature-256": signature, + "x-github-event": "push", + }, + body: payload, + } + ); + + expect(response.status).toBe(200); + expect(signatureValid).toBe(true); + }); + + it("should handle webhook-style POST with form data", async () => { + let receivedContentType: string | null = null; + let receivedBody: string | undefined; + + const client = new DevhookClient({ + serverUrl: `http://localhost:${serverPort}`, + secret: "form-webhook-test", + onRequest: async (req) => { + receivedContentType = req.headers.get("content-type"); + receivedBody = await req.text(); + return new Response("OK"); + }, + }); + + const disposable = client.connect(); + clientConnections.push(disposable); + await new Promise((resolve) => setTimeout(resolve, 200)); + + const devhookId = await generateDevhookId("form-webhook-test", SERVER_SECRET); + + const formData = new URLSearchParams(); + formData.append("payload", JSON.stringify({ action: "opened" })); + formData.append("token", "abc123"); + + const response = await fetch( + `http://localhost:${serverPort}/devhook/${devhookId}/webhook`, + { + method: "POST", + headers: { + "content-type": "application/x-www-form-urlencoded", + }, + body: formData.toString(), + } + ); + + expect(response.status).toBe(200); + expect(receivedContentType).toBe("application/x-www-form-urlencoded"); + expect(receivedBody).toContain("payload"); + expect(receivedBody).toContain("token"); + }); }); describe("server callbacks", () => { From 0f7d69765c568e774921a8a8036e1fa88a65b8fb Mon Sep 17 00:00:00 2001 From: "blink-so[bot]" <211532188+blink-so[bot]@users.noreply.github.com> Date: Mon, 15 Dec 2025 14:04:11 +0000 Subject: [PATCH 05/14] test: add comprehensive webhook functionality tests Added 10 new webhook-specific tests: - Concurrent webhooks to the same client (10 simultaneous) - Concurrent webhooks to different clients (3 clients, 5 webhooks each) - Body integrity preservation with signature verification (20 concurrent) - Varying payload sizes concurrently (100B to 50KB) - Rapid sequential webhooks (50 in sequence) - Slow processing without blocking others - GitHub-style webhook with all headers - Stripe-style webhook with timestamp signature - Webhook retry scenarios - Binary webhook payloads Total test count: 39 tests, all passing. --- packages/devhook/src/devhook.test.ts | 657 +++++++++++++++++++++++++++ 1 file changed, 657 insertions(+) diff --git a/packages/devhook/src/devhook.test.ts b/packages/devhook/src/devhook.test.ts index b49c9cc..dbc83ea 100644 --- a/packages/devhook/src/devhook.test.ts +++ b/packages/devhook/src/devhook.test.ts @@ -679,6 +679,663 @@ describe("devhook", () => { }); }); + describe("webhook functionality", () => { + let server: ReturnType; + let serverPort: number; + let clientConnections: Array<{ dispose: () => void }> = []; + + beforeAll(async () => { + serverPort = 22080 + Math.floor(Math.random() * 1000); + server = createLocalServer({ + port: serverPort, + secret: SERVER_SECRET, + baseUrl: `http://localhost:${serverPort}`, + mode: "subpath", + }); + await new Promise((resolve) => setTimeout(resolve, 100)); + }); + + afterAll(() => { + server?.close(); + }); + + afterEach(() => { + for (const conn of clientConnections) { + conn.dispose(); + } + clientConnections = []; + }); + + // Helper to create HMAC-SHA256 signature + async function createSignature(payload: string, secret: string): Promise { + const encoder = new TextEncoder(); + const key = await crypto.subtle.importKey( + "raw", + encoder.encode(secret), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"] + ); + const sig = await crypto.subtle.sign("HMAC", key, encoder.encode(payload)); + return "sha256=" + Array.from(new Uint8Array(sig)) + .map(b => b.toString(16).padStart(2, "0")) + .join(""); + } + + // Helper to verify HMAC-SHA256 signature + async function verifySignature(payload: string, signature: string, secret: string): Promise { + const expected = await createSignature(payload, secret); + return signature === expected; + } + + it("should handle concurrent webhooks to the same client", async () => { + const receivedWebhooks: Array<{ id: string; body: string; timestamp: number }> = []; + const webhookSecret = "concurrent-test-secret"; + + const client = new DevhookClient({ + serverUrl: `http://localhost:${serverPort}`, + secret: "concurrent-webhook-client", + onRequest: async (req) => { + const body = await req.text(); + const id = req.headers.get("x-webhook-id") || "unknown"; + + // Simulate some processing time + await new Promise((resolve) => setTimeout(resolve, 50 + Math.random() * 50)); + + receivedWebhooks.push({ + id, + body, + timestamp: Date.now(), + }); + + return new Response(JSON.stringify({ received: id }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }, + }); + + const disposable = client.connect(); + clientConnections.push(disposable); + await new Promise((resolve) => setTimeout(resolve, 200)); + + const devhookId = await generateDevhookId("concurrent-webhook-client", SERVER_SECRET); + + // Send 10 webhooks concurrently + const webhookCount = 10; + const promises = Array.from({ length: webhookCount }, async (_, i) => { + const payload = JSON.stringify({ event: "test", index: i }); + const response = await fetch( + `http://localhost:${serverPort}/devhook/${devhookId}/webhook`, + { + method: "POST", + headers: { + "content-type": "application/json", + "x-webhook-id": `webhook-${i}`, + }, + body: payload, + } + ); + return { index: i, status: response.status, body: await response.json() }; + }); + + const results = await Promise.all(promises); + + // All requests should succeed + expect(results.every(r => r.status === 200)).toBe(true); + + // All webhooks should be received + expect(receivedWebhooks.length).toBe(webhookCount); + + // Each webhook should have unique ID + const receivedIds = new Set(receivedWebhooks.map(w => w.id)); + expect(receivedIds.size).toBe(webhookCount); + }); + + it("should handle concurrent webhooks to different clients", async () => { + const clientResults: Map = new Map(); + + // Create 3 clients with different secrets + const clientSecrets = ["client-a", "client-b", "client-c"]; + + for (const secret of clientSecrets) { + clientResults.set(secret, []); + + const client = new DevhookClient({ + serverUrl: `http://localhost:${serverPort}`, + secret, + onRequest: async (req) => { + const body = await req.json() as { clientId: string; webhookId: string }; + clientResults.get(secret)!.push(body.webhookId); + + // Simulate processing + await new Promise((resolve) => setTimeout(resolve, 30)); + + return new Response(JSON.stringify({ client: secret, received: body.webhookId }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }, + }); + + const disposable = client.connect(); + clientConnections.push(disposable); + } + + await new Promise((resolve) => setTimeout(resolve, 300)); + + // Send webhooks to all clients concurrently + const promises: Promise<{ client: string; webhookId: string; status: number }>[] = []; + + for (const secret of clientSecrets) { + const devhookId = await generateDevhookId(secret, SERVER_SECRET); + + // Send 5 webhooks to each client + for (let i = 0; i < 5; i++) { + const webhookId = `${secret}-webhook-${i}`; + promises.push( + fetch( + `http://localhost:${serverPort}/devhook/${devhookId}/webhook`, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ clientId: secret, webhookId }), + } + ).then(async (response) => ({ + client: secret, + webhookId, + status: response.status, + })) + ); + } + } + + const results = await Promise.all(promises); + + // All requests should succeed + expect(results.every(r => r.status === 200)).toBe(true); + + // Each client should receive exactly 5 webhooks + for (const secret of clientSecrets) { + const received = clientResults.get(secret)!; + expect(received.length).toBe(5); + + // Verify the webhooks belong to this client + expect(received.every(id => id.startsWith(secret))).toBe(true); + } + }); + + it("should preserve request body integrity for signature verification with concurrent requests", async () => { + const webhookSecret = "integrity-test-secret"; + const verificationResults: Array<{ id: string; valid: boolean }> = []; + + const client = new DevhookClient({ + serverUrl: `http://localhost:${serverPort}`, + secret: "integrity-client", + onRequest: async (req) => { + const rawBody = await req.text(); + const signature = req.headers.get("x-signature"); + const webhookId = req.headers.get("x-webhook-id") || "unknown"; + + const isValid = signature ? await verifySignature(rawBody, signature, webhookSecret) : false; + + verificationResults.push({ id: webhookId, valid: isValid }); + + return new Response(JSON.stringify({ verified: isValid }), { + status: isValid ? 200 : 401, + headers: { "content-type": "application/json" }, + }); + }, + }); + + const disposable = client.connect(); + clientConnections.push(disposable); + await new Promise((resolve) => setTimeout(resolve, 200)); + + const devhookId = await generateDevhookId("integrity-client", SERVER_SECRET); + + // Send 20 webhooks concurrently with different payloads + const webhookCount = 20; + const promises = Array.from({ length: webhookCount }, async (_, i) => { + const payload = JSON.stringify({ + event: "test", + index: i, + data: `payload-data-${i}-${Math.random()}`, + timestamp: Date.now(), + }); + const signature = await createSignature(payload, webhookSecret); + + const response = await fetch( + `http://localhost:${serverPort}/devhook/${devhookId}/webhook`, + { + method: "POST", + headers: { + "content-type": "application/json", + "x-signature": signature, + "x-webhook-id": `webhook-${i}`, + }, + body: payload, + } + ); + return { index: i, status: response.status }; + }); + + const results = await Promise.all(promises); + + // All requests should succeed with valid signatures + expect(results.every(r => r.status === 200)).toBe(true); + expect(verificationResults.length).toBe(webhookCount); + expect(verificationResults.every(r => r.valid)).toBe(true); + }); + + it("should handle webhooks with varying payload sizes concurrently", async () => { + const receivedSizes: number[] = []; + + const client = new DevhookClient({ + serverUrl: `http://localhost:${serverPort}`, + secret: "varying-size-client", + onRequest: async (req) => { + const body = await req.text(); + receivedSizes.push(body.length); + return new Response(JSON.stringify({ size: body.length }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }, + }); + + const disposable = client.connect(); + clientConnections.push(disposable); + await new Promise((resolve) => setTimeout(resolve, 200)); + + const devhookId = await generateDevhookId("varying-size-client", SERVER_SECRET); + + // Send webhooks with varying sizes: 100B, 1KB, 10KB, 50KB + const sizes = [100, 1000, 10000, 50000]; + const promises = sizes.flatMap((size, sizeIndex) => + // Send 3 webhooks of each size + Array.from({ length: 3 }, async (_, i) => { + const payload = JSON.stringify({ + index: sizeIndex * 3 + i, + data: "x".repeat(size), + }); + + const response = await fetch( + `http://localhost:${serverPort}/devhook/${devhookId}/webhook`, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: payload, + } + ); + const result = await response.json() as { size: number }; + return { expectedMinSize: size, actualSize: result.size, status: response.status }; + }) + ); + + const results = await Promise.all(promises); + + // All requests should succeed + expect(results.every(r => r.status === 200)).toBe(true); + + // All sizes should be received correctly (payload includes JSON overhead) + expect(results.every(r => r.actualSize >= r.expectedMinSize)).toBe(true); + expect(receivedSizes.length).toBe(12); // 4 sizes * 3 requests each + }); + + it("should handle rapid sequential webhooks", async () => { + const receivedOrder: number[] = []; + + const client = new DevhookClient({ + serverUrl: `http://localhost:${serverPort}`, + secret: "sequential-client", + onRequest: async (req) => { + const body = await req.json() as { index: number }; + receivedOrder.push(body.index); + return new Response(JSON.stringify({ received: body.index }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }, + }); + + const disposable = client.connect(); + clientConnections.push(disposable); + await new Promise((resolve) => setTimeout(resolve, 200)); + + const devhookId = await generateDevhookId("sequential-client", SERVER_SECRET); + + // Send 50 webhooks as fast as possible (but sequentially) + const webhookCount = 50; + for (let i = 0; i < webhookCount; i++) { + const response = await fetch( + `http://localhost:${serverPort}/devhook/${devhookId}/webhook`, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ index: i }), + } + ); + expect(response.status).toBe(200); + } + + // All webhooks should be received in order + expect(receivedOrder.length).toBe(webhookCount); + expect(receivedOrder).toEqual(Array.from({ length: webhookCount }, (_, i) => i)); + }); + + it("should handle webhook with slow processing without blocking others", async () => { + const processingTimes: Array<{ id: string; duration: number }> = []; + + const client = new DevhookClient({ + serverUrl: `http://localhost:${serverPort}`, + secret: "slow-processing-client", + onRequest: async (req) => { + const start = Date.now(); + const body = await req.json() as { id: string; delay: number }; + + // Simulate variable processing time + await new Promise((resolve) => setTimeout(resolve, body.delay)); + + processingTimes.push({ id: body.id, duration: Date.now() - start }); + + return new Response(JSON.stringify({ processed: body.id }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }, + }); + + const disposable = client.connect(); + clientConnections.push(disposable); + await new Promise((resolve) => setTimeout(resolve, 200)); + + const devhookId = await generateDevhookId("slow-processing-client", SERVER_SECRET); + + const startTime = Date.now(); + + // Send webhooks with different delays concurrently + // One slow (500ms), several fast (10ms) + const promises = [ + fetch(`http://localhost:${serverPort}/devhook/${devhookId}/webhook`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ id: "slow", delay: 500 }), + }), + ...Array.from({ length: 5 }, (_, i) => + fetch(`http://localhost:${serverPort}/devhook/${devhookId}/webhook`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ id: `fast-${i}`, delay: 10 }), + }) + ), + ]; + + const results = await Promise.all(promises); + const totalTime = Date.now() - startTime; + + // All requests should succeed + expect(results.every(r => r.status === 200)).toBe(true); + + // Fast requests should not be blocked by the slow one + // Total time should be closer to 500ms (slow request) than 500 + 5*10 = 550ms + // Allow some overhead but it should complete in reasonable time + expect(totalTime).toBeLessThan(1000); + + // All webhooks should be processed + expect(processingTimes.length).toBe(6); + }); + + it("should handle GitHub-style webhook with all headers", async () => { + let receivedHeaders: Record = {}; + let receivedBody: string | undefined; + const webhookSecret = "github-webhook-secret"; + + const client = new DevhookClient({ + serverUrl: `http://localhost:${serverPort}`, + secret: "github-style-client", + onRequest: async (req) => { + receivedBody = await req.text(); + receivedHeaders = { + "x-github-event": req.headers.get("x-github-event"), + "x-github-delivery": req.headers.get("x-github-delivery"), + "x-hub-signature-256": req.headers.get("x-hub-signature-256"), + "content-type": req.headers.get("content-type"), + "user-agent": req.headers.get("user-agent"), + }; + + // Verify signature + const signature = req.headers.get("x-hub-signature-256"); + if (signature && receivedBody) { + const isValid = await verifySignature(receivedBody, signature, webhookSecret); + if (!isValid) { + return new Response("Invalid signature", { status: 401 }); + } + } + + return new Response(JSON.stringify({ success: true }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }, + }); + + const disposable = client.connect(); + clientConnections.push(disposable); + await new Promise((resolve) => setTimeout(resolve, 200)); + + const devhookId = await generateDevhookId("github-style-client", SERVER_SECRET); + + const payload = JSON.stringify({ + action: "opened", + pull_request: { + number: 42, + title: "Test PR", + }, + repository: { + full_name: "owner/repo", + }, + }); + + const signature = await createSignature(payload, webhookSecret); + const deliveryId = crypto.randomUUID(); + + const response = await fetch( + `http://localhost:${serverPort}/devhook/${devhookId}/webhook/github`, + { + method: "POST", + headers: { + "content-type": "application/json", + "x-github-event": "pull_request", + "x-github-delivery": deliveryId, + "x-hub-signature-256": signature, + "user-agent": "GitHub-Hookshot/test", + }, + body: payload, + } + ); + + expect(response.status).toBe(200); + expect(receivedHeaders["x-github-event"]).toBe("pull_request"); + expect(receivedHeaders["x-github-delivery"]).toBe(deliveryId); + expect(receivedHeaders["x-hub-signature-256"]).toBe(signature); + expect(receivedHeaders["content-type"]).toBe("application/json"); + expect(receivedHeaders["user-agent"]).toBe("GitHub-Hookshot/test"); + expect(receivedBody).toBe(payload); + }); + + it("should handle Stripe-style webhook", async () => { + const stripeSecret = "whsec_test_secret"; + let receivedEvent: unknown; + let signatureValid = false; + + const client = new DevhookClient({ + serverUrl: `http://localhost:${serverPort}`, + secret: "stripe-style-client", + onRequest: async (req) => { + const rawBody = await req.text(); + const signature = req.headers.get("stripe-signature"); + + // Stripe uses a different signature format: t=timestamp,v1=signature + if (signature) { + const parts = signature.split(","); + const timestamp = parts.find(p => p.startsWith("t="))?.slice(2); + const v1Sig = parts.find(p => p.startsWith("v1="))?.slice(3); + + if (timestamp && v1Sig) { + const signedPayload = `${timestamp}.${rawBody}`; + const expectedSig = await createSignature(signedPayload, stripeSecret); + // Remove the "sha256=" prefix for comparison + signatureValid = v1Sig === expectedSig.slice(7); + } + } + + if (!signatureValid) { + return new Response("Invalid signature", { status: 400 }); + } + + receivedEvent = JSON.parse(rawBody); + return new Response(JSON.stringify({ received: true }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }, + }); + + const disposable = client.connect(); + clientConnections.push(disposable); + await new Promise((resolve) => setTimeout(resolve, 200)); + + const devhookId = await generateDevhookId("stripe-style-client", SERVER_SECRET); + + const payload = JSON.stringify({ + id: "evt_test_123", + type: "payment_intent.succeeded", + data: { + object: { + id: "pi_test_123", + amount: 2000, + currency: "usd", + }, + }, + }); + + const timestamp = Math.floor(Date.now() / 1000).toString(); + const signedPayload = `${timestamp}.${payload}`; + const sig = await createSignature(signedPayload, stripeSecret); + const stripeSignature = `t=${timestamp},v1=${sig.slice(7)}`; // Remove "sha256=" prefix + + const response = await fetch( + `http://localhost:${serverPort}/devhook/${devhookId}/webhook/stripe`, + { + method: "POST", + headers: { + "content-type": "application/json", + "stripe-signature": stripeSignature, + }, + body: payload, + } + ); + + expect(response.status).toBe(200); + expect(signatureValid).toBe(true); + expect(receivedEvent).toEqual(JSON.parse(payload)); + }); + + it("should handle webhook retry scenarios", async () => { + let attemptCount = 0; + const maxAttempts = 3; + + const client = new DevhookClient({ + serverUrl: `http://localhost:${serverPort}`, + secret: "retry-client", + onRequest: async (req) => { + attemptCount++; + const body = await req.json() as { attempt: number }; + + // Fail first 2 attempts, succeed on 3rd + if (attemptCount < maxAttempts) { + return new Response("Service unavailable", { status: 503 }); + } + + return new Response(JSON.stringify({ success: true, attempts: attemptCount }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }, + }); + + const disposable = client.connect(); + clientConnections.push(disposable); + await new Promise((resolve) => setTimeout(resolve, 200)); + + const devhookId = await generateDevhookId("retry-client", SERVER_SECRET); + + // Simulate webhook retries + let lastResponse: Response | undefined; + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + lastResponse = await fetch( + `http://localhost:${serverPort}/devhook/${devhookId}/webhook`, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ attempt }), + } + ); + + if (lastResponse.status === 200) { + break; + } + + // Wait before retry (simulating webhook provider behavior) + await new Promise((resolve) => setTimeout(resolve, 50)); + } + + expect(lastResponse?.status).toBe(200); + expect(attemptCount).toBe(maxAttempts); + }); + + it("should handle binary webhook payloads", async () => { + let receivedBytes: Uint8Array | undefined; + + const client = new DevhookClient({ + serverUrl: `http://localhost:${serverPort}`, + secret: "binary-client", + onRequest: async (req) => { + const buffer = await req.arrayBuffer(); + receivedBytes = new Uint8Array(buffer); + return new Response(JSON.stringify({ size: receivedBytes.length }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }, + }); + + const disposable = client.connect(); + clientConnections.push(disposable); + await new Promise((resolve) => setTimeout(resolve, 200)); + + const devhookId = await generateDevhookId("binary-client", SERVER_SECRET); + + // Create binary payload + const binaryData = new Uint8Array([0x00, 0x01, 0x02, 0xFF, 0xFE, 0xFD, 0x00, 0x00]); + + const response = await fetch( + `http://localhost:${serverPort}/devhook/${devhookId}/webhook`, + { + method: "POST", + headers: { "content-type": "application/octet-stream" }, + body: binaryData, + } + ); + + expect(response.status).toBe(200); + expect(receivedBytes).toBeDefined(); + expect(receivedBytes!.length).toBe(binaryData.length); + expect(Array.from(receivedBytes!)).toEqual(Array.from(binaryData)); + }); + }); + describe("server callbacks", () => { it("should call onClientConnect and onClientDisconnect", async () => { const connectedIds: string[] = []; From b5a5640991b57d5350b28c60633b64dae5a2724b Mon Sep 17 00:00:00 2001 From: "blink-so[bot]" <211532188+blink-so[bot]@users.noreply.github.com> Date: Mon, 15 Dec 2025 14:41:39 +0000 Subject: [PATCH 06/14] feat(devhook): add WebSocket proxying support for local server - Implement WebSocket upgrade handling in local server for proxied connections - Add transformUrl option to DevhookClient for WebSocket URL transformation - Add try/catch guards for stream writes on WebSocket close/error - Fix close code validation to handle invalid codes gracefully - Add comprehensive WebSocket proxy tests: - Basic bidirectional messaging - Close propagation (external -> local and local -> external) - Binary message support - Multiple concurrent connections to same client - Multiple devhook clients simultaneously - Rapid message exchange across connections - Connection isolation (closing one doesnt affect others) - Cleanup on client disconnect - Update README with transformUrl documentation --- bun.lock | 273 ++++++++- packages/devhook/README.md | 12 +- packages/devhook/src/client/index.ts | 91 ++- packages/devhook/src/devhook.test.ts | 790 +++++++++++++++++++++++++++ packages/devhook/src/server/local.ts | 232 ++++++-- 5 files changed, 1307 insertions(+), 91 deletions(-) diff --git a/bun.lock b/bun.lock index 51f8e99..ed04fa8 100644 --- a/bun.lock +++ b/bun.lock @@ -1,6 +1,5 @@ { "lockfileVersion": 1, - "configVersion": 0, "workspaces": { "": { "name": "blink-repo", @@ -210,6 +209,22 @@ "tailwindcss-animate": "^1.0.7", }, }, + "packages/devhook": { + "name": "@blink-sdk/devhook", + "version": "0.0.1", + "dependencies": { + "@blink-sdk/multiplexer": "workspace:*", + "ws": "^8.18.0", + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20250109.0", + "@types/ws": "^8.5.10", + "hono": "^4.7.10", + "tsdown": "^0.11.12", + "vitest": "^3.2.4", + "wrangler": "^4.22.0", + }, + }, "packages/events": { "name": "@blink-sdk/events", }, @@ -550,6 +565,8 @@ "@blink-sdk/compute-protocol": ["@blink-sdk/compute-protocol@workspace:packages/compute-protocol"], + "@blink-sdk/devhook": ["@blink-sdk/devhook@workspace:packages/devhook"], + "@blink-sdk/events": ["@blink-sdk/events@workspace:packages/events"], "@blink-sdk/github": ["@blink-sdk/github@workspace:packages/github"], @@ -594,10 +611,26 @@ "@clack/prompts": ["@clack/prompts@0.11.0", "", { "dependencies": { "@clack/core": "0.5.0", "picocolors": "^1.0.0", "sisteransi": "^1.0.5" } }, "sha512-pMN5FcrEw9hUkZA4f+zLlzivQSeQf5dRGJjSUbvVYDLvpKCdQx5OaknvKzgbtXOizhP+SJJJjqEbOe55uKKfAw=="], + "@cloudflare/kv-asset-handler": ["@cloudflare/kv-asset-handler@0.4.1", "", { "dependencies": { "mime": "^3.0.0" } }, "sha512-Nu8ahitGFFJztxUml9oD/DLb7Z28C8cd8F46IVQ7y5Btz575pvMY8AqZsXkX7Gds29eCKdMgIHjIvzskHgPSFg=="], + + "@cloudflare/unenv-preset": ["@cloudflare/unenv-preset@2.7.13", "", { "peerDependencies": { "unenv": "2.0.0-rc.24", "workerd": "^1.20251202.0" }, "optionalPeers": ["workerd"] }, "sha512-NulO1H8R/DzsJguLC0ndMuk4Ufv0KSlN+E54ay9rn9ZCQo0kpAPwwh3LhgpZ96a3Dr6L9LqW57M4CqC34iLOvw=="], + + "@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20251210.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-Nn9X1moUDERA9xtFdCQ2XpQXgAS9pOjiCxvOT8sVx9UJLAiBLkfSCGbpsYdarODGybXCpjRlc77Yppuolvt7oQ=="], + + "@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20251210.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Mg8iYIZQFnbevq/ls9eW/eneWTk/EE13Pej1MwfkY5et0jVpdHnvOLywy/o+QtMJFef1AjsqXGULwAneYyBfHw=="], + + "@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20251210.0", "", { "os": "linux", "cpu": "x64" }, "sha512-kjC2fCZhZ2Gkm1biwk2qByAYpGguK5Gf5ic8owzSCUw0FOUfQxTZUT9Lp3gApxsfTLbbnLBrX/xzWjywH9QR4g=="], + + "@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20251210.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-2IB37nXi7PZVQLa1OCuO7/6pNxqisRSO8DmCQ5x/3sezI5op1vwOxAcb1osAnuVsVN9bbvpw70HJvhKruFJTuA=="], + + "@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20251210.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Uaz6/9XE+D6E7pCY4OvkCuJHu7HcSDzeGcCGY1HLhojXhHd7yL52c3yfiyJdS8hPatiAa0nn5qSI/42+aTdDSw=="], + "@cloudflare/workers-types": ["@cloudflare/workers-types@4.20251211.0", "", {}, "sha512-e87o6KbelCz7dnI5ngrGT2ca15vJZ+COb2eqJ52iDHmOaujyC6aYZ71e2vor8X6V9T6tcDElC5sAqPR93j09EA=="], "@colors/colors": ["@colors/colors@1.5.0", "", {}, "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ=="], + "@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="], + "@daytonaio/api-client": ["@daytonaio/api-client@0.117.0", "", { "dependencies": { "axios": "^1.6.1" } }, "sha512-xXowLiOH6KXiQ83gQAlXCaNB6MV3FA8e3dYx4aUerxiWnSI2WO638LLvHrw6i4MsAfweRgIEdOtEuWEpZMJVBw=="], "@daytonaio/sdk": ["@daytonaio/sdk@0.117.0", "", { "dependencies": { "@aws-sdk/client-s3": "^3.787.0", "@aws-sdk/lib-storage": "^3.798.0", "@daytonaio/api-client": "0.117.0", "@daytonaio/toolbox-api-client": "0.117.0", "@iarna/toml": "^2.2.5", "axios": "^1.11.0", "busboy": "^1.0.0", "dotenv": "^17.0.1", "expand-tilde": "^2.0.2", "fast-glob": "^3.3.0", "form-data": "^4.0.4", "isomorphic-ws": "^5.0.0", "pathe": "^2.0.3", "shell-quote": "^1.8.2", "tar": "^6.2.0" } }, "sha512-Ub9ttABhDJRuz0j3irHCh6kwTfZ0hpDsbl5dBf8l37bZz2tYMhyUlacjBmKOnhoP+/a8NiXdTh2euI5i/U+RHw=="], @@ -1170,6 +1203,12 @@ "@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="], + "@poppinss/colors": ["@poppinss/colors@4.1.6", "", { "dependencies": { "kleur": "^4.1.5" } }, "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg=="], + + "@poppinss/dumper": ["@poppinss/dumper@0.6.5", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@sindresorhus/is": "^7.0.2", "supports-color": "^10.0.0" } }, "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw=="], + + "@poppinss/exception": ["@poppinss/exception@1.2.3", "", {}, "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw=="], + "@posthog/core": ["@posthog/core@1.7.1", "", { "dependencies": { "cross-spawn": "^7.0.6" } }, "sha512-kjK0eFMIpKo9GXIbts8VtAknsoZ18oZorANdtuTj1CbgS28t4ZVq//HAWhnxEuXRTrtkd+SUJ6Ux3j2Af8NCuA=="], "@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="], @@ -1470,6 +1509,8 @@ "@smithy/uuid": ["@smithy/uuid@1.1.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-4aUIteuyxtBUhVdiQqcDhKFitwfd9hqoSDYY2KRXiWtgoWJ9Bmise+KfEPDiVHWeJepvF8xJO9/9+WDIciMFFw=="], + "@speed-highlight/core": ["@speed-highlight/core@1.2.12", "", {}, "sha512-uilwrK0Ygyri5dToHYdZSjcvpS2ZwX0w5aSt3GCEN9hrjxWCoeV4Z2DTXuxjwbntaLQIEEAlCeNQss5SoHvAEA=="], + "@standard-schema/spec": ["@standard-schema/spec@1.0.0", "", {}, "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA=="], "@standard-schema/utils": ["@standard-schema/utils@0.3.0", "", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="], @@ -1766,6 +1807,10 @@ "@vitest/pretty-format": ["@vitest/pretty-format@3.2.4", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA=="], + "@vitest/runner": ["@vitest/runner@3.2.4", "", { "dependencies": { "@vitest/utils": "3.2.4", "pathe": "^2.0.3", "strip-literal": "^3.0.0" } }, "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ=="], + + "@vitest/snapshot": ["@vitest/snapshot@3.2.4", "", { "dependencies": { "@vitest/pretty-format": "3.2.4", "magic-string": "^0.30.17", "pathe": "^2.0.3" } }, "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ=="], + "@vitest/spy": ["@vitest/spy@3.2.4", "", { "dependencies": { "tinyspy": "^4.0.3" } }, "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw=="], "@vitest/utils": ["@vitest/utils@3.2.4", "", { "dependencies": { "@vitest/pretty-format": "3.2.4", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" } }, "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA=="], @@ -1794,11 +1839,11 @@ "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], - "acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="], + "acorn": ["acorn@8.14.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA=="], "acorn-import-attributes": ["acorn-import-attributes@1.9.5", "", { "peerDependencies": { "acorn": "^8" } }, "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ=="], - "acorn-walk": ["acorn-walk@8.3.4", "", { "dependencies": { "acorn": "^8.11.0" } }, "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g=="], + "acorn-walk": ["acorn-walk@8.3.2", "", {}, "sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A=="], "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], @@ -1894,6 +1939,8 @@ "bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="], + "blake3-wasm": ["blake3-wasm@2.1.5", "", {}, "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g=="], + "blink": ["blink@workspace:packages/blink"], "bn.js": ["bn.js@5.2.2", "", {}, "sha512-v2YAxEmKaBLahNwE1mjp4WON6huMNeuDvagFZW+ASCuA/ku0bXR9hSMw0XpiqMoA3+rmnyck/tPRSFQkoC9Cuw=="], @@ -2040,10 +2087,14 @@ "code-excerpt": ["code-excerpt@4.0.0", "", { "dependencies": { "convert-to-spaces": "^2.0.1" } }, "sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA=="], + "color": ["color@4.2.3", "", { "dependencies": { "color-convert": "^2.0.1", "color-string": "^1.9.0" } }, "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A=="], + "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + "color-string": ["color-string@1.9.1", "", { "dependencies": { "color-name": "^1.0.0", "simple-swizzle": "^0.2.2" } }, "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg=="], + "combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="], "comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="], @@ -2344,6 +2395,8 @@ "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + "es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="], + "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], "es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="], @@ -2392,8 +2445,12 @@ "execa": ["execa@9.6.0", "", { "dependencies": { "@sindresorhus/merge-streams": "^4.0.0", "cross-spawn": "^7.0.6", "figures": "^6.1.0", "get-stream": "^9.0.0", "human-signals": "^8.0.1", "is-plain-obj": "^4.1.0", "is-stream": "^4.0.1", "npm-run-path": "^6.0.0", "pretty-ms": "^9.2.0", "signal-exit": "^4.1.0", "strip-final-newline": "^4.0.0", "yoctocolors": "^2.1.1" } }, "sha512-jpWzZ1ZhwUmeWRhS7Qv3mhpOhLfwI+uAX4e5fOcXqwMR7EcJ0pj2kV1CVzHVMX/LphnKWD3LObjZCoJ71lKpHw=="], + "exit-hook": ["exit-hook@2.2.1", "", {}, "sha512-eNTPlAD67BmP31LDINZ3U7HSF8l57TxOY2PmBJ1shpCvpnxBF93mWCE8YHBnXs8qiUZJc9WDcWIeC3a2HIAMfw=="], + "expand-tilde": ["expand-tilde@2.0.2", "", { "dependencies": { "homedir-polyfill": "^1.0.1" } }, "sha512-A5EmesHW6rfnZ9ysHQjPdJRni0SRar0tjtG5MNtm9n5TUvsYU8oozprtRD4AqHxcZWWlVuAmQo2nWKfN9oyjTw=="], + "expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="], + "exponential-backoff": ["exponential-backoff@3.1.2", "", {}, "sha512-8QxYTVXUkuy7fIIoitQkPwGonB8F3Zj8eEO8Sqg9Zv/bkI7RJAzowee4gr81Hak/dUTpA2Z7VfQgoijjPNlUZA=="], "express": ["express@5.1.0", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.0", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA=="], @@ -2522,6 +2579,8 @@ "glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], + "glob-to-regexp": ["glob-to-regexp@0.4.1", "", {}, "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw=="], + "global-agent": ["global-agent@3.0.0", "", { "dependencies": { "boolean": "^3.0.1", "es6-error": "^4.1.1", "matcher": "^3.0.0", "roarr": "^2.15.3", "semver": "^7.3.2", "serialize-error": "^7.0.1" } }, "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q=="], "global-directory": ["global-directory@4.0.1", "", { "dependencies": { "ini": "4.1.1" } }, "sha512-wHTUcDUoZ1H5/0iVqEudYW4/kAlN5cZ3j/bXn0Dpbizl9iaUVeWSHqiOjsgk6OW2bkLclbBjzewBz6weQ1zA2Q=="], @@ -2694,6 +2753,8 @@ "is-arguments": ["is-arguments@1.2.0", "", { "dependencies": { "call-bound": "^1.0.2", "has-tostringtag": "^1.0.2" } }, "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA=="], + "is-arrayish": ["is-arrayish@0.3.4", "", {}, "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA=="], + "is-buffer": ["is-buffer@1.1.6", "", {}, "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w=="], "is-callable": ["is-callable@1.2.7", "", {}, "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA=="], @@ -2820,6 +2881,8 @@ "kind-of": ["kind-of@3.2.2", "", { "dependencies": { "is-buffer": "^1.1.5" } }, "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ=="], + "kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], + "ky": ["ky@1.11.0", "", {}, "sha512-NEyo0ICpS0cqSuyoJFMCnHOZJILqXsKhIZlHJGDYaH8OB5IFrGzuBpEwyoMZG6gUKMPrazH30Ax5XKaujvD8ag=="], "langium": ["langium@3.3.1", "", { "dependencies": { "chevrotain": "~11.0.3", "chevrotain-allstar": "~0.3.0", "vscode-languageserver": "~9.0.1", "vscode-languageserver-textdocument": "~1.0.11", "vscode-uri": "~3.0.8" } }, "sha512-QJv/h939gDpvT+9SiLVlY7tZC3xB2qK57v0J04Sh9wpMb6MP1q8gB21L3WIo8T5P1MSMg3Ep14L7KkDCFG3y4w=="], @@ -3028,7 +3091,7 @@ "miller-rabin": ["miller-rabin@4.0.1", "", { "dependencies": { "bn.js": "^4.0.0", "brorand": "^1.0.1" }, "bin": { "miller-rabin": "bin/miller-rabin" } }, "sha512-115fLhvZVqWwHPbClyntxEVfVDfl9DLLTuJvq3g2O/Oxi8AiNouAHvDSzHS0viUJc+V5vm3eq91Xwqn9dp4jRA=="], - "mime": ["mime@1.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="], + "mime": ["mime@3.0.0", "", { "bin": { "mime": "cli.js" } }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="], "mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], @@ -3042,6 +3105,8 @@ "mini-svg-data-uri": ["mini-svg-data-uri@1.4.4", "", { "bin": { "mini-svg-data-uri": "cli.js" } }, "sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg=="], + "miniflare": ["miniflare@4.20251210.0", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "acorn": "8.14.0", "acorn-walk": "8.3.2", "exit-hook": "2.2.1", "glob-to-regexp": "0.4.1", "sharp": "^0.33.5", "stoppable": "1.1.0", "undici": "7.14.0", "workerd": "1.20251210.0", "ws": "8.18.0", "youch": "4.1.0-beta.10", "zod": "3.22.3" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-k6kIoXwGVqlPZb0hcn+X7BmnK+8BjIIkusQPY22kCo2RaQJ/LzAjtxHQdGXerlHSnJyQivDQsL6BJHMpQfUFyw=="], + "minimalistic-assert": ["minimalistic-assert@1.0.1", "", {}, "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A=="], "minimalistic-crypto-utils": ["minimalistic-crypto-utils@1.0.1", "", {}, "sha512-JIYlbt6g8i5jKfJ3xz7rF0LXmv2TkDxBLUkiBeZ7bAx4GnnNMr8xFpGnOxn6GhTEHx3SjRrZEoU+j04prX1ktg=="], @@ -3562,10 +3627,14 @@ "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], + "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], + "signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], "simple-git": ["simple-git@3.28.0", "", { "dependencies": { "@kwsites/file-exists": "^1.1.1", "@kwsites/promise-deferred": "^1.1.1", "debug": "^4.4.0" } }, "sha512-Rs/vQRwsn1ILH1oBUy8NucJlXmnnLeLCfcvbSehkPzbv3wwoFWIdtfd6Ndo6ZPhlPsCZ60CPI4rxurnwAa+a2w=="], + "simple-swizzle": ["simple-swizzle@0.2.4", "", { "dependencies": { "is-arrayish": "^0.3.1" } }, "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw=="], + "simple-update-notifier": ["simple-update-notifier@2.0.0", "", { "dependencies": { "semver": "^7.5.3" } }, "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w=="], "sirv": ["sirv@3.0.2", "", { "dependencies": { "@polka/url": "^1.0.0-next.24", "mrmime": "^2.0.0", "totalist": "^3.0.0" } }, "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g=="], @@ -3612,10 +3681,16 @@ "stack-utils": ["stack-utils@2.0.6", "", { "dependencies": { "escape-string-regexp": "^2.0.0" } }, "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ=="], + "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], + "stat-mode": ["stat-mode@1.0.0", "", {}, "sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg=="], "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], + "std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="], + + "stoppable": ["stoppable@1.1.0", "", {}, "sha512-KXDYZ9dszj6bzvnEMRYvxgeTHU74QBFL54XKtP3nyMuJ81CFYtABZ3bAzL2EdFUaEwJOBOgENyFj3R7oTzDyyw=="], + "storybook": ["storybook@9.1.16", "", { "dependencies": { "@storybook/global": "^5.0.0", "@testing-library/jest-dom": "^6.6.3", "@testing-library/user-event": "^14.6.1", "@vitest/expect": "3.2.4", "@vitest/mocker": "3.2.4", "@vitest/spy": "3.2.4", "better-opn": "^3.0.2", "esbuild": "^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0 || ^0.25.0", "esbuild-register": "^3.5.0", "recast": "^0.23.5", "semver": "^7.6.2", "ws": "^8.18.0" }, "peerDependencies": { "prettier": "^2 || ^3" }, "optionalPeers": ["prettier"], "bin": "./bin/index.cjs" }, "sha512-339U14K6l46EFyRvaPS2ZlL7v7Pb+LlcXT8KAETrGPxq8v1sAjj2HAOB6zrlAK3M+0+ricssfAwsLCwt7Eg8TQ=="], "stream-browserify": ["stream-browserify@3.0.0", "", { "dependencies": { "inherits": "~2.0.4", "readable-stream": "^3.5.0" } }, "sha512-H73RAHsVBapbim0tU2JwwOiXUj+fikfiaoYAKHF3VJfA0pe2BCzkhAHBlLG6REzE+2WNZcxOXjK7lkso+9euLA=="], @@ -3650,6 +3725,8 @@ "strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], + "strip-literal": ["strip-literal@3.1.0", "", { "dependencies": { "js-tokens": "^9.0.1" } }, "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg=="], + "stripe": ["stripe@18.5.0", "", { "dependencies": { "qs": "^6.11.0" }, "peerDependencies": { "@types/node": ">=12.x.x" }, "optionalPeers": ["@types/node"] }, "sha512-Hp+wFiEQtCB0LlNgcFh5uVyKznpDjzyUZ+CNVEf+I3fhlYvh7rZruIg+jOwzJRCpy0ZTPMjlzm7J2/M2N6d+DA=="], "strnum": ["strnum@2.1.1", "", {}, "sha512-7ZvoFTiCnGxBtDqJ//Cu6fWtZtc7Y3x+QOirG15wztbdngGSkht27o2pyGWrVy0b4WAy3jbKmnoK6g5VlVNUUw=="], @@ -3710,6 +3787,8 @@ "tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="], + "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], + "tinycolor2": ["tinycolor2@1.6.0", "", {}, "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw=="], "tinyexec": ["tinyexec@1.0.1", "", {}, "sha512-5uC6DDlmeqiOwCPmK9jMSdOuZTh8bU39Ys6yidB+UTt5hfZUPGAypSgFRiEp+jbi9qH40BLDvy85jIU88wKSqw=="], @@ -3718,6 +3797,8 @@ "tinygradient": ["tinygradient@1.1.5", "", { "dependencies": { "@types/tinycolor2": "^1.4.0", "tinycolor2": "^1.0.0" } }, "sha512-8nIfc2vgQ4TeLnk2lFj4tRLvvJwEfQuabdsmvDdQPT0xlk9TaNtpGd6nNRxXoK6vQhN6RSzj+Cnp5tTQmpxmbw=="], + "tinypool": ["tinypool@1.1.1", "", {}, "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg=="], + "tinyrainbow": ["tinyrainbow@2.0.0", "", {}, "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw=="], "tinyspy": ["tinyspy@4.0.4", "", {}, "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q=="], @@ -3792,6 +3873,8 @@ "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + "unenv": ["unenv@2.0.0-rc.24", "", { "dependencies": { "pathe": "^2.0.3" } }, "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw=="], + "unicode-emoji-modifier-base": ["unicode-emoji-modifier-base@1.0.0", "", {}, "sha512-yLSH4py7oFH3oG/9K+XWrz1pSi3dfUrWEnInbxMfArOfc1+33BlGPQtLsOYwvdMy11AwUBetYuaRxSPqgkq+8g=="], "unicorn-magic": ["unicorn-magic@0.1.0", "", {}, "sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ=="], @@ -3878,6 +3961,8 @@ "vite-hot-client": ["vite-hot-client@2.1.0", "", { "peerDependencies": { "vite": "^2.6.0 || ^3.0.0 || ^4.0.0 || ^5.0.0-0 || ^6.0.0-0 || ^7.0.0-0" } }, "sha512-7SpgZmU7R+dDnSmvXE1mfDtnHLHQSisdySVR7lO8ceAXvM0otZeuQQ6C8LrS5d/aYyP/QZ0hI0L+dIPrm4YlFQ=="], + "vite-node": ["vite-node@3.2.4", "", { "dependencies": { "cac": "^6.7.14", "debug": "^4.4.1", "es-module-lexer": "^1.7.0", "pathe": "^2.0.3", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "bin": { "vite-node": "vite-node.mjs" } }, "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg=="], + "vite-plugin-inspect": ["vite-plugin-inspect@11.3.3", "", { "dependencies": { "ansis": "^4.1.0", "debug": "^4.4.1", "error-stack-parser-es": "^1.0.5", "ohash": "^2.0.11", "open": "^10.2.0", "perfect-debounce": "^2.0.0", "sirv": "^3.0.1", "unplugin-utils": "^0.3.0", "vite-dev-rpc": "^1.1.0" }, "peerDependencies": { "vite": "^6.0.0 || ^7.0.0-0" } }, "sha512-u2eV5La99oHoYPHE6UvbwgEqKKOQGz86wMg40CCosP6q8BkB6e5xPneZfYagK4ojPJSj5anHCrnvC20DpwVdRA=="], "vite-plugin-node-polyfills": ["vite-plugin-node-polyfills@0.24.0", "", { "dependencies": { "@rollup/plugin-inject": "^5.0.5", "node-stdlib-browser": "^1.2.0" }, "peerDependencies": { "vite": "^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-GA9QKLH+vIM8NPaGA+o2t8PDfFUl32J8rUp1zQfMKVJQiNkOX4unE51tR6ppl6iKw5yOrDAdSH7r/UIFLCVhLw=="], @@ -3886,6 +3971,8 @@ "vite-tsconfig-paths": ["vite-tsconfig-paths@5.1.4", "", { "dependencies": { "debug": "^4.1.1", "globrex": "^0.1.2", "tsconfck": "^3.0.3" }, "peerDependencies": { "vite": "*" }, "optionalPeers": ["vite"] }, "sha512-cYj0LRuLV2c2sMqhqhGpaO3LretdtMn/BVX4cPLanIZuwwrkVl+lK84E/miEXkCHWXuq65rhNN4rXsBcOB3S4w=="], + "vitest": ["vitest@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", "@vitest/mocker": "3.2.4", "@vitest/pretty-format": "^3.2.4", "@vitest/runner": "3.2.4", "@vitest/snapshot": "3.2.4", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "debug": "^4.4.1", "expect-type": "^1.2.1", "magic-string": "^0.30.17", "pathe": "^2.0.3", "picomatch": "^4.0.2", "std-env": "^3.9.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.2", "tinyglobby": "^0.2.14", "tinypool": "^1.1.1", "tinyrainbow": "^2.0.0", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", "vite-node": "3.2.4", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@types/debug": "^4.1.12", "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "@vitest/browser": "3.2.4", "@vitest/ui": "3.2.4", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@types/debug", "@types/node", "@vitest/browser", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A=="], + "vm-browserify": ["vm-browserify@1.1.2", "", {}, "sha512-2ham8XPWTONajOR0ohOKOHXkm3+gaBmGut3SRuu75xLd/RRaY6vqgh8NBYYk7+RW3u5AtzPQZG8F10LHkl0lAQ=="], "vscode-jsonrpc": ["vscode-jsonrpc@8.2.0", "", {}, "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA=="], @@ -3924,10 +4011,16 @@ "which-typed-array": ["which-typed-array@1.1.19", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "for-each": "^0.3.5", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2" } }, "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw=="], + "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], + "widest-line": ["widest-line@5.0.0", "", { "dependencies": { "string-width": "^7.0.0" } }, "sha512-c9bZp7b5YtRj2wOe6dlj32MK+Bx/M/d+9VB2SHM1OtsUHR0aV0tdP6DWh/iMt0kWi1t5g1Iudu6hQRNd1A4PVA=="], "wordwrap": ["wordwrap@1.0.0", "", {}, "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q=="], + "workerd": ["workerd@1.20251210.0", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20251210.0", "@cloudflare/workerd-darwin-arm64": "1.20251210.0", "@cloudflare/workerd-linux-64": "1.20251210.0", "@cloudflare/workerd-linux-arm64": "1.20251210.0", "@cloudflare/workerd-windows-64": "1.20251210.0" }, "bin": { "workerd": "bin/workerd" } }, "sha512-9MUUneP1BnRE9XAYi94FXxHmiLGbO75EHQZsgWqSiOXjoXSqJCw8aQbIEPxCy19TclEl/kHUFYce8ST2W+Qpjw=="], + + "wrangler": ["wrangler@4.54.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.4.1", "@cloudflare/unenv-preset": "2.7.13", "blake3-wasm": "2.1.5", "esbuild": "0.27.0", "miniflare": "4.20251210.0", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20251210.0" }, "optionalDependencies": { "fsevents": "~2.3.2" }, "peerDependencies": { "@cloudflare/workers-types": "^4.20251210.0" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js" } }, "sha512-bANFsjDwJLbprYoBK+hUDZsVbUv2SqJd8QvArLIcZk+fPq4h/Ohtj5vkKXD3k0s2bD1DXLk08D+hYmeNH+xC6A=="], + "wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="], "wrap-ansi-cjs": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], @@ -3968,6 +4061,10 @@ "yoga-layout": ["yoga-layout@3.2.1", "", {}, "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ=="], + "youch": ["youch@4.1.0-beta.10", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@poppinss/dumper": "^0.6.4", "@speed-highlight/core": "^1.2.7", "cookie": "^1.0.2", "youch-core": "^0.3.3" } }, "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ=="], + + "youch-core": ["youch-core@0.3.3", "", { "dependencies": { "@poppinss/exception": "^1.2.2", "error-stack-parser-es": "^1.0.5" } }, "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA=="], + "zod": ["zod@4.1.13", "", {}, "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig=="], "zod-to-json-schema": ["zod-to-json-schema@3.24.6", "", { "peerDependencies": { "zod": "^3.24.1" } }, "sha512-h/z3PKvcTcTetyjl1fkj79MHNEjm+HpD6NXheWjzOekY7kV+lwDYnHw+ivHkijnCSMz1yJaWBD9vu/Fcmk+vEg=="], @@ -4028,6 +4125,8 @@ "@babel/traverse/@babel/types": ["@babel/types@7.28.5", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA=="], + "@blink-sdk/devhook/tsdown": ["tsdown@0.11.13", "", { "dependencies": { "ansis": "^4.0.0", "cac": "^6.7.14", "chokidar": "^4.0.3", "debug": "^4.4.1", "diff": "^8.0.1", "empathic": "^1.1.0", "hookable": "^5.5.3", "rolldown": "1.0.0-beta.9", "rolldown-plugin-dts": "^0.13.3", "semver": "^7.7.2", "tinyexec": "^1.0.1", "tinyglobby": "^0.2.13", "unconfig": "^7.3.2" }, "peerDependencies": { "publint": "^0.3.0", "typescript": "^5.0.0", "unplugin-lightningcss": "^0.4.0", "unplugin-unused": "^0.5.0" }, "optionalPeers": ["publint", "typescript", "unplugin-lightningcss", "unplugin-unused"], "bin": { "tsdown": "dist/run.js" } }, "sha512-VSfoNm8MJXFdg7PJ4p2javgjMRiQQHpkP9N3iBBTrmCixcT6YZ9ZtqYMW3NDHczqR0C0Qnur1HMQr1ZfZcmrng=="], + "@blink-sdk/github/file-type": ["file-type@21.0.0", "", { "dependencies": { "@tokenizer/inflate": "^0.2.7", "strtok3": "^10.2.2", "token-types": "^6.0.0", "uint8array-extras": "^1.4.0" } }, "sha512-ek5xNX2YBYlXhiUXui3D/BXa3LdqPmoLJ7rqEx2bKJ7EAUEfmXgW0Das7Dc6Nr9MvqaOnIqiPV0mZk/r/UpNAg=="], "@blink-sdk/scout-agent/tsdown": ["tsdown@0.3.1", "", { "dependencies": { "cac": "^6.7.14", "chokidar": "^4.0.1", "consola": "^3.2.3", "debug": "^4.3.7", "picocolors": "^1.1.1", "pkg-types": "^1.2.1", "rolldown": "nightly", "tinyglobby": "^0.2.10", "unconfig": "^0.6.0", "unplugin-isolated-decl": "^0.7.2", "unplugin-unused": "^0.2.3" }, "bin": { "tsdown": "bin/tsdown.js" } }, "sha512-5WLFU7f2NRnsez0jxi7m2lEQNPvBOdos0W8vHvKDnS6tYTfOfmZ5D2z/G9pFTQSjeBhoi6BFRMybc4LzCOKR8A=="], @@ -4048,6 +4147,8 @@ "@blink/desktop/react-dom": ["react-dom@19.2.0", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.0" } }, "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ=="], + "@cspotcode/source-map-support/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.9", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" } }, "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ=="], + "@develar/schema-utils/ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="], "@electron/asar/commander": ["commander@5.1.0", "", {}, "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg=="], @@ -4314,6 +4415,10 @@ "@pnpm/network.ca-file/graceful-fs": ["graceful-fs@4.2.10", "", {}, "sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA=="], + "@poppinss/dumper/@sindresorhus/is": ["@sindresorhus/is@7.1.0", "", {}, "sha512-7F/yz2IphV39hiS2zB4QYVkivrptHHh0K8qJJd9HhuWSdvf8AN7NpebW3CcDZDBQsUPMoDKWsY2WWgW7bqOcfA=="], + + "@poppinss/dumper/supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="], + "@radix-ui/react-alert-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], "@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], @@ -4628,6 +4733,8 @@ "iconv-corefoundation/node-addon-api": ["node-addon-api@1.7.2", "", {}, "sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg=="], + "import-in-the-middle/acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="], + "importx/esbuild": ["esbuild@0.25.10", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.10", "@esbuild/android-arm": "0.25.10", "@esbuild/android-arm64": "0.25.10", "@esbuild/android-x64": "0.25.10", "@esbuild/darwin-arm64": "0.25.10", "@esbuild/darwin-x64": "0.25.10", "@esbuild/freebsd-arm64": "0.25.10", "@esbuild/freebsd-x64": "0.25.10", "@esbuild/linux-arm": "0.25.10", "@esbuild/linux-arm64": "0.25.10", "@esbuild/linux-ia32": "0.25.10", "@esbuild/linux-loong64": "0.25.10", "@esbuild/linux-mips64el": "0.25.10", "@esbuild/linux-ppc64": "0.25.10", "@esbuild/linux-riscv64": "0.25.10", "@esbuild/linux-s390x": "0.25.10", "@esbuild/linux-x64": "0.25.10", "@esbuild/netbsd-arm64": "0.25.10", "@esbuild/netbsd-x64": "0.25.10", "@esbuild/openbsd-arm64": "0.25.10", "@esbuild/openbsd-x64": "0.25.10", "@esbuild/openharmony-arm64": "0.25.10", "@esbuild/sunos-x64": "0.25.10", "@esbuild/win32-arm64": "0.25.10", "@esbuild/win32-ia32": "0.25.10", "@esbuild/win32-x64": "0.25.10" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-9RiGKvCwaqxO2owP61uQ4BgNborAQskMR6QusfWzQqv7AZOg5oGehdY2pRJMTKuwxd1IDBP4rSbI5lHzU7SMsQ=="], "jsonwebtoken/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="], @@ -4640,6 +4747,8 @@ "less/image-size": ["image-size@0.5.5", "", { "bin": { "image-size": "bin/image-size.js" } }, "sha512-6TDAlDPZxUFCv+fuOkIoXT/V/f3Qbq8e37p+YOiYrUv3v9cc3/6x78VdfPgFVaB9dZYeLUfKgHRebpkm/oP2VQ=="], + "less/mime": ["mime@1.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="], + "log-symbols/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "loose-envify/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], @@ -4672,6 +4781,14 @@ "miller-rabin/bn.js": ["bn.js@4.12.2", "", {}, "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw=="], + "miniflare/sharp": ["sharp@0.33.5", "", { "dependencies": { "color": "^4.2.3", "detect-libc": "^2.0.3", "semver": "^7.6.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.33.5", "@img/sharp-darwin-x64": "0.33.5", "@img/sharp-libvips-darwin-arm64": "1.0.4", "@img/sharp-libvips-darwin-x64": "1.0.4", "@img/sharp-libvips-linux-arm": "1.0.5", "@img/sharp-libvips-linux-arm64": "1.0.4", "@img/sharp-libvips-linux-s390x": "1.0.4", "@img/sharp-libvips-linux-x64": "1.0.4", "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", "@img/sharp-libvips-linuxmusl-x64": "1.0.4", "@img/sharp-linux-arm": "0.33.5", "@img/sharp-linux-arm64": "0.33.5", "@img/sharp-linux-s390x": "0.33.5", "@img/sharp-linux-x64": "0.33.5", "@img/sharp-linuxmusl-arm64": "0.33.5", "@img/sharp-linuxmusl-x64": "0.33.5", "@img/sharp-wasm32": "0.33.5", "@img/sharp-win32-ia32": "0.33.5", "@img/sharp-win32-x64": "0.33.5" } }, "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw=="], + + "miniflare/undici": ["undici@7.14.0", "", {}, "sha512-Vqs8HTzjpQXZeXdpsfChQTlafcMQaaIwnGwLam1wudSSjlJeQ3bw1j+TLPePgrCnCpUXx7Ba5Pdpf5OBih62NQ=="], + + "miniflare/ws": ["ws@8.18.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="], + + "miniflare/zod": ["zod@3.22.3", "", {}, "sha512-EjIevzuJRiRPbVH4mGc8nApb/lVLKVpmUhAaR5R5doKGfAnGJ6Gr3CViAVjP+4FWSxCsybeWQdcgCtbX+7oZug=="], + "minipass-collect/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], "minipass-fetch/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], @@ -4684,6 +4801,8 @@ "minizlib/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], + "mlly/acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="], + "needle/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], "next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="], @@ -4830,6 +4949,8 @@ "type-is/mime-types": ["mime-types@3.0.1", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA=="], + "unplugin/acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="], + "update-notifier/boxen": ["boxen@8.0.1", "", { "dependencies": { "ansi-align": "^3.0.1", "camelcase": "^8.0.0", "chalk": "^5.3.0", "cli-boxes": "^3.0.0", "string-width": "^7.2.0", "type-fest": "^4.21.0", "widest-line": "^5.0.0", "wrap-ansi": "^9.0.0" } }, "sha512-F3PH5k5juxom4xktynS7MoFY+NUWH5LC4CnH11YB8NPew+HLpmBLCybSAEyb2F+4pRXhuhWqFesoQd6DAyc2hw=="], "update-notifier/is-in-ci": ["is-in-ci@1.0.0", "", { "bin": { "is-in-ci": "cli.js" } }, "sha512-eUuAjybVTHMYWm/U+vBO1sY/JOCgoPCXRxzdju0K+K0BiGW0SChEL1MLC0PoCIR1OlPo5YAp8HuQoUlsWEICwg=="], @@ -4842,12 +4963,20 @@ "vite-plugin-storybook-nextjs/@next/env": ["@next/env@16.0.0", "", {}, "sha512-s5j2iFGp38QsG1LWRQaE2iUY3h1jc014/melHFfLdrsMJPqxqDQwWNwyQTcNoUSGZlCVZuM7t7JDMmSyRilsnA=="], + "vitest/tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="], + + "webpack-bundle-analyzer/acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="], + + "webpack-bundle-analyzer/acorn-walk": ["acorn-walk@8.3.4", "", { "dependencies": { "acorn": "^8.11.0" } }, "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g=="], + "webpack-bundle-analyzer/commander": ["commander@7.2.0", "", {}, "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw=="], "webpack-bundle-analyzer/sirv": ["sirv@2.0.4", "", { "dependencies": { "@polka/url": "^1.0.0-next.24", "mrmime": "^2.0.0", "totalist": "^3.0.0" } }, "sha512-94Bdh3cC2PKrbgSOUqTiGPWVZeSiXfKOVZNJniWoqrWrRkB1CJzBU3NEbiTsPcYy1lDsANA/THzS+9WBiy5nfQ=="], "webpack-bundle-analyzer/ws": ["ws@7.5.10", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ=="], + "wrangler/esbuild": ["esbuild@0.27.0", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.0", "@esbuild/android-arm": "0.27.0", "@esbuild/android-arm64": "0.27.0", "@esbuild/android-x64": "0.27.0", "@esbuild/darwin-arm64": "0.27.0", "@esbuild/darwin-x64": "0.27.0", "@esbuild/freebsd-arm64": "0.27.0", "@esbuild/freebsd-x64": "0.27.0", "@esbuild/linux-arm": "0.27.0", "@esbuild/linux-arm64": "0.27.0", "@esbuild/linux-ia32": "0.27.0", "@esbuild/linux-loong64": "0.27.0", "@esbuild/linux-mips64el": "0.27.0", "@esbuild/linux-ppc64": "0.27.0", "@esbuild/linux-riscv64": "0.27.0", "@esbuild/linux-s390x": "0.27.0", "@esbuild/linux-x64": "0.27.0", "@esbuild/netbsd-arm64": "0.27.0", "@esbuild/netbsd-x64": "0.27.0", "@esbuild/openbsd-arm64": "0.27.0", "@esbuild/openbsd-x64": "0.27.0", "@esbuild/openharmony-arm64": "0.27.0", "@esbuild/sunos-x64": "0.27.0", "@esbuild/win32-arm64": "0.27.0", "@esbuild/win32-ia32": "0.27.0", "@esbuild/win32-x64": "0.27.0" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-jd0f4NHbD6cALCyGElNpGAOtWxSq46l9X/sWB0Nzd5er4Kz2YTm+Vl0qKFT9KUJvD8+fiO8AvoHhFvEatfVixA=="], + "wrap-ansi-cjs/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], "wrap-ansi-cjs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], @@ -4878,6 +5007,12 @@ "@babel/traverse/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + "@blink-sdk/devhook/tsdown/empathic": ["empathic@1.1.0", "", {}, "sha512-rsPft6CK3eHtrlp9Y5ALBb+hfK+DWnA4WFebbazxjWyx8vSm3rZeoM3z9irsjcqO3PYRzlfv27XIB4tz2DV7RA=="], + + "@blink-sdk/devhook/tsdown/rolldown": ["rolldown@1.0.0-beta.9", "", { "dependencies": { "@oxc-project/types": "0.70.0", "@rolldown/pluginutils": "1.0.0-beta.9", "ansis": "^4.0.0" }, "optionalDependencies": { "@rolldown/binding-darwin-arm64": "1.0.0-beta.9", "@rolldown/binding-darwin-x64": "1.0.0-beta.9", "@rolldown/binding-freebsd-x64": "1.0.0-beta.9", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-beta.9", "@rolldown/binding-linux-arm64-gnu": "1.0.0-beta.9", "@rolldown/binding-linux-arm64-musl": "1.0.0-beta.9", "@rolldown/binding-linux-x64-gnu": "1.0.0-beta.9", "@rolldown/binding-linux-x64-musl": "1.0.0-beta.9", "@rolldown/binding-wasm32-wasi": "1.0.0-beta.9", "@rolldown/binding-win32-arm64-msvc": "1.0.0-beta.9", "@rolldown/binding-win32-ia32-msvc": "1.0.0-beta.9", "@rolldown/binding-win32-x64-msvc": "1.0.0-beta.9" }, "peerDependencies": { "@oxc-project/runtime": "0.70.0" }, "optionalPeers": ["@oxc-project/runtime"], "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-ZgZky52n6iF0UainGKjptKGrOG4Con2S5sdc4C4y2Oj25D5PHAY8Y8E5f3M2TSd/zlhQs574JlMeTe3vREczSg=="], + + "@blink-sdk/devhook/tsdown/rolldown-plugin-dts": ["rolldown-plugin-dts@0.13.14", "", { "dependencies": { "@babel/generator": "^7.28.0", "@babel/parser": "^7.28.0", "@babel/types": "^7.28.1", "ast-kit": "^2.1.1", "birpc": "^2.5.0", "debug": "^4.4.1", "dts-resolver": "^2.1.1", "get-tsconfig": "^4.10.1" }, "peerDependencies": { "@typescript/native-preview": ">=7.0.0-dev.20250601.1", "rolldown": "^1.0.0-beta.9", "typescript": "^5.0.0", "vue-tsc": "^2.2.0 || ^3.0.0" }, "optionalPeers": ["@typescript/native-preview", "typescript", "vue-tsc"] }, "sha512-wjNhHZz9dlN6PTIXyizB6u/mAg1wEFMW9yw7imEVe3CxHSRnNHVyycIX0yDEOVJfDNISLPbkCIPEpFpizy5+PQ=="], + "@blink-sdk/github/file-type/strtok3": ["strtok3@10.3.4", "", { "dependencies": { "@tokenizer/token": "^0.3.0" } }, "sha512-KIy5nylvC5le1OdaaoCJ07L+8iQzJHGH6pWDuzS+d07Cu7n1MZ2x26P8ZKIWfbK02+XIL8Mp4RkWeqdUCrDMfg=="], "@blink-sdk/scout-agent/tsdown/rolldown": ["rolldown@1.0.0-beta.13-commit.024b632", "", { "dependencies": { "@oxc-project/runtime": "=0.72.3", "@oxc-project/types": "=0.72.3", "@rolldown/pluginutils": "1.0.0-beta.13-commit.024b632", "ansis": "^4.0.0" }, "optionalDependencies": { "@rolldown/binding-darwin-arm64": "1.0.0-beta.13-commit.024b632", "@rolldown/binding-darwin-x64": "1.0.0-beta.13-commit.024b632", "@rolldown/binding-freebsd-x64": "1.0.0-beta.13-commit.024b632", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-beta.13-commit.024b632", "@rolldown/binding-linux-arm64-gnu": "1.0.0-beta.13-commit.024b632", "@rolldown/binding-linux-arm64-musl": "1.0.0-beta.13-commit.024b632", "@rolldown/binding-linux-x64-gnu": "1.0.0-beta.13-commit.024b632", "@rolldown/binding-linux-x64-musl": "1.0.0-beta.13-commit.024b632", "@rolldown/binding-wasm32-wasi": "1.0.0-beta.13-commit.024b632", "@rolldown/binding-win32-arm64-msvc": "1.0.0-beta.13-commit.024b632", "@rolldown/binding-win32-ia32-msvc": "1.0.0-beta.13-commit.024b632", "@rolldown/binding-win32-x64-msvc": "1.0.0-beta.13-commit.024b632" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-sntAHxNJ22WdcXVHQDoRst4eOJZjuT3S1aqsNWsvK2aaFVPgpVPY3WGwvJ91SvH/oTdRCyJw5PwpzbaMdKdYqQ=="], @@ -5674,6 +5809,44 @@ "mdast-util-mdx-jsx/parse-entities/is-hexadecimal": ["is-hexadecimal@2.0.1", "", {}, "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="], + "miniflare/sharp/@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.0.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ=="], + + "miniflare/sharp/@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.0.4" }, "os": "darwin", "cpu": "x64" }, "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q=="], + + "miniflare/sharp/@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.0.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg=="], + + "miniflare/sharp/@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.0.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ=="], + + "miniflare/sharp/@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.0.5", "", { "os": "linux", "cpu": "arm" }, "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g=="], + + "miniflare/sharp/@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.0.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA=="], + + "miniflare/sharp/@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.0.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA=="], + + "miniflare/sharp/@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.0.4", "", { "os": "linux", "cpu": "x64" }, "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw=="], + + "miniflare/sharp/@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.0.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA=="], + + "miniflare/sharp/@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.0.4", "", { "os": "linux", "cpu": "x64" }, "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw=="], + + "miniflare/sharp/@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.0.5" }, "os": "linux", "cpu": "arm" }, "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ=="], + + "miniflare/sharp/@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.0.4" }, "os": "linux", "cpu": "arm64" }, "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA=="], + + "miniflare/sharp/@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.0.4" }, "os": "linux", "cpu": "s390x" }, "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q=="], + + "miniflare/sharp/@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.0.4" }, "os": "linux", "cpu": "x64" }, "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA=="], + + "miniflare/sharp/@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.0.4" }, "os": "linux", "cpu": "arm64" }, "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g=="], + + "miniflare/sharp/@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.0.4" }, "os": "linux", "cpu": "x64" }, "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw=="], + + "miniflare/sharp/@img/sharp-wasm32": ["@img/sharp-wasm32@0.33.5", "", { "dependencies": { "@emnapi/runtime": "^1.2.0" }, "cpu": "none" }, "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg=="], + + "miniflare/sharp/@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.33.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ=="], + + "miniflare/sharp/@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.33.5", "", { "os": "win32", "cpu": "x64" }, "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg=="], + "node-stdlib-browser/string_decoder/safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], "normalize-package-data/hosted-git-info/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], @@ -5870,6 +6043,58 @@ "vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.10", "", { "os": "win32", "cpu": "x64" }, "sha512-9KpxSVFCu0iK1owoez6aC/s/EdUQLDN3adTxGCqxMVhrPDj6bt5dbrHDXUuq+Bs2vATFBBrQS5vdQ/Ed2P+nbw=="], + "wrangler/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.0", "", { "os": "aix", "cpu": "ppc64" }, "sha512-KuZrd2hRjz01y5JK9mEBSD3Vj3mbCvemhT466rSuJYeE/hjuBrHfjjcjMdTm/sz7au+++sdbJZJmuBwQLuw68A=="], + + "wrangler/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.27.0", "", { "os": "android", "cpu": "arm" }, "sha512-j67aezrPNYWJEOHUNLPj9maeJte7uSMM6gMoxfPC9hOg8N02JuQi/T7ewumf4tNvJadFkvLZMlAq73b9uwdMyQ=="], + + "wrangler/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.0", "", { "os": "android", "cpu": "arm64" }, "sha512-CC3vt4+1xZrs97/PKDkl0yN7w8edvU2vZvAFGD16n9F0Cvniy5qvzRXjfO1l94efczkkQE6g1x0i73Qf5uthOQ=="], + + "wrangler/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.27.0", "", { "os": "android", "cpu": "x64" }, "sha512-wurMkF1nmQajBO1+0CJmcN17U4BP6GqNSROP8t0X/Jiw2ltYGLHpEksp9MpoBqkrFR3kv2/te6Sha26k3+yZ9Q=="], + + "wrangler/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-uJOQKYCcHhg07DL7i8MzjvS2LaP7W7Pn/7uA0B5S1EnqAirJtbyw4yC5jQ5qcFjHK9l6o/MX9QisBg12kNkdHg=="], + + "wrangler/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-8mG6arH3yB/4ZXiEnXof5MK72dE6zM9cDvUcPtxhUZsDjESl9JipZYW60C3JGreKCEP+p8P/72r69m4AZGJd5g=="], + + "wrangler/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-9FHtyO988CwNMMOE3YIeci+UV+x5Zy8fI2qHNpsEtSF83YPBmE8UWmfYAQg6Ux7Gsmd4FejZqnEUZCMGaNQHQw=="], + + "wrangler/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-zCMeMXI4HS/tXvJz8vWGexpZj2YVtRAihHLk1imZj4efx1BQzN76YFeKqlDr3bUWI26wHwLWPd3rwh6pe4EV7g=="], + + "wrangler/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.0", "", { "os": "linux", "cpu": "arm" }, "sha512-t76XLQDpxgmq2cNXKTVEB7O7YMb42atj2Re2Haf45HkaUpjM2J0UuJZDuaGbPbamzZ7bawyGFUkodL+zcE+jvQ=="], + + "wrangler/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-AS18v0V+vZiLJyi/4LphvBE+OIX682Pu7ZYNsdUHyUKSoRwdnOsMf6FDekwoAFKej14WAkOef3zAORJgAtXnlQ=="], + + "wrangler/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.0", "", { "os": "linux", "cpu": "ia32" }, "sha512-Mz1jxqm/kfgKkc/KLHC5qIujMvnnarD9ra1cEcrs7qshTUSksPihGrWHVG5+osAIQ68577Zpww7SGapmzSt4Nw=="], + + "wrangler/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.0", "", { "os": "linux", "cpu": "none" }, "sha512-QbEREjdJeIreIAbdG2hLU1yXm1uu+LTdzoq1KCo4G4pFOLlvIspBm36QrQOar9LFduavoWX2msNFAAAY9j4BDg=="], + + "wrangler/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.0", "", { "os": "linux", "cpu": "none" }, "sha512-sJz3zRNe4tO2wxvDpH/HYJilb6+2YJxo/ZNbVdtFiKDufzWq4JmKAiHy9iGoLjAV7r/W32VgaHGkk35cUXlNOg=="], + + "wrangler/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-z9N10FBD0DCS2dmSABDBb5TLAyF1/ydVb+N4pi88T45efQ/w4ohr/F/QYCkxDPnkhkp6AIpIcQKQ8F0ANoA2JA=="], + + "wrangler/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.0", "", { "os": "linux", "cpu": "none" }, "sha512-pQdyAIZ0BWIC5GyvVFn5awDiO14TkT/19FTmFcPdDec94KJ1uZcmFs21Fo8auMXzD4Tt+diXu1LW1gHus9fhFQ=="], + + "wrangler/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-hPlRWR4eIDDEci953RI1BLZitgi5uqcsjKMxwYfmi4LcwyWo2IcRP+lThVnKjNtk90pLS8nKdroXYOqW+QQH+w=="], + + "wrangler/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.0", "", { "os": "linux", "cpu": "x64" }, "sha512-1hBWx4OUJE2cab++aVZ7pObD6s+DK4mPGpemtnAORBvb5l/g5xFGk0vc0PjSkrDs0XaXj9yyob3d14XqvnQ4gw=="], + + "wrangler/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.0", "", { "os": "none", "cpu": "arm64" }, "sha512-6m0sfQfxfQfy1qRuecMkJlf1cIzTOgyaeXaiVaaki8/v+WB+U4hc6ik15ZW6TAllRlg/WuQXxWj1jx6C+dfy3w=="], + + "wrangler/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.0", "", { "os": "none", "cpu": "x64" }, "sha512-xbbOdfn06FtcJ9d0ShxxvSn2iUsGd/lgPIO2V3VZIPDbEaIj1/3nBBe1AwuEZKXVXkMmpr6LUAgMkLD/4D2PPA=="], + + "wrangler/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.0", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fWgqR8uNbCQ/GGv0yhzttj6sU/9Z5/Sv/VGU3F5OuXK6J6SlriONKrQ7tNlwBrJZXRYk5jUhuWvF7GYzGguBZQ=="], + + "wrangler/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.0", "", { "os": "openbsd", "cpu": "x64" }, "sha512-aCwlRdSNMNxkGGqQajMUza6uXzR/U0dIl1QmLjPtRbLOx3Gy3otfFu/VjATy4yQzo9yFDGTxYDo1FfAD9oRD2A=="], + + "wrangler/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.0", "", { "os": "none", "cpu": "arm64" }, "sha512-nyvsBccxNAsNYz2jVFYwEGuRRomqZ149A39SHWk4hV0jWxKM0hjBPm3AmdxcbHiFLbBSwG6SbpIcUbXjgyECfA=="], + + "wrangler/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.0", "", { "os": "sunos", "cpu": "x64" }, "sha512-Q1KY1iJafM+UX6CFEL+F4HRTgygmEW568YMqDA5UV97AuZSm21b7SXIrRJDwXWPzr8MGr75fUZPV67FdtMHlHA=="], + + "wrangler/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-W1eyGNi6d+8kOmZIwi/EDjrL9nxQIQ0MiGqe/AWc6+IaHloxHSGoeRgDRKHFISThLmsewZ5nHFvGFWdBYlgKPg=="], + + "wrangler/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-30z1aKL9h22kQhilnYkORFYt+3wp7yZsHWus+wSKAJR8JtdfI76LJ4SBdMsCopTR3z/ORqVu5L1vtnHZWVj4cQ=="], + + "wrangler/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.0", "", { "os": "win32", "cpu": "x64" }, "sha512-aIitBcjQeyOhMTImhLZmtxfdOcuNRpwlPNmlFKPcHQYPhEssw75Cl1TSXJXpMkzaua9FUetx/4OQKq7eJul5Cg=="], + "wrap-ansi-cjs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], "wrap-ansi-cjs/string-width/is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], @@ -5890,6 +6115,40 @@ "@aws-crypto/util/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="], + "@blink-sdk/devhook/tsdown/rolldown/@oxc-project/types": ["@oxc-project/types@0.70.0", "", {}, "sha512-ngyLUpUjO3dpqygSRQDx7nMx8+BmXbWOU4oIwTJFV2MVIDG7knIZwgdwXlQWLg3C3oxg1lS7ppMtPKqKFb7wzw=="], + + "@blink-sdk/devhook/tsdown/rolldown/@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-beta.9", "", { "os": "darwin", "cpu": "arm64" }, "sha512-geUG/FUpm+membLC0NQBb39vVyOfguYZ2oyXc7emr6UjH6TeEECT4b0CPZXKFnELareTiU/Jfl70/eEgNxyQeA=="], + + "@blink-sdk/devhook/tsdown/rolldown/@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-beta.9", "", { "os": "darwin", "cpu": "x64" }, "sha512-7wPXDwcOtv2I+pWTL2UNpNAxMAGukgBT90Jz4DCfwaYdGvQncF7J0S7IWrRVsRFhBavxM+65RcueE3VXw5UIbg=="], + + "@blink-sdk/devhook/tsdown/rolldown/@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0-beta.9", "", { "os": "freebsd", "cpu": "x64" }, "sha512-agO5mONTNKVrcIt4SRxw5Ni0FOVV3gaH8dIiNp1A4JeU91b9kw7x+JRuNJAQuM2X3pYqVvA6qh13UTNOsaqM/Q=="], + + "@blink-sdk/devhook/tsdown/rolldown/@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.9", "", { "os": "linux", "cpu": "arm" }, "sha512-dDNDV9p/8WYDriS9HCcbH6y6+JP38o3enj/pMkdkmkxEnZ0ZoHIfQ9RGYWeRYU56NKBCrya4qZBJx49Jk9LRug=="], + + "@blink-sdk/devhook/tsdown/rolldown/@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.0-beta.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-kZKegmHG1ZvfsFIwYU6DeFSxSIcIliXzeznsJHUo9D9/dlVSDi/PUvsRKcuJkQjZoejM6pk8MHN/UfgGdIhPHw=="], + + "@blink-sdk/devhook/tsdown/rolldown/@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-beta.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-f+VL8mO31pyMJiJPr2aA1ryYONkP2UqgbwK7fKtKHZIeDd/AoUGn3+ujPqDhuy2NxgcJ5H8NaSvDpG1tJMHh+g=="], + + "@blink-sdk/devhook/tsdown/rolldown/@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-beta.9", "", { "os": "linux", "cpu": "x64" }, "sha512-GiUEZ0WPjX5LouDoC3O8aJa4h6BLCpIvaAboNw5JoRour/3dC6rbtZZ/B5FC3/ySsN3/dFOhAH97ylQxoZJi7A=="], + + "@blink-sdk/devhook/tsdown/rolldown/@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0-beta.9", "", { "os": "linux", "cpu": "x64" }, "sha512-AMb0dicw+QHh6RxvWo4BRcuTMgS0cwUejJRMpSyIcHYnKTbj6nUW4HbWNQuDfZiF27l6F5gEwBS+YLUdVzL9vg=="], + + "@blink-sdk/devhook/tsdown/rolldown/@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.0-beta.9", "", { "dependencies": { "@napi-rs/wasm-runtime": "^0.2.4" }, "cpu": "none" }, "sha512-+pdaiTx7L8bWKvsAuCE0HAxP1ze1WOLoWGCawcrZbMSY10dMh2i82lJiH6tXGXbfYYwsNWhWE2NyG4peFZvRfQ=="], + + "@blink-sdk/devhook/tsdown/rolldown/@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.0-beta.9", "", { "os": "win32", "cpu": "arm64" }, "sha512-A7kN248viWvb8eZMzQu024TBKGoyoVYBsDG2DtoP8u2pzwoh5yDqUL291u01o4f8uzpUHq8mfwQJmcGChFu8KQ=="], + + "@blink-sdk/devhook/tsdown/rolldown/@rolldown/binding-win32-ia32-msvc": ["@rolldown/binding-win32-ia32-msvc@1.0.0-beta.9", "", { "os": "win32", "cpu": "ia32" }, "sha512-DzKN7iEYjAP8AK8F2G2aCej3fk43Y/EQrVrR3gF0XREes56chjQ7bXIhw819jv74BbxGdnpPcslhet/cgt7WRA=="], + + "@blink-sdk/devhook/tsdown/rolldown/@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-beta.9", "", { "os": "win32", "cpu": "x64" }, "sha512-GMWgTvvbZ8TfBsAiJpoz4SRq3IN3aUMn0rYm8q4I8dcEk4J1uISyfb6ZMzvqW+cvScTWVKWZNqnrmYOKLLUt4w=="], + + "@blink-sdk/devhook/tsdown/rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.9", "", {}, "sha512-e9MeMtVWo186sgvFFJOPGy7/d2j2mZhLJIdVW0C/xDluuOvymEATqz6zKsP0ZmXGzQtqlyjz5sC1sYQUoJG98w=="], + + "@blink-sdk/devhook/tsdown/rolldown-plugin-dts/@babel/generator": ["@babel/generator@7.28.5", "", { "dependencies": { "@babel/parser": "^7.28.5", "@babel/types": "^7.28.5", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ=="], + + "@blink-sdk/devhook/tsdown/rolldown-plugin-dts/@babel/parser": ["@babel/parser@7.28.5", "", { "dependencies": { "@babel/types": "^7.28.5" }, "bin": "./bin/babel-parser.js" }, "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ=="], + + "@blink-sdk/devhook/tsdown/rolldown-plugin-dts/@babel/types": ["@babel/types@7.28.5", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA=="], + "@blink-sdk/scout-agent/tsdown/rolldown/@oxc-project/types": ["@oxc-project/types@0.72.3", "", {}, "sha512-CfAC4wrmMkUoISpQkFAIfMVvlPfQV3xg7ZlcqPXPOIMQhdKIId44G8W0mCPgtpWdFFAyJ+SFtiM+9vbyCkoVng=="], "@blink-sdk/scout-agent/tsdown/rolldown/@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-beta.13-commit.024b632", "", { "os": "darwin", "cpu": "arm64" }, "sha512-dkMfisSkfS3Rbyj+qL6HFQmGNlwCKhkwH7pKg2oVhzpEQYnuP0YIUGV4WXsTd3hxoHNgs+LQU5LJe78IhE2q6g=="], @@ -6030,6 +6289,10 @@ "yargs/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + "@blink-sdk/devhook/tsdown/rolldown-plugin-dts/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + + "@blink-sdk/devhook/tsdown/rolldown/@rolldown/binding-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@0.2.12", "", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@tybys/wasm-util": "^0.10.0" } }, "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ=="], + "@blink-sdk/scout-agent/tsdown/rolldown/@rolldown/binding-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@0.2.12", "", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@tybys/wasm-util": "^0.10.0" } }, "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ=="], "@npmcli/move-file/rimraf/glob/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], @@ -6046,6 +6309,8 @@ "iconv-corefoundation/cli-truncate/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + "@blink-sdk/devhook/tsdown/rolldown/@rolldown/binding-wasm32-wasi/@napi-rs/wasm-runtime/@emnapi/runtime": ["@emnapi/runtime@1.5.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ=="], + "@blink-sdk/scout-agent/tsdown/rolldown/@rolldown/binding-wasm32-wasi/@napi-rs/wasm-runtime/@emnapi/runtime": ["@emnapi/runtime@1.5.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ=="], "blink/tsdown/rolldown/@rolldown/binding-wasm32-wasi/@napi-rs/wasm-runtime/@emnapi/runtime": ["@emnapi/runtime@1.5.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ=="], diff --git a/packages/devhook/README.md b/packages/devhook/README.md index c074a97..ec9acf6 100644 --- a/packages/devhook/README.md +++ b/packages/devhook/README.md @@ -24,8 +24,13 @@ import { DevhookClient } from "@blink-sdk/devhook"; const client = new DevhookClient({ serverUrl: "https://devhook.example.com", secret: "my-secret-key", + // Transform URLs to point to your local server (used for WebSocket connections) + transformUrl: (url) => { + url.host = "localhost:3000"; + return url; + }, + // Handle HTTP requests onRequest: async (req) => { - // Forward to your local server const url = new URL(req.url); url.host = "localhost:3000"; return fetch(new Request(url.toString(), req)); @@ -173,7 +178,10 @@ interface DevhookClientOptions { /** Client secret for URL generation */ secret: string; - /** Handle incoming proxied requests */ + /** Transform URL to point to local server (used for WebSocket connections) */ + transformUrl?: (url: URL) => URL; + + /** Handle incoming proxied HTTP requests */ onRequest: (request: Request) => Promise; /** Called when connected (with public URL) */ diff --git a/packages/devhook/src/client/index.ts b/packages/devhook/src/client/index.ts index 1d9b15b..a3cadd3 100644 --- a/packages/devhook/src/client/index.ts +++ b/packages/devhook/src/client/index.ts @@ -47,6 +47,21 @@ export interface DevhookClientOptions { * Called when an error occurs. */ onError?: (error: unknown) => void; + + /** + * Transform the incoming URL to point to the local target. + * This is used for both HTTP requests (via onRequest) and WebSocket connections. + * If not provided, URLs are used as-is. + * + * @example + * ```ts + * transformUrl: (url) => { + * url.host = "localhost:3000"; + * return url; + * } + * ``` + */ + transformUrl?: (url: URL) => URL; } /** @@ -378,10 +393,17 @@ export class DevhookClient { init: ProxyInitRequest ): Promise { try { - const url = new URL(init.url); - url.protocol = url.protocol === "https:" ? "wss:" : "ws:"; + // Transform the URL using the same pattern as onRequest + // This allows the user to map the devhook URL to the local target + let targetUrl = new URL(init.url); + + if (this.opts.transformUrl) { + targetUrl = this.opts.transformUrl(targetUrl); + } + + targetUrl.protocol = targetUrl.protocol === "https:" ? "wss:" : "ws:"; - const ws = new WebSocket(url.toString(), init.headers["sec-websocket-protocol"], { + const ws = new WebSocket(targetUrl.toString(), init.headers["sec-websocket-protocol"], { headers: init.headers, perMessageDeflate: false, }); @@ -413,27 +435,35 @@ export class DevhookClient { }); ws.on("close", (code, reason) => { - const closePayload: WebSocketClosePayload = { - code, - reason: reason.toString(), - }; - stream.writeTyped( - ClientMessageType.PROXY_WEBSOCKET_CLOSE, - this.encoder.encode(JSON.stringify(closePayload)) - ); - stream.close(); + try { + const closePayload: WebSocketClosePayload = { + code, + reason: reason.toString(), + }; + stream.writeTyped( + ClientMessageType.PROXY_WEBSOCKET_CLOSE, + this.encoder.encode(JSON.stringify(closePayload)) + ); + stream.close(); + } catch { + // Stream may already be disposed, ignore + } }); ws.on("error", (err) => { - const closePayload: WebSocketClosePayload = { - code: 1011, - reason: err.message, - }; - stream.writeTyped( - ClientMessageType.PROXY_WEBSOCKET_CLOSE, - this.encoder.encode(JSON.stringify(closePayload)) - ); - stream.close(); + try { + const closePayload: WebSocketClosePayload = { + code: 1011, + reason: err.message, + }; + stream.writeTyped( + ClientMessageType.PROXY_WEBSOCKET_CLOSE, + this.encoder.encode(JSON.stringify(closePayload)) + ); + stream.close(); + } catch { + // Stream may already be disposed, ignore + } }); // Handle messages from the server to forward to the local WebSocket @@ -448,10 +478,21 @@ export class DevhookClient { break; } case ServerMessageType.PROXY_WEBSOCKET_CLOSE: { - const closePayload = JSON.parse( - this.decoder.decode(payload) - ) as WebSocketClosePayload; - ws.close(closePayload.code, closePayload.reason); + try { + const closePayload = JSON.parse( + this.decoder.decode(payload) + ) as WebSocketClosePayload; + // ws.close requires a valid code (1000 or 3000-4999) or no arguments + const code = closePayload.code; + if (code !== undefined && code >= 1000 && code <= 4999) { + ws.close(code, closePayload.reason); + } else { + ws.close(); + } + } catch { + // Ignore close errors + ws.close(); + } break; } } diff --git a/packages/devhook/src/devhook.test.ts b/packages/devhook/src/devhook.test.ts index dbc83ea..83a6def 100644 --- a/packages/devhook/src/devhook.test.ts +++ b/packages/devhook/src/devhook.test.ts @@ -1336,6 +1336,796 @@ describe("devhook", () => { }); }); + describe("websocket proxying", () => { + let server: ReturnType; + let serverPort: number; + let clientConnections: Array<{ dispose: () => void }> = []; + + beforeAll(async () => { + serverPort = 22080 + Math.floor(Math.random() * 1000); + server = createLocalServer({ + port: serverPort, + secret: SERVER_SECRET, + baseUrl: `http://localhost:${serverPort}`, + mode: "subpath", + }); + await new Promise((resolve) => setTimeout(resolve, 100)); + }); + + afterAll(() => { + server?.close(); + }); + + afterEach(() => { + for (const conn of clientConnections) { + conn.dispose(); + } + clientConnections = []; + }); + + it("should proxy WebSocket connections", async () => { + const receivedMessages: string[] = []; + let localWsConnected = false; + + // Create a simple WebSocket server to simulate the local target FIRST + const { WebSocketServer, WebSocket: WsClient } = await import("ws"); + const localWsServer = new WebSocketServer({ port: 0 }); + const localWsPort = (localWsServer.address() as { port: number }).port; + + localWsServer.on("connection", (ws) => { + localWsConnected = true; + ws.on("message", (data) => { + receivedMessages.push(data.toString()); + // Echo back the message + ws.send(`echo: ${data.toString()}`); + }); + }); + + const client = new DevhookClient({ + serverUrl: `http://localhost:${serverPort}`, + secret: "ws-test-client", + transformUrl: (url) => { + url.host = `localhost:${localWsPort}`; + return url; + }, + onRequest: async (req) => { + const url = new URL(req.url); + url.host = `localhost:${localWsPort}`; + return fetch(new Request(url.toString(), req)); + }, + }); + + const disposable = client.connect(); + clientConnections.push(disposable); + await new Promise((resolve) => setTimeout(resolve, 200)); + + const devhookId = await generateDevhookId("ws-test-client", SERVER_SECRET); + + // Connect to the devhook URL via WebSocket + const externalWs = new WsClient( + `ws://localhost:${serverPort}/devhook/${devhookId}/ws` + ); + + const externalMessages: string[] = []; + await new Promise((resolve, reject) => { + externalWs.on("open", () => { + externalWs.send("hello from external"); + }); + + externalWs.on("message", (data) => { + externalMessages.push(data.toString()); + if (externalMessages.length >= 1) { + resolve(); + } + }); + + externalWs.on("error", reject); + + setTimeout(() => reject(new Error("Timeout waiting for WebSocket")), 5000); + }); + + expect(localWsConnected).toBe(true); + expect(receivedMessages).toContain("hello from external"); + expect(externalMessages).toContain("echo: hello from external"); + + externalWs.close(); + localWsServer.close(); + }); + + it("should handle WebSocket close from external client", async () => { + let localWsClosed = false; + let closeCode: number | undefined; + + const { WebSocketServer, WebSocket: WsClient } = await import("ws"); + const localWsServer = new WebSocketServer({ port: 0 }); + const localWsPort = (localWsServer.address() as { port: number }).port; + + localWsServer.on("connection", (ws) => { + ws.on("close", (code) => { + localWsClosed = true; + closeCode = code; + }); + }); + + const client = new DevhookClient({ + serverUrl: `http://localhost:${serverPort}`, + secret: "ws-close-test", + transformUrl: (url) => { + url.host = `localhost:${localWsPort}`; + return url; + }, + onRequest: async (req) => { + const url = new URL(req.url); + url.host = `localhost:${localWsPort}`; + return fetch(new Request(url.toString(), req)); + }, + }); + + const disposable = client.connect(); + clientConnections.push(disposable); + await new Promise((resolve) => setTimeout(resolve, 200)); + + const devhookId = await generateDevhookId("ws-close-test", SERVER_SECRET); + + const externalWs = new WsClient( + `ws://localhost:${serverPort}/devhook/${devhookId}/ws` + ); + + await new Promise((resolve, reject) => { + externalWs.on("open", () => { + // Close with a specific code + externalWs.close(1000, "Normal closure"); + }); + + externalWs.on("close", () => { + // Give time for the close to propagate + setTimeout(resolve, 100); + }); + + externalWs.on("error", reject); + setTimeout(() => reject(new Error("Timeout")), 5000); + }); + + expect(localWsClosed).toBe(true); + expect(closeCode).toBe(1000); + + localWsServer.close(); + }); + + it("should handle WebSocket close from local server", async () => { + let externalWsClosed = false; + let externalCloseCode: number | undefined; + + const { WebSocketServer, WebSocket: WsClient } = await import("ws"); + const localWsServer = new WebSocketServer({ port: 0 }); + const localWsPort = (localWsServer.address() as { port: number }).port; + + localWsServer.on("connection", (ws) => { + // Close immediately after connection + setTimeout(() => ws.close(1001, "Going away"), 50); + }); + + const client = new DevhookClient({ + serverUrl: `http://localhost:${serverPort}`, + secret: "ws-server-close-test", + transformUrl: (url) => { + url.host = `localhost:${localWsPort}`; + return url; + }, + onRequest: async (req) => { + const url = new URL(req.url); + url.host = `localhost:${localWsPort}`; + return fetch(new Request(url.toString(), req)); + }, + }); + + const disposable = client.connect(); + clientConnections.push(disposable); + await new Promise((resolve) => setTimeout(resolve, 200)); + + const devhookId = await generateDevhookId("ws-server-close-test", SERVER_SECRET); + + const externalWs = new WsClient( + `ws://localhost:${serverPort}/devhook/${devhookId}/ws` + ); + + await new Promise((resolve, reject) => { + externalWs.on("close", (code) => { + externalWsClosed = true; + externalCloseCode = code; + resolve(); + }); + + externalWs.on("error", reject); + setTimeout(() => reject(new Error("Timeout")), 5000); + }); + + expect(externalWsClosed).toBe(true); + expect(externalCloseCode).toBe(1001); + + localWsServer.close(); + }); + + it("should handle binary WebSocket messages", async () => { + const receivedBinary: Uint8Array[] = []; + + const { WebSocketServer, WebSocket: WsClient } = await import("ws"); + const localWsServer = new WebSocketServer({ port: 0 }); + const localWsPort = (localWsServer.address() as { port: number }).port; + + localWsServer.on("connection", (ws) => { + ws.on("message", (data) => { + const bytes = data instanceof Buffer ? new Uint8Array(data) : new Uint8Array(data as ArrayBuffer); + receivedBinary.push(bytes); + // Echo back + ws.send(bytes); + }); + }); + + const client = new DevhookClient({ + serverUrl: `http://localhost:${serverPort}`, + secret: "ws-binary-test", + transformUrl: (url) => { + url.host = `localhost:${localWsPort}`; + return url; + }, + onRequest: async (req) => { + const url = new URL(req.url); + url.host = `localhost:${localWsPort}`; + return fetch(new Request(url.toString(), req)); + }, + }); + + const disposable = client.connect(); + clientConnections.push(disposable); + await new Promise((resolve) => setTimeout(resolve, 200)); + + const devhookId = await generateDevhookId("ws-binary-test", SERVER_SECRET); + + const externalWs = new WsClient( + `ws://localhost:${serverPort}/devhook/${devhookId}/ws` + ); + + const receivedEcho: Uint8Array[] = []; + const testData = new Uint8Array([0x00, 0x01, 0xFF, 0xFE, 0x42]); + + await new Promise((resolve, reject) => { + externalWs.on("open", () => { + externalWs.send(testData); + }); + + externalWs.on("message", (data) => { + const bytes = data instanceof Buffer ? new Uint8Array(data) : new Uint8Array(data as ArrayBuffer); + receivedEcho.push(bytes); + resolve(); + }); + + externalWs.on("error", reject); + setTimeout(() => reject(new Error("Timeout")), 5000); + }); + + // Wait a bit for any stray messages to settle + await new Promise((r) => setTimeout(r, 50)); + + expect(receivedBinary.length).toBeGreaterThanOrEqual(1); + expect(Array.from(receivedBinary[0]!)).toEqual(Array.from(testData)); + expect(receivedEcho.length).toBeGreaterThanOrEqual(1); + expect(Array.from(receivedEcho[0]!)).toEqual(Array.from(testData)); + + externalWs.close(); + localWsServer.close(); + }); + + it("should return 503 when no client is connected for WebSocket", async () => { + const { WebSocket: WsClient } = await import("ws"); + const devhookId = await generateDevhookId("nonexistent-ws", SERVER_SECRET); + + const externalWs = new WsClient( + `ws://localhost:${serverPort}/devhook/${devhookId}/ws` + ); + + await new Promise((resolve) => { + externalWs.on("error", () => { + // Expected - connection should fail + resolve(); + }); + + externalWs.on("open", () => { + // This shouldn't happen + externalWs.close(); + resolve(); + }); + + setTimeout(resolve, 1000); + }); + + expect(externalWs.readyState).not.toBe(WsClient.OPEN); + }); + + it("should handle multiple concurrent WebSocket connections to the same client", async () => { + const messagesPerConnection: Map = new Map(); + const localConnections: Set = new Set(); + let connectionCounter = 0; + + const { WebSocketServer, WebSocket: WsClient } = await import("ws"); + const localWsServer = new WebSocketServer({ port: 0 }); + const localWsPort = (localWsServer.address() as { port: number }).port; + + localWsServer.on("connection", (ws) => { + const connId = connectionCounter++; + localConnections.add(connId); + messagesPerConnection.set(connId, []); + + ws.on("message", (data) => { + const msg = data.toString(); + messagesPerConnection.get(connId)!.push(msg); + // Echo back with connection ID + ws.send(`conn${connId}: ${msg}`); + }); + + ws.on("close", () => { + localConnections.delete(connId); + }); + }); + + const client = new DevhookClient({ + serverUrl: `http://localhost:${serverPort}`, + secret: "ws-concurrent-test", + transformUrl: (url) => { + url.host = `localhost:${localWsPort}`; + return url; + }, + onRequest: async (req) => { + const url = new URL(req.url); + url.host = `localhost:${localWsPort}`; + return fetch(new Request(url.toString(), req)); + }, + }); + + const disposable = client.connect(); + clientConnections.push(disposable); + await new Promise((resolve) => setTimeout(resolve, 200)); + + const devhookId = await generateDevhookId("ws-concurrent-test", SERVER_SECRET); + + // Create 5 concurrent WebSocket connections + const numConnections = 5; + const externalWsConnections: InstanceType[] = []; + const receivedMessages: Map = new Map(); + + for (let i = 0; i < numConnections; i++) { + receivedMessages.set(i, []); + const ws = new WsClient( + `ws://localhost:${serverPort}/devhook/${devhookId}/ws${i}` + ); + externalWsConnections.push(ws); + } + + // Wait for all connections to open + await Promise.all( + externalWsConnections.map( + (ws, i) => + new Promise((resolve, reject) => { + ws.on("open", resolve); + ws.on("error", reject); + ws.on("message", (data) => { + receivedMessages.get(i)!.push(data.toString()); + }); + setTimeout(() => reject(new Error(`Connection ${i} timeout`)), 5000); + }) + ) + ); + + expect(localConnections.size).toBe(numConnections); + + // Send messages from each connection + for (let i = 0; i < numConnections; i++) { + externalWsConnections[i]!.send(`hello from ws${i}`); + } + + // Wait for responses + await new Promise((resolve) => setTimeout(resolve, 200)); + + // Verify each connection received its own response + for (let i = 0; i < numConnections; i++) { + expect(receivedMessages.get(i)!.length).toBeGreaterThanOrEqual(1); + expect(receivedMessages.get(i)![0]).toContain(`hello from ws${i}`); + } + + // Verify local server received all messages + const allLocalMessages = Array.from(messagesPerConnection.values()).flat(); + expect(allLocalMessages.length).toBe(numConnections); + + // Close all connections + for (const ws of externalWsConnections) { + ws.close(); + } + + await new Promise((resolve) => setTimeout(resolve, 100)); + localWsServer.close(); + }); + + it("should handle WebSocket connections from multiple devhook clients simultaneously", async () => { + const { WebSocketServer, WebSocket: WsClient } = await import("ws"); + + // Create two separate local WebSocket servers for two different devhook clients + const localWsServer1 = new WebSocketServer({ port: 0 }); + const localWsPort1 = (localWsServer1.address() as { port: number }).port; + const localWsServer2 = new WebSocketServer({ port: 0 }); + const localWsPort2 = (localWsServer2.address() as { port: number }).port; + + const messages1: string[] = []; + const messages2: string[] = []; + + localWsServer1.on("connection", (ws) => { + ws.on("message", (data) => { + messages1.push(data.toString()); + ws.send(`server1: ${data.toString()}`); + }); + }); + + localWsServer2.on("connection", (ws) => { + ws.on("message", (data) => { + messages2.push(data.toString()); + ws.send(`server2: ${data.toString()}`); + }); + }); + + // Create two devhook clients with different secrets + const client1 = new DevhookClient({ + serverUrl: `http://localhost:${serverPort}`, + secret: "ws-multi-client-1", + transformUrl: (url) => { + url.host = `localhost:${localWsPort1}`; + return url; + }, + onRequest: async (req) => { + const url = new URL(req.url); + url.host = `localhost:${localWsPort1}`; + return fetch(new Request(url.toString(), req)); + }, + }); + + const client2 = new DevhookClient({ + serverUrl: `http://localhost:${serverPort}`, + secret: "ws-multi-client-2", + transformUrl: (url) => { + url.host = `localhost:${localWsPort2}`; + return url; + }, + onRequest: async (req) => { + const url = new URL(req.url); + url.host = `localhost:${localWsPort2}`; + return fetch(new Request(url.toString(), req)); + }, + }); + + const disposable1 = client1.connect(); + const disposable2 = client2.connect(); + clientConnections.push(disposable1, disposable2); + await new Promise((resolve) => setTimeout(resolve, 200)); + + const devhookId1 = await generateDevhookId("ws-multi-client-1", SERVER_SECRET); + const devhookId2 = await generateDevhookId("ws-multi-client-2", SERVER_SECRET); + + // Connect external WebSockets to each devhook + const externalWs1 = new WsClient( + `ws://localhost:${serverPort}/devhook/${devhookId1}/ws` + ); + const externalWs2 = new WsClient( + `ws://localhost:${serverPort}/devhook/${devhookId2}/ws` + ); + + const received1: string[] = []; + const received2: string[] = []; + + // Wait for both connections to open and set up message handlers + await Promise.all([ + new Promise((resolve, reject) => { + externalWs1.on("open", resolve); + externalWs1.on("error", reject); + externalWs1.on("message", (data) => received1.push(data.toString())); + setTimeout(() => reject(new Error("Timeout ws1")), 5000); + }), + new Promise((resolve, reject) => { + externalWs2.on("open", resolve); + externalWs2.on("error", reject); + externalWs2.on("message", (data) => received2.push(data.toString())); + setTimeout(() => reject(new Error("Timeout ws2")), 5000); + }), + ]); + + // Send messages to each devhook + externalWs1.send("message to client 1"); + externalWs2.send("message to client 2"); + + // Wait for responses + await new Promise((resolve) => setTimeout(resolve, 200)); + + // Verify messages were routed correctly + expect(messages1).toContain("message to client 1"); + expect(messages2).toContain("message to client 2"); + expect(messages1).not.toContain("message to client 2"); + expect(messages2).not.toContain("message to client 1"); + + // Verify responses came from correct servers + expect(received1.length).toBeGreaterThanOrEqual(1); + expect(received1[0]).toContain("server1:"); + expect(received2.length).toBeGreaterThanOrEqual(1); + expect(received2[0]).toContain("server2:"); + + // Cleanup + externalWs1.close(); + externalWs2.close(); + await new Promise((resolve) => setTimeout(resolve, 100)); + localWsServer1.close(); + localWsServer2.close(); + }); + + it("should handle rapid WebSocket message exchange across multiple connections", async () => { + const { WebSocketServer, WebSocket: WsClient } = await import("ws"); + const localWsServer = new WebSocketServer({ port: 0 }); + const localWsPort = (localWsServer.address() as { port: number }).port; + + const allReceivedByServer: string[] = []; + + localWsServer.on("connection", (ws) => { + ws.on("message", (data) => { + allReceivedByServer.push(data.toString()); + // Echo back immediately + ws.send(data); + }); + }); + + const client = new DevhookClient({ + serverUrl: `http://localhost:${serverPort}`, + secret: "ws-rapid-test", + transformUrl: (url) => { + url.host = `localhost:${localWsPort}`; + return url; + }, + onRequest: async (req) => { + const url = new URL(req.url); + url.host = `localhost:${localWsPort}`; + return fetch(new Request(url.toString(), req)); + }, + }); + + const disposable = client.connect(); + clientConnections.push(disposable); + await new Promise((resolve) => setTimeout(resolve, 200)); + + const devhookId = await generateDevhookId("ws-rapid-test", SERVER_SECRET); + + // Create 3 connections that will all send messages rapidly + const numConnections = 3; + const messagesPerConnection = 10; + const connections: InstanceType[] = []; + const receivedByClient: Map = new Map(); + + for (let i = 0; i < numConnections; i++) { + receivedByClient.set(i, []); + const ws = new WsClient( + `ws://localhost:${serverPort}/devhook/${devhookId}/rapid${i}` + ); + connections.push(ws); + } + + // Wait for all connections to open + await Promise.all( + connections.map( + (ws, i) => + new Promise((resolve, reject) => { + ws.on("open", resolve); + ws.on("error", reject); + ws.on("message", (data) => { + receivedByClient.get(i)!.push(data.toString()); + }); + setTimeout(() => reject(new Error(`Connection ${i} timeout`)), 5000); + }) + ) + ); + + // Send messages rapidly from all connections + const sendPromises: Promise[] = []; + for (let i = 0; i < numConnections; i++) { + for (let j = 0; j < messagesPerConnection; j++) { + connections[i]!.send(`conn${i}-msg${j}`); + } + } + + // Wait for all messages to be processed + const totalExpectedMessages = numConnections * messagesPerConnection; + await new Promise((resolve) => { + const checkComplete = () => { + if (allReceivedByServer.length >= totalExpectedMessages) { + resolve(); + } else { + setTimeout(checkComplete, 50); + } + }; + checkComplete(); + // Timeout after 5 seconds + setTimeout(resolve, 5000); + }); + + // Verify all messages were received by the server + expect(allReceivedByServer.length).toBe(totalExpectedMessages); + + // Verify each connection's messages are present + for (let i = 0; i < numConnections; i++) { + for (let j = 0; j < messagesPerConnection; j++) { + expect(allReceivedByServer).toContain(`conn${i}-msg${j}`); + } + } + + // Wait a bit more for echo responses + await new Promise((resolve) => setTimeout(resolve, 200)); + + // Verify each client received echo responses + // Note: Each connection receives echoes for all messages sent on that connection + for (let i = 0; i < numConnections; i++) { + expect(receivedByClient.get(i)!.length).toBeGreaterThanOrEqual(messagesPerConnection); + } + + // Cleanup + for (const ws of connections) { + ws.close(); + } + await new Promise((resolve) => setTimeout(resolve, 100)); + localWsServer.close(); + }); + + it("should isolate WebSocket connections - closing one doesn't affect others", async () => { + const { WebSocketServer, WebSocket: WsClient } = await import("ws"); + const localWsServer = new WebSocketServer({ port: 0 }); + const localWsPort = (localWsServer.address() as { port: number }).port; + + let activeLocalConnections = 0; + + localWsServer.on("connection", (ws) => { + activeLocalConnections++; + ws.on("message", (data) => ws.send(data)); + ws.on("close", () => activeLocalConnections--); + }); + + const client = new DevhookClient({ + serverUrl: `http://localhost:${serverPort}`, + secret: "ws-isolate-test", + transformUrl: (url) => { + url.host = `localhost:${localWsPort}`; + return url; + }, + onRequest: async (req) => { + const url = new URL(req.url); + url.host = `localhost:${localWsPort}`; + return fetch(new Request(url.toString(), req)); + }, + }); + + const disposable = client.connect(); + clientConnections.push(disposable); + await new Promise((resolve) => setTimeout(resolve, 200)); + + const devhookId = await generateDevhookId("ws-isolate-test", SERVER_SECRET); + + // Create 3 connections + const ws1 = new WsClient(`ws://localhost:${serverPort}/devhook/${devhookId}/a`); + const ws2 = new WsClient(`ws://localhost:${serverPort}/devhook/${devhookId}/b`); + const ws3 = new WsClient(`ws://localhost:${serverPort}/devhook/${devhookId}/c`); + + const received2: string[] = []; + const received3: string[] = []; + + await Promise.all([ + new Promise((resolve, reject) => { + ws1.on("open", resolve); + ws1.on("error", reject); + setTimeout(() => reject(new Error("Timeout")), 5000); + }), + new Promise((resolve, reject) => { + ws2.on("open", resolve); + ws2.on("error", reject); + ws2.on("message", (data) => received2.push(data.toString())); + setTimeout(() => reject(new Error("Timeout")), 5000); + }), + new Promise((resolve, reject) => { + ws3.on("open", resolve); + ws3.on("error", reject); + ws3.on("message", (data) => received3.push(data.toString())); + setTimeout(() => reject(new Error("Timeout")), 5000); + }), + ]); + + expect(activeLocalConnections).toBe(3); + + // Close ws1 + ws1.close(); + // Wait for close to propagate through the devhook proxy + await new Promise((resolve) => setTimeout(resolve, 200)); + + // ws2 and ws3 should still work and be able to send/receive messages + expect(ws2.readyState).toBe(WsClient.OPEN); + expect(ws3.readyState).toBe(WsClient.OPEN); + + // Send messages on remaining connections - this is the key test + ws2.send("still alive 2"); + ws3.send("still alive 3"); + + await new Promise((resolve) => setTimeout(resolve, 200)); + + // The important assertion: closing ws1 didn't break ws2 and ws3 + expect(received2).toContain("still alive 2"); + expect(received3).toContain("still alive 3"); + + // Cleanup + ws2.close(); + ws3.close(); + await new Promise((resolve) => setTimeout(resolve, 100)); + localWsServer.close(); + }); + + it("should close proxied WebSockets when devhook client disconnects", async () => { + let externalWsClosed = false; + + const { WebSocketServer, WebSocket: WsClient } = await import("ws"); + const localWsServer = new WebSocketServer({ port: 0 }); + const localWsPort = (localWsServer.address() as { port: number }).port; + + localWsServer.on("connection", (ws) => { + // Keep connection open + ws.on("message", (data) => { + ws.send(data); + }); + }); + + const client = new DevhookClient({ + serverUrl: `http://localhost:${serverPort}`, + secret: "ws-disconnect-test", + transformUrl: (url) => { + url.host = `localhost:${localWsPort}`; + return url; + }, + onRequest: async (req) => { + const url = new URL(req.url); + url.host = `localhost:${localWsPort}`; + return fetch(new Request(url.toString(), req)); + }, + }); + + const disposable = client.connect(); + clientConnections.push(disposable); + await new Promise((resolve) => setTimeout(resolve, 200)); + + const devhookId = await generateDevhookId("ws-disconnect-test", SERVER_SECRET); + + const externalWs = new WsClient( + `ws://localhost:${serverPort}/devhook/${devhookId}/ws` + ); + + await new Promise((resolve, reject) => { + externalWs.on("open", resolve); + externalWs.on("error", reject); + setTimeout(() => reject(new Error("Timeout")), 5000); + }); + + externalWs.on("close", () => { + externalWsClosed = true; + }); + + // Disconnect the devhook client + disposable.dispose(); + clientConnections.pop(); + + // Wait for close to propagate + await new Promise((resolve) => setTimeout(resolve, 300)); + + expect(externalWsClosed).toBe(true); + + localWsServer.close(); + }); + }); + describe("server callbacks", () => { it("should call onClientConnect and onClientDisconnect", async () => { const connectedIds: string[] = []; diff --git a/packages/devhook/src/server/local.ts b/packages/devhook/src/server/local.ts index 9815052..e4e8c54 100644 --- a/packages/devhook/src/server/local.ts +++ b/packages/devhook/src/server/local.ts @@ -168,14 +168,14 @@ export function createLocalServer(opts: LocalServerOptions): { const worker = session.worker!; const response = await worker.proxy(proxyRequest); - // Handle WebSocket upgrade + // Handle WebSocket upgrade - this shouldn't happen for HTTP requests + // WebSocket upgrades are handled in the httpServer.on("upgrade") handler if (response.upgrade) { - // WebSocket upgrade on proxy is complex in Node, skip for now - res.writeHead(501, { "content-type": "application/json" }); + res.writeHead(400, { "content-type": "application/json" }); res.end( JSON.stringify({ - error: "Not implemented", - message: "WebSocket proxying is not yet supported in local mode.", + error: "Bad request", + message: "WebSocket upgrade requests must use the WebSocket protocol.", }) ); return; @@ -218,79 +218,190 @@ export function createLocalServer(opts: LocalServerOptions): { const wss = new WebSocketServer({ noServer: true }); + // WebSocket server for proxied connections (external -> local) + const proxyWss = new WebSocketServer({ noServer: true }); + httpServer.on("upgrade", async (req, socket, head) => { const url = new URL(req.url || "/", `http://${req.headers.host}`); - if (url.pathname !== "/api/devhook/connect") { + // Handle devhook client connections + if (url.pathname === "/api/devhook/connect") { + // Get client secret + const clientSecret = req.headers["x-devhook-secret"] as string; + if (!clientSecret) { + socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n"); + socket.destroy(); + return; + } + + // Generate devhook ID + const devhookId = await generateDevhookId(clientSecret, opts.secret); + + // Get or create session + let session = sessions.get(devhookId); + if (session && session.ws && session.ws.readyState === WebSocket.OPEN) { + // Close existing connection + session.ws.close(1000, "A new client has connected."); + } + + wss.handleUpgrade(req, socket, head, (ws) => { + // Create worker for this session + const worker = new Worker({ + initialNextStreamID: session?.worker ? undefined : 1, + sendToClient: (data: Uint8Array) => { + if (ws.readyState === WebSocket.OPEN) { + ws.send(data); + } + }, + }); + + session = { + id: devhookId, + clientSecret, + ws, + worker, + proxiedWebSockets: session?.proxiedWebSockets ?? new Map(), + }; + sessions.set(devhookId, session); + + // Subscribe to WebSocket messages from the devhook client + worker.onWebSocketMessage((event) => { + const proxyWs = session!.proxiedWebSockets.get(event.stream); + if (proxyWs && proxyWs.readyState === WebSocket.OPEN) { + proxyWs.send(event.message); + } + }); + + // Subscribe to WebSocket close events from the devhook client + worker.onWebSocketClose((event) => { + const proxyWs = session!.proxiedWebSockets.get(event.stream); + if (proxyWs) { + proxyWs.close(event.code, event.reason); + session!.proxiedWebSockets.delete(event.stream); + } + }); + + // Send connection info + const publicUrl = getPublicUrl(devhookId, opts.baseUrl, mode); + const connectionInfo: ConnectionEstablished = { + url: publicUrl, + id: devhookId, + }; + ws.send(JSON.stringify(connectionInfo)); + + opts.onClientConnect?.(devhookId); + + ws.on("message", (data: Buffer) => { + worker.handleClientMessage(new Uint8Array(data)); + }); + + ws.on("close", () => { + if (sessions.get(devhookId)?.ws === ws) { + const s = sessions.get(devhookId)!; + // Close all proxied WebSockets when client disconnects + for (const proxyWs of s.proxiedWebSockets.values()) { + try { + proxyWs.close(1001, "Devhook client disconnected"); + } catch { + // Ignore close errors + } + } + s.proxiedWebSockets.clear(); + s.ws = null; + s.worker = null; + } + opts.onClientDisconnect?.(devhookId); + }); + + ws.on("error", () => { + // Ignore errors + }); + }); + return; + } + + // Handle proxied WebSocket connections (external -> devhook -> local) + const devhookId = extractDevhookId(url, opts.baseUrl, mode, req.headers.host); + if (!devhookId) { + socket.write("HTTP/1.1 404 Not Found\r\n\r\n"); socket.destroy(); return; } - // Get client secret - const clientSecret = req.headers["x-devhook-secret"] as string; - if (!clientSecret) { - socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n"); + const session = sessions.get(devhookId); + if (!session || !session.ws || session.ws.readyState !== WebSocket.OPEN || !session.worker) { + socket.write("HTTP/1.1 503 Service Unavailable\r\n\r\n"); socket.destroy(); return; } - // Generate devhook ID - const devhookId = await generateDevhookId(clientSecret, opts.secret); + // Build proxy URL (strip devhook prefix in subpath mode) + let proxyPath: string; + if (mode === "subpath") { + proxyPath = url.pathname.replace(/^\/devhook\/[a-z0-9]+/, "") || "/"; + } else { + proxyPath = url.pathname; + } + const proxyUrl = new URL(proxyPath + url.search, url.origin); - // Get or create session - let session = sessions.get(devhookId); - if (session && session.ws && session.ws.readyState === WebSocket.OPEN) { - // Close existing connection - session.ws.close(1000, "A new client has connected."); + // Build headers + const headers: Record = {}; + for (const [key, value] of Object.entries(req.headers)) { + if (value) { + headers[key] = Array.isArray(value) ? value.join(", ") : value; + } } - wss.handleUpgrade(req, socket, head, (ws) => { - // Create worker for this session - const worker = new Worker({ - initialNextStreamID: session?.worker ? undefined : 1, - sendToClient: (data: Uint8Array) => { - if (ws.readyState === WebSocket.OPEN) { - ws.send(data); - } - }, - }); + // Send the WebSocket upgrade request through the worker to the devhook client + const worker = session.worker; + const proxyRequest = new Request(proxyUrl.toString(), { + method: "GET", + headers, + }); - session = { - id: devhookId, - clientSecret, - ws, - worker, - proxiedWebSockets: session?.proxiedWebSockets ?? new Map(), - }; - sessions.set(devhookId, session); - - // Send connection info - const publicUrl = getPublicUrl(devhookId, opts.baseUrl, mode); - const connectionInfo: ConnectionEstablished = { - url: publicUrl, - id: devhookId, - }; - ws.send(JSON.stringify(connectionInfo)); - - opts.onClientConnect?.(devhookId); - - ws.on("message", (data: Buffer) => { - worker.handleClientMessage(new Uint8Array(data)); - }); + try { + const response = await worker.proxy(proxyRequest); - ws.on("close", () => { - if (sessions.get(devhookId)?.ws === ws) { - const s = sessions.get(devhookId)!; - s.ws = null; - s.worker = null; - } - opts.onClientDisconnect?.(devhookId); - }); + if (!response.upgrade) { + // The local server didn't accept the WebSocket upgrade + socket.write(`HTTP/1.1 ${response.status} ${response.statusText}\r\n\r\n`); + socket.destroy(); + return; + } - ws.on("error", () => { - // Ignore errors + const streamID = response.stream; + + // Upgrade the external connection + proxyWss.handleUpgrade(req, socket, head, (externalWs) => { + // Store the proxied WebSocket + session.proxiedWebSockets.set(streamID, externalWs); + + // Forward messages from external WebSocket to devhook client + externalWs.on("message", (data: Buffer | ArrayBuffer | Buffer[]) => { + const payload = data instanceof ArrayBuffer + ? new Uint8Array(data) + : Array.isArray(data) + ? Buffer.concat(data) + : data; + worker.sendProxiedWebSocketMessage(streamID, payload); + }); + + // Handle close from external WebSocket + externalWs.on("close", (code, reason) => { + worker.sendProxiedWebSocketClose(streamID, code, reason.toString()); + session.proxiedWebSockets.delete(streamID); + }); + + // Handle errors from external WebSocket + externalWs.on("error", () => { + worker.sendProxiedWebSocketClose(streamID, 1011, "WebSocket error"); + session.proxiedWebSockets.delete(streamID); + }); }); - }); + } catch (err) { + socket.write("HTTP/1.1 502 Bad Gateway\r\n\r\n"); + socket.destroy(); + } }); httpServer.listen(opts.port, () => { @@ -309,6 +420,7 @@ export function createLocalServer(opts: LocalServerOptions): { } sessions.clear(); wss.close(); + proxyWss.close(); httpServer.close(); }, }; From e61d94fcae77846ac3f3a1d033b8ec6390c1c761 Mon Sep 17 00:00:00 2001 From: Hugo Dutka Date: Mon, 15 Dec 2025 15:51:52 +0000 Subject: [PATCH 07/14] test(devhook): add shared test suite for local and Cloudflare servers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refactor tests to use a shared test suite that runs against both server implementations to ensure API compatibility: - Add shared.test-suite.ts with tests for HTTP proxying and WebSocket - Add test adapters for local and Cloudflare servers - Add test-utils.ts with common test helpers - Fix Cloudflare DO initialization to work via request headers - Fix getPublicUrl() to include /devhook/ prefix in subpath mode - Skip WebSocket tests for Cloudflare in local mode (miniflare limitation) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- packages/devhook/package.json | 4 +- packages/devhook/src/cloudflare.test.ts | 45 + packages/devhook/src/devhook.test.ts | 2130 +---------------- .../src/server/cloudflare.test-adapter.ts | 78 + packages/devhook/src/server/cloudflare.ts | 30 +- packages/devhook/src/server/durable-object.ts | 35 +- .../devhook/src/server/local.test-adapter.ts | 34 + packages/devhook/src/shared.test-suite.ts | 867 +++++++ packages/devhook/src/test-utils.ts | 118 + 9 files changed, 1184 insertions(+), 2157 deletions(-) create mode 100644 packages/devhook/src/cloudflare.test.ts create mode 100644 packages/devhook/src/server/cloudflare.test-adapter.ts create mode 100644 packages/devhook/src/server/local.test-adapter.ts create mode 100644 packages/devhook/src/shared.test-suite.ts create mode 100644 packages/devhook/src/test-utils.ts diff --git a/packages/devhook/package.json b/packages/devhook/package.json index 3db715f..f8b9f8f 100644 --- a/packages/devhook/package.json +++ b/packages/devhook/package.json @@ -29,7 +29,9 @@ "scripts": { "build": "tsdown", "typecheck": "tsgo --noEmit", - "test": "vitest run", + "test": "vitest run src/devhook.test.ts", + "test:cloudflare": "vitest run src/cloudflare.test.ts", + "test:all": "vitest run", "test:watch": "vitest" }, "exports": { diff --git a/packages/devhook/src/cloudflare.test.ts b/packages/devhook/src/cloudflare.test.ts new file mode 100644 index 0000000..ff4e56a --- /dev/null +++ b/packages/devhook/src/cloudflare.test.ts @@ -0,0 +1,45 @@ +/** + * Cloudflare server test suite. + * + * This file runs the shared test suite against the Cloudflare Worker implementation + * using wrangler's unstable_dev API for local testing. + * + * IMPORTANT: These tests require wrangler and its dependencies to work properly. + * They may be skipped in CI if wrangler isn't available. + * + * Run with: bun run test:cloudflare + * Or with explicit enable: ENABLE_CLOUDFLARE_TESTS=1 bun run test:cloudflare + * + * NOTE: WebSocket proxying tests are skipped in local mode because miniflare's + * WebSocket implementation has some differences from production Cloudflare. + * The HTTP tests are the primary concern for API compatibility. + */ + +import { describe, it, expect } from "vitest"; +import { createCloudflareServerFactory } from "./server/cloudflare.test-adapter"; +import { runSharedTests } from "./shared.test-suite"; + +const SERVER_SECRET = "test-server-secret"; + +// Check if we should skip tests (e.g., in CI without wrangler) +const SKIP_TESTS = process.env.SKIP_CLOUDFLARE_TESTS === "1"; + +// Skip WebSocket tests in local mode as miniflare has different WebSocket behavior +const SKIP_WEBSOCKET_TESTS = process.env.SKIP_WEBSOCKET_TESTS !== "0"; + +if (SKIP_TESTS) { + describe("devhook cloudflare (skipped)", () => { + it("cloudflare tests are skipped - set SKIP_CLOUDFLARE_TESTS=0 to enable", () => { + expect(true).toBe(true); + }); + }); +} else { + // Run the shared test suite against the Cloudflare worker + // This ensures both local and Cloudflare servers pass the same tests + runSharedTests( + "cloudflare", + createCloudflareServerFactory(SERVER_SECRET), + SERVER_SECRET, + { skipWebSocketTests: SKIP_WEBSOCKET_TESTS } + ); +} diff --git a/packages/devhook/src/devhook.test.ts b/packages/devhook/src/devhook.test.ts index 83a6def..f3fa41e 100644 --- a/packages/devhook/src/devhook.test.ts +++ b/packages/devhook/src/devhook.test.ts @@ -1,12 +1,20 @@ -import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from "vitest"; -import { DevhookClient } from "./client"; -import { createLocalServer } from "./server/local"; +/** + * Devhook test suite. + * + * This file runs the shared test suite against the local server implementation. + * The same tests are also run against the Cloudflare server in cloudflare.test.ts. + */ + +import { describe, it, expect } from "vitest"; import { generateDevhookId, verifyDevhookId } from "./server/crypto"; +import { createLocalServerFactory } from "./server/local.test-adapter"; +import { runSharedTests } from "./shared.test-suite"; const SERVER_SECRET = "test-server-secret"; const CLIENT_SECRET = "test-client-secret"; describe("devhook", () => { + // Crypto tests are standalone - they don't need a server describe("crypto", () => { it("should generate consistent devhook IDs", async () => { const id1 = await generateDevhookId(CLIENT_SECRET, SERVER_SECRET); @@ -54,11 +62,9 @@ describe("devhook", () => { }); it("should use full base36 alphabet for maximum entropy", async () => { - // Generate many IDs and verify we see characters beyond hex (g-z) const ids = new Set(); const allChars = new Set(); - // Generate IDs with different secrets for (let i = 0; i < 100; i++) { const id = await generateDevhookId(`secret-${i}`, SERVER_SECRET); ids.add(id); @@ -67,2122 +73,12 @@ describe("devhook", () => { } } - // All IDs should be unique expect(ids.size).toBe(100); - - // We should see characters beyond hex (g-z) - // With 100 random IDs, it's statistically almost certain we'll see some const beyondHex = [...allChars].filter((c) => c >= "g" && c <= "z"); expect(beyondHex.length).toBeGreaterThan(0); }); }); - describe("local server", () => { - let server: ReturnType; - let serverPort: number; - - beforeAll(async () => { - serverPort = 18080 + Math.floor(Math.random() * 1000); - server = createLocalServer({ - port: serverPort, - secret: SERVER_SECRET, - baseUrl: `http://localhost:${serverPort}`, - mode: "subpath", - }); - await new Promise((resolve) => setTimeout(resolve, 100)); - }); - - afterAll(() => { - server?.close(); - }); - - it("should respond to health check", async () => { - const response = await fetch(`http://localhost:${serverPort}/health`); - expect(response.status).toBe(200); - const body = (await response.json()) as { status: string }; - expect(body.status).toBe("ok"); - }); - - it("should return 404 for unknown routes", async () => { - const response = await fetch(`http://localhost:${serverPort}/unknown`); - expect(response.status).toBe(404); - }); - - it("should return 426 for non-WebSocket connect requests", async () => { - const response = await fetch(`http://localhost:${serverPort}/api/devhook/connect`); - expect(response.status).toBe(426); - }); - }); - - describe("client-server integration", () => { - let server: ReturnType; - let serverPort: number; - let clientConnections: Array<{ dispose: () => void }> = []; - - beforeAll(async () => { - serverPort = 19080 + Math.floor(Math.random() * 1000); - server = createLocalServer({ - port: serverPort, - secret: SERVER_SECRET, - baseUrl: `http://localhost:${serverPort}`, - mode: "subpath", - }); - await new Promise((resolve) => setTimeout(resolve, 100)); - }); - - afterAll(() => { - server?.close(); - }); - - afterEach(() => { - // Clean up any client connections - for (const conn of clientConnections) { - conn.dispose(); - } - clientConnections = []; - }); - - it("should connect and receive public URL", async () => { - let connectedUrl: string | undefined; - let connectedId: string | undefined; - - const client = new DevhookClient({ - serverUrl: `http://localhost:${serverPort}`, - secret: CLIENT_SECRET, - onRequest: async () => new Response("OK"), - onConnect: ({ url, id }) => { - connectedUrl = url; - connectedId = id; - }, - }); - - const disposable = client.connect(); - clientConnections.push(disposable); - - await new Promise((resolve) => setTimeout(resolve, 200)); - - expect(connectedUrl).toBeDefined(); - expect(connectedId).toBeDefined(); - expect(connectedId).toHaveLength(16); - expect(connectedUrl).toContain(connectedId); - expect(connectedUrl).toContain("/devhook/"); - }); - - it("should proxy GET requests", async () => { - let receivedRequest: Request | undefined; - - const client = new DevhookClient({ - serverUrl: `http://localhost:${serverPort}`, - secret: CLIENT_SECRET, - onRequest: async (req) => { - receivedRequest = req; - return new Response("GET response"); - }, - }); - - const disposable = client.connect(); - clientConnections.push(disposable); - await new Promise((resolve) => setTimeout(resolve, 200)); - - const devhookId = await generateDevhookId(CLIENT_SECRET, SERVER_SECRET); - const response = await fetch( - `http://localhost:${serverPort}/devhook/${devhookId}/api/data` - ); - - expect(response.status).toBe(200); - expect(await response.text()).toBe("GET response"); - expect(receivedRequest?.method).toBe("GET"); - expect(new URL(receivedRequest!.url).pathname).toBe("/api/data"); - }); - - it("should proxy POST requests with JSON body", async () => { - let receivedBody: unknown; - - const client = new DevhookClient({ - serverUrl: `http://localhost:${serverPort}`, - secret: CLIENT_SECRET, - onRequest: async (req) => { - receivedBody = await req.json(); - return new Response(JSON.stringify({ received: true }), { - headers: { "content-type": "application/json" }, - }); - }, - }); - - const disposable = client.connect(); - clientConnections.push(disposable); - await new Promise((resolve) => setTimeout(resolve, 200)); - - const devhookId = await generateDevhookId(CLIENT_SECRET, SERVER_SECRET); - const response = await fetch( - `http://localhost:${serverPort}/devhook/${devhookId}/api/submit`, - { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ name: "test", value: 123 }), - } - ); - - expect(response.status).toBe(200); - const body = (await response.json()) as { received: boolean }; - expect(body.received).toBe(true); - expect(receivedBody).toEqual({ name: "test", value: 123 }); - }); - - it("should preserve query parameters", async () => { - let receivedUrl: string | undefined; - - const client = new DevhookClient({ - serverUrl: `http://localhost:${serverPort}`, - secret: CLIENT_SECRET, - onRequest: async (req) => { - receivedUrl = req.url; - return new Response("OK"); - }, - }); - - const disposable = client.connect(); - clientConnections.push(disposable); - await new Promise((resolve) => setTimeout(resolve, 200)); - - const devhookId = await generateDevhookId(CLIENT_SECRET, SERVER_SECRET); - await fetch( - `http://localhost:${serverPort}/devhook/${devhookId}/search?q=test&page=1` - ); - - expect(receivedUrl).toBeDefined(); - const url = new URL(receivedUrl!); - expect(url.searchParams.get("q")).toBe("test"); - expect(url.searchParams.get("page")).toBe("1"); - }); - - it("should preserve request headers", async () => { - let receivedHeaders: Headers | undefined; - - const client = new DevhookClient({ - serverUrl: `http://localhost:${serverPort}`, - secret: CLIENT_SECRET, - onRequest: async (req) => { - receivedHeaders = req.headers; - return new Response("OK"); - }, - }); - - const disposable = client.connect(); - clientConnections.push(disposable); - await new Promise((resolve) => setTimeout(resolve, 200)); - - const devhookId = await generateDevhookId(CLIENT_SECRET, SERVER_SECRET); - await fetch( - `http://localhost:${serverPort}/devhook/${devhookId}/api`, - { - headers: { - "x-custom-header": "custom-value", - "authorization": "Bearer token123", - }, - } - ); - - expect(receivedHeaders?.get("x-custom-header")).toBe("custom-value"); - expect(receivedHeaders?.get("authorization")).toBe("Bearer token123"); - }); - - it("should return response headers from client", async () => { - const client = new DevhookClient({ - serverUrl: `http://localhost:${serverPort}`, - secret: CLIENT_SECRET, - onRequest: async () => { - return new Response("OK", { - headers: { - "x-response-header": "response-value", - "cache-control": "no-cache", - }, - }); - }, - }); - - const disposable = client.connect(); - clientConnections.push(disposable); - await new Promise((resolve) => setTimeout(resolve, 200)); - - const devhookId = await generateDevhookId(CLIENT_SECRET, SERVER_SECRET); - const response = await fetch( - `http://localhost:${serverPort}/devhook/${devhookId}/api` - ); - - expect(response.headers.get("x-response-header")).toBe("response-value"); - expect(response.headers.get("cache-control")).toBe("no-cache"); - }); - - it("should handle different HTTP status codes", async () => { - const client = new DevhookClient({ - serverUrl: `http://localhost:${serverPort}`, - secret: CLIENT_SECRET, - onRequest: async (req) => { - const url = new URL(req.url); - const status = parseInt(url.searchParams.get("status") || "200"); - return new Response(status === 204 ? null : "Response", { status }); - }, - }); - - const disposable = client.connect(); - clientConnections.push(disposable); - await new Promise((resolve) => setTimeout(resolve, 200)); - - const devhookId = await generateDevhookId(CLIENT_SECRET, SERVER_SECRET); - - // Test 201 Created - const res201 = await fetch( - `http://localhost:${serverPort}/devhook/${devhookId}/api?status=201` - ); - expect(res201.status).toBe(201); - - // Test 404 Not Found - const res404 = await fetch( - `http://localhost:${serverPort}/devhook/${devhookId}/api?status=404` - ); - expect(res404.status).toBe(404); - - // Test 500 Internal Server Error - const res500 = await fetch( - `http://localhost:${serverPort}/devhook/${devhookId}/api?status=500` - ); - expect(res500.status).toBe(500); - }); - - it("should return 503 when no client is connected", async () => { - const devhookId = await generateDevhookId("nonexistent-secret", SERVER_SECRET); - - const response = await fetch( - `http://localhost:${serverPort}/devhook/${devhookId}/test` - ); - - expect(response.status).toBe(503); - const body = (await response.json()) as { error: string; message: string }; - expect(body.error).toBe("No client connected"); - expect(body.message).toContain("not currently connected"); - }); - - it("should handle client disconnection gracefully", async () => { - const client = new DevhookClient({ - serverUrl: `http://localhost:${serverPort}`, - secret: "disconnect-test-secret", - onRequest: async () => new Response("OK"), - }); - - const disposable = client.connect(); - await new Promise((resolve) => setTimeout(resolve, 200)); - - // Disconnect the client - disposable.dispose(); - await new Promise((resolve) => setTimeout(resolve, 100)); - - // Try to make a request - should get 503 - const devhookId = await generateDevhookId("disconnect-test-secret", SERVER_SECRET); - const response = await fetch( - `http://localhost:${serverPort}/devhook/${devhookId}/test` - ); - - expect(response.status).toBe(503); - }); - - it("should handle reconnection with same secret", async () => { - let connectCount = 0; - const testSecret = "reconnect-test-" + Math.random(); - - const createClient = () => - new DevhookClient({ - serverUrl: `http://localhost:${serverPort}`, - secret: testSecret, - onRequest: async () => new Response(`Response ${connectCount}`), - onConnect: () => { - connectCount++; - }, - }); - - // First connection - const client1 = createClient(); - const disposable1 = client1.connect(); - clientConnections.push(disposable1); - await new Promise((resolve) => setTimeout(resolve, 200)); - expect(connectCount).toBe(1); - - // Second connection should replace the first - const client2 = createClient(); - const disposable2 = client2.connect(); - clientConnections.push(disposable2); - await new Promise((resolve) => setTimeout(resolve, 200)); - expect(connectCount).toBe(2); - - // Verify the new client handles requests - const devhookId = await generateDevhookId(testSecret, SERVER_SECRET); - const response = await fetch( - `http://localhost:${serverPort}/devhook/${devhookId}/test` - ); - expect(response.status).toBe(200); - expect(await response.text()).toBe("Response 2"); - }); - - it("should handle multiple concurrent clients with different secrets", async () => { - const secret1 = "multi-client-1-" + Math.random(); - const secret2 = "multi-client-2-" + Math.random(); - - const client1 = new DevhookClient({ - serverUrl: `http://localhost:${serverPort}`, - secret: secret1, - onRequest: async () => new Response("Client 1"), - }); - - const client2 = new DevhookClient({ - serverUrl: `http://localhost:${serverPort}`, - secret: secret2, - onRequest: async () => new Response("Client 2"), - }); - - const disposable1 = client1.connect(); - const disposable2 = client2.connect(); - clientConnections.push(disposable1, disposable2); - await new Promise((resolve) => setTimeout(resolve, 200)); - - const devhookId1 = await generateDevhookId(secret1, SERVER_SECRET); - const devhookId2 = await generateDevhookId(secret2, SERVER_SECRET); - - const response1 = await fetch( - `http://localhost:${serverPort}/devhook/${devhookId1}/test` - ); - const response2 = await fetch( - `http://localhost:${serverPort}/devhook/${devhookId2}/test` - ); - - expect(await response1.text()).toBe("Client 1"); - expect(await response2.text()).toBe("Client 2"); - }); - - it("should handle request errors gracefully", async () => { - const client = new DevhookClient({ - serverUrl: `http://localhost:${serverPort}`, - secret: "error-test-secret", - onRequest: async () => { - throw new Error("Intentional error"); - }, - }); - - const disposable = client.connect(); - clientConnections.push(disposable); - await new Promise((resolve) => setTimeout(resolve, 200)); - - const devhookId = await generateDevhookId("error-test-secret", SERVER_SECRET); - const response = await fetch( - `http://localhost:${serverPort}/devhook/${devhookId}/test` - ); - - expect(response.status).toBe(502); - const text = await response.text(); - expect(text).toContain("Intentional error"); - }); - - it("should call onDisconnect when connection is lost", async () => { - let disconnected = false; - - const client = new DevhookClient({ - serverUrl: `http://localhost:${serverPort}`, - secret: "disconnect-callback-test", - onRequest: async () => new Response("OK"), - onDisconnect: () => { - disconnected = true; - }, - }); - - const disposable = client.connect(); - await new Promise((resolve) => setTimeout(resolve, 200)); - - expect(disconnected).toBe(false); - disposable.dispose(); - await new Promise((resolve) => setTimeout(resolve, 100)); - - // Note: onDisconnect may not be called immediately on dispose - // since dispose just closes the socket - }); - - it("should handle large request bodies", async () => { - let receivedSize = 0; - - const client = new DevhookClient({ - serverUrl: `http://localhost:${serverPort}`, - secret: "large-body-test", - onRequest: async (req) => { - const body = await req.text(); - receivedSize = body.length; - return new Response(`Received ${receivedSize} bytes`); - }, - }); - - const disposable = client.connect(); - clientConnections.push(disposable); - await new Promise((resolve) => setTimeout(resolve, 200)); - - const devhookId = await generateDevhookId("large-body-test", SERVER_SECRET); - const largeBody = "x".repeat(100000); // 100KB - - const response = await fetch( - `http://localhost:${serverPort}/devhook/${devhookId}/upload`, - { - method: "POST", - body: largeBody, - } - ); - - expect(response.status).toBe(200); - expect(receivedSize).toBe(100000); - }); - - it("should handle large response bodies", async () => { - const client = new DevhookClient({ - serverUrl: `http://localhost:${serverPort}`, - secret: "large-response-test", - onRequest: async () => { - const largeBody = "y".repeat(100000); // 100KB - return new Response(largeBody); - }, - }); - - const disposable = client.connect(); - clientConnections.push(disposable); - await new Promise((resolve) => setTimeout(resolve, 200)); - - const devhookId = await generateDevhookId("large-response-test", SERVER_SECRET); - const response = await fetch( - `http://localhost:${serverPort}/devhook/${devhookId}/download` - ); - - expect(response.status).toBe(200); - const body = await response.text(); - expect(body.length).toBe(100000); - expect(body).toBe("y".repeat(100000)); - }); - - it("should support webhook signature verification", async () => { - // Simulate a webhook with HMAC signature verification (like GitHub webhooks) - const webhookSecret = "webhook-secret-key"; - let signatureValid = false; - - const client = new DevhookClient({ - serverUrl: `http://localhost:${serverPort}`, - secret: "webhook-test", - onRequest: async (req) => { - // Read the raw body for signature verification - const rawBody = await req.text(); - const signature = req.headers.get("x-hub-signature-256"); - - if (signature) { - // Verify HMAC-SHA256 signature (simplified - real impl would use crypto) - const encoder = new TextEncoder(); - const key = await crypto.subtle.importKey( - "raw", - encoder.encode(webhookSecret), - { name: "HMAC", hash: "SHA-256" }, - false, - ["sign"] - ); - const sig = await crypto.subtle.sign("HMAC", key, encoder.encode(rawBody)); - const expectedSig = "sha256=" + Array.from(new Uint8Array(sig)) - .map(b => b.toString(16).padStart(2, "0")) - .join(""); - - signatureValid = signature === expectedSig; - } - - return new Response(JSON.stringify({ received: true }), { - status: 200, - headers: { "content-type": "application/json" }, - }); - }, - }); - - const disposable = client.connect(); - clientConnections.push(disposable); - await new Promise((resolve) => setTimeout(resolve, 200)); - - const devhookId = await generateDevhookId("webhook-test", SERVER_SECRET); - - // Create a webhook payload with signature - const payload = JSON.stringify({ event: "push", repository: "test/repo" }); - const encoder = new TextEncoder(); - const key = await crypto.subtle.importKey( - "raw", - encoder.encode(webhookSecret), - { name: "HMAC", hash: "SHA-256" }, - false, - ["sign"] - ); - const sig = await crypto.subtle.sign("HMAC", key, encoder.encode(payload)); - const signature = "sha256=" + Array.from(new Uint8Array(sig)) - .map(b => b.toString(16).padStart(2, "0")) - .join(""); - - const response = await fetch( - `http://localhost:${serverPort}/devhook/${devhookId}/webhook/github`, - { - method: "POST", - headers: { - "content-type": "application/json", - "x-hub-signature-256": signature, - "x-github-event": "push", - }, - body: payload, - } - ); - - expect(response.status).toBe(200); - expect(signatureValid).toBe(true); - }); - - it("should handle webhook-style POST with form data", async () => { - let receivedContentType: string | null = null; - let receivedBody: string | undefined; - - const client = new DevhookClient({ - serverUrl: `http://localhost:${serverPort}`, - secret: "form-webhook-test", - onRequest: async (req) => { - receivedContentType = req.headers.get("content-type"); - receivedBody = await req.text(); - return new Response("OK"); - }, - }); - - const disposable = client.connect(); - clientConnections.push(disposable); - await new Promise((resolve) => setTimeout(resolve, 200)); - - const devhookId = await generateDevhookId("form-webhook-test", SERVER_SECRET); - - const formData = new URLSearchParams(); - formData.append("payload", JSON.stringify({ action: "opened" })); - formData.append("token", "abc123"); - - const response = await fetch( - `http://localhost:${serverPort}/devhook/${devhookId}/webhook`, - { - method: "POST", - headers: { - "content-type": "application/x-www-form-urlencoded", - }, - body: formData.toString(), - } - ); - - expect(response.status).toBe(200); - expect(receivedContentType).toBe("application/x-www-form-urlencoded"); - expect(receivedBody).toContain("payload"); - expect(receivedBody).toContain("token"); - }); - }); - - describe("webhook functionality", () => { - let server: ReturnType; - let serverPort: number; - let clientConnections: Array<{ dispose: () => void }> = []; - - beforeAll(async () => { - serverPort = 22080 + Math.floor(Math.random() * 1000); - server = createLocalServer({ - port: serverPort, - secret: SERVER_SECRET, - baseUrl: `http://localhost:${serverPort}`, - mode: "subpath", - }); - await new Promise((resolve) => setTimeout(resolve, 100)); - }); - - afterAll(() => { - server?.close(); - }); - - afterEach(() => { - for (const conn of clientConnections) { - conn.dispose(); - } - clientConnections = []; - }); - - // Helper to create HMAC-SHA256 signature - async function createSignature(payload: string, secret: string): Promise { - const encoder = new TextEncoder(); - const key = await crypto.subtle.importKey( - "raw", - encoder.encode(secret), - { name: "HMAC", hash: "SHA-256" }, - false, - ["sign"] - ); - const sig = await crypto.subtle.sign("HMAC", key, encoder.encode(payload)); - return "sha256=" + Array.from(new Uint8Array(sig)) - .map(b => b.toString(16).padStart(2, "0")) - .join(""); - } - - // Helper to verify HMAC-SHA256 signature - async function verifySignature(payload: string, signature: string, secret: string): Promise { - const expected = await createSignature(payload, secret); - return signature === expected; - } - - it("should handle concurrent webhooks to the same client", async () => { - const receivedWebhooks: Array<{ id: string; body: string; timestamp: number }> = []; - const webhookSecret = "concurrent-test-secret"; - - const client = new DevhookClient({ - serverUrl: `http://localhost:${serverPort}`, - secret: "concurrent-webhook-client", - onRequest: async (req) => { - const body = await req.text(); - const id = req.headers.get("x-webhook-id") || "unknown"; - - // Simulate some processing time - await new Promise((resolve) => setTimeout(resolve, 50 + Math.random() * 50)); - - receivedWebhooks.push({ - id, - body, - timestamp: Date.now(), - }); - - return new Response(JSON.stringify({ received: id }), { - status: 200, - headers: { "content-type": "application/json" }, - }); - }, - }); - - const disposable = client.connect(); - clientConnections.push(disposable); - await new Promise((resolve) => setTimeout(resolve, 200)); - - const devhookId = await generateDevhookId("concurrent-webhook-client", SERVER_SECRET); - - // Send 10 webhooks concurrently - const webhookCount = 10; - const promises = Array.from({ length: webhookCount }, async (_, i) => { - const payload = JSON.stringify({ event: "test", index: i }); - const response = await fetch( - `http://localhost:${serverPort}/devhook/${devhookId}/webhook`, - { - method: "POST", - headers: { - "content-type": "application/json", - "x-webhook-id": `webhook-${i}`, - }, - body: payload, - } - ); - return { index: i, status: response.status, body: await response.json() }; - }); - - const results = await Promise.all(promises); - - // All requests should succeed - expect(results.every(r => r.status === 200)).toBe(true); - - // All webhooks should be received - expect(receivedWebhooks.length).toBe(webhookCount); - - // Each webhook should have unique ID - const receivedIds = new Set(receivedWebhooks.map(w => w.id)); - expect(receivedIds.size).toBe(webhookCount); - }); - - it("should handle concurrent webhooks to different clients", async () => { - const clientResults: Map = new Map(); - - // Create 3 clients with different secrets - const clientSecrets = ["client-a", "client-b", "client-c"]; - - for (const secret of clientSecrets) { - clientResults.set(secret, []); - - const client = new DevhookClient({ - serverUrl: `http://localhost:${serverPort}`, - secret, - onRequest: async (req) => { - const body = await req.json() as { clientId: string; webhookId: string }; - clientResults.get(secret)!.push(body.webhookId); - - // Simulate processing - await new Promise((resolve) => setTimeout(resolve, 30)); - - return new Response(JSON.stringify({ client: secret, received: body.webhookId }), { - status: 200, - headers: { "content-type": "application/json" }, - }); - }, - }); - - const disposable = client.connect(); - clientConnections.push(disposable); - } - - await new Promise((resolve) => setTimeout(resolve, 300)); - - // Send webhooks to all clients concurrently - const promises: Promise<{ client: string; webhookId: string; status: number }>[] = []; - - for (const secret of clientSecrets) { - const devhookId = await generateDevhookId(secret, SERVER_SECRET); - - // Send 5 webhooks to each client - for (let i = 0; i < 5; i++) { - const webhookId = `${secret}-webhook-${i}`; - promises.push( - fetch( - `http://localhost:${serverPort}/devhook/${devhookId}/webhook`, - { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ clientId: secret, webhookId }), - } - ).then(async (response) => ({ - client: secret, - webhookId, - status: response.status, - })) - ); - } - } - - const results = await Promise.all(promises); - - // All requests should succeed - expect(results.every(r => r.status === 200)).toBe(true); - - // Each client should receive exactly 5 webhooks - for (const secret of clientSecrets) { - const received = clientResults.get(secret)!; - expect(received.length).toBe(5); - - // Verify the webhooks belong to this client - expect(received.every(id => id.startsWith(secret))).toBe(true); - } - }); - - it("should preserve request body integrity for signature verification with concurrent requests", async () => { - const webhookSecret = "integrity-test-secret"; - const verificationResults: Array<{ id: string; valid: boolean }> = []; - - const client = new DevhookClient({ - serverUrl: `http://localhost:${serverPort}`, - secret: "integrity-client", - onRequest: async (req) => { - const rawBody = await req.text(); - const signature = req.headers.get("x-signature"); - const webhookId = req.headers.get("x-webhook-id") || "unknown"; - - const isValid = signature ? await verifySignature(rawBody, signature, webhookSecret) : false; - - verificationResults.push({ id: webhookId, valid: isValid }); - - return new Response(JSON.stringify({ verified: isValid }), { - status: isValid ? 200 : 401, - headers: { "content-type": "application/json" }, - }); - }, - }); - - const disposable = client.connect(); - clientConnections.push(disposable); - await new Promise((resolve) => setTimeout(resolve, 200)); - - const devhookId = await generateDevhookId("integrity-client", SERVER_SECRET); - - // Send 20 webhooks concurrently with different payloads - const webhookCount = 20; - const promises = Array.from({ length: webhookCount }, async (_, i) => { - const payload = JSON.stringify({ - event: "test", - index: i, - data: `payload-data-${i}-${Math.random()}`, - timestamp: Date.now(), - }); - const signature = await createSignature(payload, webhookSecret); - - const response = await fetch( - `http://localhost:${serverPort}/devhook/${devhookId}/webhook`, - { - method: "POST", - headers: { - "content-type": "application/json", - "x-signature": signature, - "x-webhook-id": `webhook-${i}`, - }, - body: payload, - } - ); - return { index: i, status: response.status }; - }); - - const results = await Promise.all(promises); - - // All requests should succeed with valid signatures - expect(results.every(r => r.status === 200)).toBe(true); - expect(verificationResults.length).toBe(webhookCount); - expect(verificationResults.every(r => r.valid)).toBe(true); - }); - - it("should handle webhooks with varying payload sizes concurrently", async () => { - const receivedSizes: number[] = []; - - const client = new DevhookClient({ - serverUrl: `http://localhost:${serverPort}`, - secret: "varying-size-client", - onRequest: async (req) => { - const body = await req.text(); - receivedSizes.push(body.length); - return new Response(JSON.stringify({ size: body.length }), { - status: 200, - headers: { "content-type": "application/json" }, - }); - }, - }); - - const disposable = client.connect(); - clientConnections.push(disposable); - await new Promise((resolve) => setTimeout(resolve, 200)); - - const devhookId = await generateDevhookId("varying-size-client", SERVER_SECRET); - - // Send webhooks with varying sizes: 100B, 1KB, 10KB, 50KB - const sizes = [100, 1000, 10000, 50000]; - const promises = sizes.flatMap((size, sizeIndex) => - // Send 3 webhooks of each size - Array.from({ length: 3 }, async (_, i) => { - const payload = JSON.stringify({ - index: sizeIndex * 3 + i, - data: "x".repeat(size), - }); - - const response = await fetch( - `http://localhost:${serverPort}/devhook/${devhookId}/webhook`, - { - method: "POST", - headers: { "content-type": "application/json" }, - body: payload, - } - ); - const result = await response.json() as { size: number }; - return { expectedMinSize: size, actualSize: result.size, status: response.status }; - }) - ); - - const results = await Promise.all(promises); - - // All requests should succeed - expect(results.every(r => r.status === 200)).toBe(true); - - // All sizes should be received correctly (payload includes JSON overhead) - expect(results.every(r => r.actualSize >= r.expectedMinSize)).toBe(true); - expect(receivedSizes.length).toBe(12); // 4 sizes * 3 requests each - }); - - it("should handle rapid sequential webhooks", async () => { - const receivedOrder: number[] = []; - - const client = new DevhookClient({ - serverUrl: `http://localhost:${serverPort}`, - secret: "sequential-client", - onRequest: async (req) => { - const body = await req.json() as { index: number }; - receivedOrder.push(body.index); - return new Response(JSON.stringify({ received: body.index }), { - status: 200, - headers: { "content-type": "application/json" }, - }); - }, - }); - - const disposable = client.connect(); - clientConnections.push(disposable); - await new Promise((resolve) => setTimeout(resolve, 200)); - - const devhookId = await generateDevhookId("sequential-client", SERVER_SECRET); - - // Send 50 webhooks as fast as possible (but sequentially) - const webhookCount = 50; - for (let i = 0; i < webhookCount; i++) { - const response = await fetch( - `http://localhost:${serverPort}/devhook/${devhookId}/webhook`, - { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ index: i }), - } - ); - expect(response.status).toBe(200); - } - - // All webhooks should be received in order - expect(receivedOrder.length).toBe(webhookCount); - expect(receivedOrder).toEqual(Array.from({ length: webhookCount }, (_, i) => i)); - }); - - it("should handle webhook with slow processing without blocking others", async () => { - const processingTimes: Array<{ id: string; duration: number }> = []; - - const client = new DevhookClient({ - serverUrl: `http://localhost:${serverPort}`, - secret: "slow-processing-client", - onRequest: async (req) => { - const start = Date.now(); - const body = await req.json() as { id: string; delay: number }; - - // Simulate variable processing time - await new Promise((resolve) => setTimeout(resolve, body.delay)); - - processingTimes.push({ id: body.id, duration: Date.now() - start }); - - return new Response(JSON.stringify({ processed: body.id }), { - status: 200, - headers: { "content-type": "application/json" }, - }); - }, - }); - - const disposable = client.connect(); - clientConnections.push(disposable); - await new Promise((resolve) => setTimeout(resolve, 200)); - - const devhookId = await generateDevhookId("slow-processing-client", SERVER_SECRET); - - const startTime = Date.now(); - - // Send webhooks with different delays concurrently - // One slow (500ms), several fast (10ms) - const promises = [ - fetch(`http://localhost:${serverPort}/devhook/${devhookId}/webhook`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ id: "slow", delay: 500 }), - }), - ...Array.from({ length: 5 }, (_, i) => - fetch(`http://localhost:${serverPort}/devhook/${devhookId}/webhook`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ id: `fast-${i}`, delay: 10 }), - }) - ), - ]; - - const results = await Promise.all(promises); - const totalTime = Date.now() - startTime; - - // All requests should succeed - expect(results.every(r => r.status === 200)).toBe(true); - - // Fast requests should not be blocked by the slow one - // Total time should be closer to 500ms (slow request) than 500 + 5*10 = 550ms - // Allow some overhead but it should complete in reasonable time - expect(totalTime).toBeLessThan(1000); - - // All webhooks should be processed - expect(processingTimes.length).toBe(6); - }); - - it("should handle GitHub-style webhook with all headers", async () => { - let receivedHeaders: Record = {}; - let receivedBody: string | undefined; - const webhookSecret = "github-webhook-secret"; - - const client = new DevhookClient({ - serverUrl: `http://localhost:${serverPort}`, - secret: "github-style-client", - onRequest: async (req) => { - receivedBody = await req.text(); - receivedHeaders = { - "x-github-event": req.headers.get("x-github-event"), - "x-github-delivery": req.headers.get("x-github-delivery"), - "x-hub-signature-256": req.headers.get("x-hub-signature-256"), - "content-type": req.headers.get("content-type"), - "user-agent": req.headers.get("user-agent"), - }; - - // Verify signature - const signature = req.headers.get("x-hub-signature-256"); - if (signature && receivedBody) { - const isValid = await verifySignature(receivedBody, signature, webhookSecret); - if (!isValid) { - return new Response("Invalid signature", { status: 401 }); - } - } - - return new Response(JSON.stringify({ success: true }), { - status: 200, - headers: { "content-type": "application/json" }, - }); - }, - }); - - const disposable = client.connect(); - clientConnections.push(disposable); - await new Promise((resolve) => setTimeout(resolve, 200)); - - const devhookId = await generateDevhookId("github-style-client", SERVER_SECRET); - - const payload = JSON.stringify({ - action: "opened", - pull_request: { - number: 42, - title: "Test PR", - }, - repository: { - full_name: "owner/repo", - }, - }); - - const signature = await createSignature(payload, webhookSecret); - const deliveryId = crypto.randomUUID(); - - const response = await fetch( - `http://localhost:${serverPort}/devhook/${devhookId}/webhook/github`, - { - method: "POST", - headers: { - "content-type": "application/json", - "x-github-event": "pull_request", - "x-github-delivery": deliveryId, - "x-hub-signature-256": signature, - "user-agent": "GitHub-Hookshot/test", - }, - body: payload, - } - ); - - expect(response.status).toBe(200); - expect(receivedHeaders["x-github-event"]).toBe("pull_request"); - expect(receivedHeaders["x-github-delivery"]).toBe(deliveryId); - expect(receivedHeaders["x-hub-signature-256"]).toBe(signature); - expect(receivedHeaders["content-type"]).toBe("application/json"); - expect(receivedHeaders["user-agent"]).toBe("GitHub-Hookshot/test"); - expect(receivedBody).toBe(payload); - }); - - it("should handle Stripe-style webhook", async () => { - const stripeSecret = "whsec_test_secret"; - let receivedEvent: unknown; - let signatureValid = false; - - const client = new DevhookClient({ - serverUrl: `http://localhost:${serverPort}`, - secret: "stripe-style-client", - onRequest: async (req) => { - const rawBody = await req.text(); - const signature = req.headers.get("stripe-signature"); - - // Stripe uses a different signature format: t=timestamp,v1=signature - if (signature) { - const parts = signature.split(","); - const timestamp = parts.find(p => p.startsWith("t="))?.slice(2); - const v1Sig = parts.find(p => p.startsWith("v1="))?.slice(3); - - if (timestamp && v1Sig) { - const signedPayload = `${timestamp}.${rawBody}`; - const expectedSig = await createSignature(signedPayload, stripeSecret); - // Remove the "sha256=" prefix for comparison - signatureValid = v1Sig === expectedSig.slice(7); - } - } - - if (!signatureValid) { - return new Response("Invalid signature", { status: 400 }); - } - - receivedEvent = JSON.parse(rawBody); - return new Response(JSON.stringify({ received: true }), { - status: 200, - headers: { "content-type": "application/json" }, - }); - }, - }); - - const disposable = client.connect(); - clientConnections.push(disposable); - await new Promise((resolve) => setTimeout(resolve, 200)); - - const devhookId = await generateDevhookId("stripe-style-client", SERVER_SECRET); - - const payload = JSON.stringify({ - id: "evt_test_123", - type: "payment_intent.succeeded", - data: { - object: { - id: "pi_test_123", - amount: 2000, - currency: "usd", - }, - }, - }); - - const timestamp = Math.floor(Date.now() / 1000).toString(); - const signedPayload = `${timestamp}.${payload}`; - const sig = await createSignature(signedPayload, stripeSecret); - const stripeSignature = `t=${timestamp},v1=${sig.slice(7)}`; // Remove "sha256=" prefix - - const response = await fetch( - `http://localhost:${serverPort}/devhook/${devhookId}/webhook/stripe`, - { - method: "POST", - headers: { - "content-type": "application/json", - "stripe-signature": stripeSignature, - }, - body: payload, - } - ); - - expect(response.status).toBe(200); - expect(signatureValid).toBe(true); - expect(receivedEvent).toEqual(JSON.parse(payload)); - }); - - it("should handle webhook retry scenarios", async () => { - let attemptCount = 0; - const maxAttempts = 3; - - const client = new DevhookClient({ - serverUrl: `http://localhost:${serverPort}`, - secret: "retry-client", - onRequest: async (req) => { - attemptCount++; - const body = await req.json() as { attempt: number }; - - // Fail first 2 attempts, succeed on 3rd - if (attemptCount < maxAttempts) { - return new Response("Service unavailable", { status: 503 }); - } - - return new Response(JSON.stringify({ success: true, attempts: attemptCount }), { - status: 200, - headers: { "content-type": "application/json" }, - }); - }, - }); - - const disposable = client.connect(); - clientConnections.push(disposable); - await new Promise((resolve) => setTimeout(resolve, 200)); - - const devhookId = await generateDevhookId("retry-client", SERVER_SECRET); - - // Simulate webhook retries - let lastResponse: Response | undefined; - for (let attempt = 1; attempt <= maxAttempts; attempt++) { - lastResponse = await fetch( - `http://localhost:${serverPort}/devhook/${devhookId}/webhook`, - { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ attempt }), - } - ); - - if (lastResponse.status === 200) { - break; - } - - // Wait before retry (simulating webhook provider behavior) - await new Promise((resolve) => setTimeout(resolve, 50)); - } - - expect(lastResponse?.status).toBe(200); - expect(attemptCount).toBe(maxAttempts); - }); - - it("should handle binary webhook payloads", async () => { - let receivedBytes: Uint8Array | undefined; - - const client = new DevhookClient({ - serverUrl: `http://localhost:${serverPort}`, - secret: "binary-client", - onRequest: async (req) => { - const buffer = await req.arrayBuffer(); - receivedBytes = new Uint8Array(buffer); - return new Response(JSON.stringify({ size: receivedBytes.length }), { - status: 200, - headers: { "content-type": "application/json" }, - }); - }, - }); - - const disposable = client.connect(); - clientConnections.push(disposable); - await new Promise((resolve) => setTimeout(resolve, 200)); - - const devhookId = await generateDevhookId("binary-client", SERVER_SECRET); - - // Create binary payload - const binaryData = new Uint8Array([0x00, 0x01, 0x02, 0xFF, 0xFE, 0xFD, 0x00, 0x00]); - - const response = await fetch( - `http://localhost:${serverPort}/devhook/${devhookId}/webhook`, - { - method: "POST", - headers: { "content-type": "application/octet-stream" }, - body: binaryData, - } - ); - - expect(response.status).toBe(200); - expect(receivedBytes).toBeDefined(); - expect(receivedBytes!.length).toBe(binaryData.length); - expect(Array.from(receivedBytes!)).toEqual(Array.from(binaryData)); - }); - }); - - describe("websocket proxying", () => { - let server: ReturnType; - let serverPort: number; - let clientConnections: Array<{ dispose: () => void }> = []; - - beforeAll(async () => { - serverPort = 22080 + Math.floor(Math.random() * 1000); - server = createLocalServer({ - port: serverPort, - secret: SERVER_SECRET, - baseUrl: `http://localhost:${serverPort}`, - mode: "subpath", - }); - await new Promise((resolve) => setTimeout(resolve, 100)); - }); - - afterAll(() => { - server?.close(); - }); - - afterEach(() => { - for (const conn of clientConnections) { - conn.dispose(); - } - clientConnections = []; - }); - - it("should proxy WebSocket connections", async () => { - const receivedMessages: string[] = []; - let localWsConnected = false; - - // Create a simple WebSocket server to simulate the local target FIRST - const { WebSocketServer, WebSocket: WsClient } = await import("ws"); - const localWsServer = new WebSocketServer({ port: 0 }); - const localWsPort = (localWsServer.address() as { port: number }).port; - - localWsServer.on("connection", (ws) => { - localWsConnected = true; - ws.on("message", (data) => { - receivedMessages.push(data.toString()); - // Echo back the message - ws.send(`echo: ${data.toString()}`); - }); - }); - - const client = new DevhookClient({ - serverUrl: `http://localhost:${serverPort}`, - secret: "ws-test-client", - transformUrl: (url) => { - url.host = `localhost:${localWsPort}`; - return url; - }, - onRequest: async (req) => { - const url = new URL(req.url); - url.host = `localhost:${localWsPort}`; - return fetch(new Request(url.toString(), req)); - }, - }); - - const disposable = client.connect(); - clientConnections.push(disposable); - await new Promise((resolve) => setTimeout(resolve, 200)); - - const devhookId = await generateDevhookId("ws-test-client", SERVER_SECRET); - - // Connect to the devhook URL via WebSocket - const externalWs = new WsClient( - `ws://localhost:${serverPort}/devhook/${devhookId}/ws` - ); - - const externalMessages: string[] = []; - await new Promise((resolve, reject) => { - externalWs.on("open", () => { - externalWs.send("hello from external"); - }); - - externalWs.on("message", (data) => { - externalMessages.push(data.toString()); - if (externalMessages.length >= 1) { - resolve(); - } - }); - - externalWs.on("error", reject); - - setTimeout(() => reject(new Error("Timeout waiting for WebSocket")), 5000); - }); - - expect(localWsConnected).toBe(true); - expect(receivedMessages).toContain("hello from external"); - expect(externalMessages).toContain("echo: hello from external"); - - externalWs.close(); - localWsServer.close(); - }); - - it("should handle WebSocket close from external client", async () => { - let localWsClosed = false; - let closeCode: number | undefined; - - const { WebSocketServer, WebSocket: WsClient } = await import("ws"); - const localWsServer = new WebSocketServer({ port: 0 }); - const localWsPort = (localWsServer.address() as { port: number }).port; - - localWsServer.on("connection", (ws) => { - ws.on("close", (code) => { - localWsClosed = true; - closeCode = code; - }); - }); - - const client = new DevhookClient({ - serverUrl: `http://localhost:${serverPort}`, - secret: "ws-close-test", - transformUrl: (url) => { - url.host = `localhost:${localWsPort}`; - return url; - }, - onRequest: async (req) => { - const url = new URL(req.url); - url.host = `localhost:${localWsPort}`; - return fetch(new Request(url.toString(), req)); - }, - }); - - const disposable = client.connect(); - clientConnections.push(disposable); - await new Promise((resolve) => setTimeout(resolve, 200)); - - const devhookId = await generateDevhookId("ws-close-test", SERVER_SECRET); - - const externalWs = new WsClient( - `ws://localhost:${serverPort}/devhook/${devhookId}/ws` - ); - - await new Promise((resolve, reject) => { - externalWs.on("open", () => { - // Close with a specific code - externalWs.close(1000, "Normal closure"); - }); - - externalWs.on("close", () => { - // Give time for the close to propagate - setTimeout(resolve, 100); - }); - - externalWs.on("error", reject); - setTimeout(() => reject(new Error("Timeout")), 5000); - }); - - expect(localWsClosed).toBe(true); - expect(closeCode).toBe(1000); - - localWsServer.close(); - }); - - it("should handle WebSocket close from local server", async () => { - let externalWsClosed = false; - let externalCloseCode: number | undefined; - - const { WebSocketServer, WebSocket: WsClient } = await import("ws"); - const localWsServer = new WebSocketServer({ port: 0 }); - const localWsPort = (localWsServer.address() as { port: number }).port; - - localWsServer.on("connection", (ws) => { - // Close immediately after connection - setTimeout(() => ws.close(1001, "Going away"), 50); - }); - - const client = new DevhookClient({ - serverUrl: `http://localhost:${serverPort}`, - secret: "ws-server-close-test", - transformUrl: (url) => { - url.host = `localhost:${localWsPort}`; - return url; - }, - onRequest: async (req) => { - const url = new URL(req.url); - url.host = `localhost:${localWsPort}`; - return fetch(new Request(url.toString(), req)); - }, - }); - - const disposable = client.connect(); - clientConnections.push(disposable); - await new Promise((resolve) => setTimeout(resolve, 200)); - - const devhookId = await generateDevhookId("ws-server-close-test", SERVER_SECRET); - - const externalWs = new WsClient( - `ws://localhost:${serverPort}/devhook/${devhookId}/ws` - ); - - await new Promise((resolve, reject) => { - externalWs.on("close", (code) => { - externalWsClosed = true; - externalCloseCode = code; - resolve(); - }); - - externalWs.on("error", reject); - setTimeout(() => reject(new Error("Timeout")), 5000); - }); - - expect(externalWsClosed).toBe(true); - expect(externalCloseCode).toBe(1001); - - localWsServer.close(); - }); - - it("should handle binary WebSocket messages", async () => { - const receivedBinary: Uint8Array[] = []; - - const { WebSocketServer, WebSocket: WsClient } = await import("ws"); - const localWsServer = new WebSocketServer({ port: 0 }); - const localWsPort = (localWsServer.address() as { port: number }).port; - - localWsServer.on("connection", (ws) => { - ws.on("message", (data) => { - const bytes = data instanceof Buffer ? new Uint8Array(data) : new Uint8Array(data as ArrayBuffer); - receivedBinary.push(bytes); - // Echo back - ws.send(bytes); - }); - }); - - const client = new DevhookClient({ - serverUrl: `http://localhost:${serverPort}`, - secret: "ws-binary-test", - transformUrl: (url) => { - url.host = `localhost:${localWsPort}`; - return url; - }, - onRequest: async (req) => { - const url = new URL(req.url); - url.host = `localhost:${localWsPort}`; - return fetch(new Request(url.toString(), req)); - }, - }); - - const disposable = client.connect(); - clientConnections.push(disposable); - await new Promise((resolve) => setTimeout(resolve, 200)); - - const devhookId = await generateDevhookId("ws-binary-test", SERVER_SECRET); - - const externalWs = new WsClient( - `ws://localhost:${serverPort}/devhook/${devhookId}/ws` - ); - - const receivedEcho: Uint8Array[] = []; - const testData = new Uint8Array([0x00, 0x01, 0xFF, 0xFE, 0x42]); - - await new Promise((resolve, reject) => { - externalWs.on("open", () => { - externalWs.send(testData); - }); - - externalWs.on("message", (data) => { - const bytes = data instanceof Buffer ? new Uint8Array(data) : new Uint8Array(data as ArrayBuffer); - receivedEcho.push(bytes); - resolve(); - }); - - externalWs.on("error", reject); - setTimeout(() => reject(new Error("Timeout")), 5000); - }); - - // Wait a bit for any stray messages to settle - await new Promise((r) => setTimeout(r, 50)); - - expect(receivedBinary.length).toBeGreaterThanOrEqual(1); - expect(Array.from(receivedBinary[0]!)).toEqual(Array.from(testData)); - expect(receivedEcho.length).toBeGreaterThanOrEqual(1); - expect(Array.from(receivedEcho[0]!)).toEqual(Array.from(testData)); - - externalWs.close(); - localWsServer.close(); - }); - - it("should return 503 when no client is connected for WebSocket", async () => { - const { WebSocket: WsClient } = await import("ws"); - const devhookId = await generateDevhookId("nonexistent-ws", SERVER_SECRET); - - const externalWs = new WsClient( - `ws://localhost:${serverPort}/devhook/${devhookId}/ws` - ); - - await new Promise((resolve) => { - externalWs.on("error", () => { - // Expected - connection should fail - resolve(); - }); - - externalWs.on("open", () => { - // This shouldn't happen - externalWs.close(); - resolve(); - }); - - setTimeout(resolve, 1000); - }); - - expect(externalWs.readyState).not.toBe(WsClient.OPEN); - }); - - it("should handle multiple concurrent WebSocket connections to the same client", async () => { - const messagesPerConnection: Map = new Map(); - const localConnections: Set = new Set(); - let connectionCounter = 0; - - const { WebSocketServer, WebSocket: WsClient } = await import("ws"); - const localWsServer = new WebSocketServer({ port: 0 }); - const localWsPort = (localWsServer.address() as { port: number }).port; - - localWsServer.on("connection", (ws) => { - const connId = connectionCounter++; - localConnections.add(connId); - messagesPerConnection.set(connId, []); - - ws.on("message", (data) => { - const msg = data.toString(); - messagesPerConnection.get(connId)!.push(msg); - // Echo back with connection ID - ws.send(`conn${connId}: ${msg}`); - }); - - ws.on("close", () => { - localConnections.delete(connId); - }); - }); - - const client = new DevhookClient({ - serverUrl: `http://localhost:${serverPort}`, - secret: "ws-concurrent-test", - transformUrl: (url) => { - url.host = `localhost:${localWsPort}`; - return url; - }, - onRequest: async (req) => { - const url = new URL(req.url); - url.host = `localhost:${localWsPort}`; - return fetch(new Request(url.toString(), req)); - }, - }); - - const disposable = client.connect(); - clientConnections.push(disposable); - await new Promise((resolve) => setTimeout(resolve, 200)); - - const devhookId = await generateDevhookId("ws-concurrent-test", SERVER_SECRET); - - // Create 5 concurrent WebSocket connections - const numConnections = 5; - const externalWsConnections: InstanceType[] = []; - const receivedMessages: Map = new Map(); - - for (let i = 0; i < numConnections; i++) { - receivedMessages.set(i, []); - const ws = new WsClient( - `ws://localhost:${serverPort}/devhook/${devhookId}/ws${i}` - ); - externalWsConnections.push(ws); - } - - // Wait for all connections to open - await Promise.all( - externalWsConnections.map( - (ws, i) => - new Promise((resolve, reject) => { - ws.on("open", resolve); - ws.on("error", reject); - ws.on("message", (data) => { - receivedMessages.get(i)!.push(data.toString()); - }); - setTimeout(() => reject(new Error(`Connection ${i} timeout`)), 5000); - }) - ) - ); - - expect(localConnections.size).toBe(numConnections); - - // Send messages from each connection - for (let i = 0; i < numConnections; i++) { - externalWsConnections[i]!.send(`hello from ws${i}`); - } - - // Wait for responses - await new Promise((resolve) => setTimeout(resolve, 200)); - - // Verify each connection received its own response - for (let i = 0; i < numConnections; i++) { - expect(receivedMessages.get(i)!.length).toBeGreaterThanOrEqual(1); - expect(receivedMessages.get(i)![0]).toContain(`hello from ws${i}`); - } - - // Verify local server received all messages - const allLocalMessages = Array.from(messagesPerConnection.values()).flat(); - expect(allLocalMessages.length).toBe(numConnections); - - // Close all connections - for (const ws of externalWsConnections) { - ws.close(); - } - - await new Promise((resolve) => setTimeout(resolve, 100)); - localWsServer.close(); - }); - - it("should handle WebSocket connections from multiple devhook clients simultaneously", async () => { - const { WebSocketServer, WebSocket: WsClient } = await import("ws"); - - // Create two separate local WebSocket servers for two different devhook clients - const localWsServer1 = new WebSocketServer({ port: 0 }); - const localWsPort1 = (localWsServer1.address() as { port: number }).port; - const localWsServer2 = new WebSocketServer({ port: 0 }); - const localWsPort2 = (localWsServer2.address() as { port: number }).port; - - const messages1: string[] = []; - const messages2: string[] = []; - - localWsServer1.on("connection", (ws) => { - ws.on("message", (data) => { - messages1.push(data.toString()); - ws.send(`server1: ${data.toString()}`); - }); - }); - - localWsServer2.on("connection", (ws) => { - ws.on("message", (data) => { - messages2.push(data.toString()); - ws.send(`server2: ${data.toString()}`); - }); - }); - - // Create two devhook clients with different secrets - const client1 = new DevhookClient({ - serverUrl: `http://localhost:${serverPort}`, - secret: "ws-multi-client-1", - transformUrl: (url) => { - url.host = `localhost:${localWsPort1}`; - return url; - }, - onRequest: async (req) => { - const url = new URL(req.url); - url.host = `localhost:${localWsPort1}`; - return fetch(new Request(url.toString(), req)); - }, - }); - - const client2 = new DevhookClient({ - serverUrl: `http://localhost:${serverPort}`, - secret: "ws-multi-client-2", - transformUrl: (url) => { - url.host = `localhost:${localWsPort2}`; - return url; - }, - onRequest: async (req) => { - const url = new URL(req.url); - url.host = `localhost:${localWsPort2}`; - return fetch(new Request(url.toString(), req)); - }, - }); - - const disposable1 = client1.connect(); - const disposable2 = client2.connect(); - clientConnections.push(disposable1, disposable2); - await new Promise((resolve) => setTimeout(resolve, 200)); - - const devhookId1 = await generateDevhookId("ws-multi-client-1", SERVER_SECRET); - const devhookId2 = await generateDevhookId("ws-multi-client-2", SERVER_SECRET); - - // Connect external WebSockets to each devhook - const externalWs1 = new WsClient( - `ws://localhost:${serverPort}/devhook/${devhookId1}/ws` - ); - const externalWs2 = new WsClient( - `ws://localhost:${serverPort}/devhook/${devhookId2}/ws` - ); - - const received1: string[] = []; - const received2: string[] = []; - - // Wait for both connections to open and set up message handlers - await Promise.all([ - new Promise((resolve, reject) => { - externalWs1.on("open", resolve); - externalWs1.on("error", reject); - externalWs1.on("message", (data) => received1.push(data.toString())); - setTimeout(() => reject(new Error("Timeout ws1")), 5000); - }), - new Promise((resolve, reject) => { - externalWs2.on("open", resolve); - externalWs2.on("error", reject); - externalWs2.on("message", (data) => received2.push(data.toString())); - setTimeout(() => reject(new Error("Timeout ws2")), 5000); - }), - ]); - - // Send messages to each devhook - externalWs1.send("message to client 1"); - externalWs2.send("message to client 2"); - - // Wait for responses - await new Promise((resolve) => setTimeout(resolve, 200)); - - // Verify messages were routed correctly - expect(messages1).toContain("message to client 1"); - expect(messages2).toContain("message to client 2"); - expect(messages1).not.toContain("message to client 2"); - expect(messages2).not.toContain("message to client 1"); - - // Verify responses came from correct servers - expect(received1.length).toBeGreaterThanOrEqual(1); - expect(received1[0]).toContain("server1:"); - expect(received2.length).toBeGreaterThanOrEqual(1); - expect(received2[0]).toContain("server2:"); - - // Cleanup - externalWs1.close(); - externalWs2.close(); - await new Promise((resolve) => setTimeout(resolve, 100)); - localWsServer1.close(); - localWsServer2.close(); - }); - - it("should handle rapid WebSocket message exchange across multiple connections", async () => { - const { WebSocketServer, WebSocket: WsClient } = await import("ws"); - const localWsServer = new WebSocketServer({ port: 0 }); - const localWsPort = (localWsServer.address() as { port: number }).port; - - const allReceivedByServer: string[] = []; - - localWsServer.on("connection", (ws) => { - ws.on("message", (data) => { - allReceivedByServer.push(data.toString()); - // Echo back immediately - ws.send(data); - }); - }); - - const client = new DevhookClient({ - serverUrl: `http://localhost:${serverPort}`, - secret: "ws-rapid-test", - transformUrl: (url) => { - url.host = `localhost:${localWsPort}`; - return url; - }, - onRequest: async (req) => { - const url = new URL(req.url); - url.host = `localhost:${localWsPort}`; - return fetch(new Request(url.toString(), req)); - }, - }); - - const disposable = client.connect(); - clientConnections.push(disposable); - await new Promise((resolve) => setTimeout(resolve, 200)); - - const devhookId = await generateDevhookId("ws-rapid-test", SERVER_SECRET); - - // Create 3 connections that will all send messages rapidly - const numConnections = 3; - const messagesPerConnection = 10; - const connections: InstanceType[] = []; - const receivedByClient: Map = new Map(); - - for (let i = 0; i < numConnections; i++) { - receivedByClient.set(i, []); - const ws = new WsClient( - `ws://localhost:${serverPort}/devhook/${devhookId}/rapid${i}` - ); - connections.push(ws); - } - - // Wait for all connections to open - await Promise.all( - connections.map( - (ws, i) => - new Promise((resolve, reject) => { - ws.on("open", resolve); - ws.on("error", reject); - ws.on("message", (data) => { - receivedByClient.get(i)!.push(data.toString()); - }); - setTimeout(() => reject(new Error(`Connection ${i} timeout`)), 5000); - }) - ) - ); - - // Send messages rapidly from all connections - const sendPromises: Promise[] = []; - for (let i = 0; i < numConnections; i++) { - for (let j = 0; j < messagesPerConnection; j++) { - connections[i]!.send(`conn${i}-msg${j}`); - } - } - - // Wait for all messages to be processed - const totalExpectedMessages = numConnections * messagesPerConnection; - await new Promise((resolve) => { - const checkComplete = () => { - if (allReceivedByServer.length >= totalExpectedMessages) { - resolve(); - } else { - setTimeout(checkComplete, 50); - } - }; - checkComplete(); - // Timeout after 5 seconds - setTimeout(resolve, 5000); - }); - - // Verify all messages were received by the server - expect(allReceivedByServer.length).toBe(totalExpectedMessages); - - // Verify each connection's messages are present - for (let i = 0; i < numConnections; i++) { - for (let j = 0; j < messagesPerConnection; j++) { - expect(allReceivedByServer).toContain(`conn${i}-msg${j}`); - } - } - - // Wait a bit more for echo responses - await new Promise((resolve) => setTimeout(resolve, 200)); - - // Verify each client received echo responses - // Note: Each connection receives echoes for all messages sent on that connection - for (let i = 0; i < numConnections; i++) { - expect(receivedByClient.get(i)!.length).toBeGreaterThanOrEqual(messagesPerConnection); - } - - // Cleanup - for (const ws of connections) { - ws.close(); - } - await new Promise((resolve) => setTimeout(resolve, 100)); - localWsServer.close(); - }); - - it("should isolate WebSocket connections - closing one doesn't affect others", async () => { - const { WebSocketServer, WebSocket: WsClient } = await import("ws"); - const localWsServer = new WebSocketServer({ port: 0 }); - const localWsPort = (localWsServer.address() as { port: number }).port; - - let activeLocalConnections = 0; - - localWsServer.on("connection", (ws) => { - activeLocalConnections++; - ws.on("message", (data) => ws.send(data)); - ws.on("close", () => activeLocalConnections--); - }); - - const client = new DevhookClient({ - serverUrl: `http://localhost:${serverPort}`, - secret: "ws-isolate-test", - transformUrl: (url) => { - url.host = `localhost:${localWsPort}`; - return url; - }, - onRequest: async (req) => { - const url = new URL(req.url); - url.host = `localhost:${localWsPort}`; - return fetch(new Request(url.toString(), req)); - }, - }); - - const disposable = client.connect(); - clientConnections.push(disposable); - await new Promise((resolve) => setTimeout(resolve, 200)); - - const devhookId = await generateDevhookId("ws-isolate-test", SERVER_SECRET); - - // Create 3 connections - const ws1 = new WsClient(`ws://localhost:${serverPort}/devhook/${devhookId}/a`); - const ws2 = new WsClient(`ws://localhost:${serverPort}/devhook/${devhookId}/b`); - const ws3 = new WsClient(`ws://localhost:${serverPort}/devhook/${devhookId}/c`); - - const received2: string[] = []; - const received3: string[] = []; - - await Promise.all([ - new Promise((resolve, reject) => { - ws1.on("open", resolve); - ws1.on("error", reject); - setTimeout(() => reject(new Error("Timeout")), 5000); - }), - new Promise((resolve, reject) => { - ws2.on("open", resolve); - ws2.on("error", reject); - ws2.on("message", (data) => received2.push(data.toString())); - setTimeout(() => reject(new Error("Timeout")), 5000); - }), - new Promise((resolve, reject) => { - ws3.on("open", resolve); - ws3.on("error", reject); - ws3.on("message", (data) => received3.push(data.toString())); - setTimeout(() => reject(new Error("Timeout")), 5000); - }), - ]); - - expect(activeLocalConnections).toBe(3); - - // Close ws1 - ws1.close(); - // Wait for close to propagate through the devhook proxy - await new Promise((resolve) => setTimeout(resolve, 200)); - - // ws2 and ws3 should still work and be able to send/receive messages - expect(ws2.readyState).toBe(WsClient.OPEN); - expect(ws3.readyState).toBe(WsClient.OPEN); - - // Send messages on remaining connections - this is the key test - ws2.send("still alive 2"); - ws3.send("still alive 3"); - - await new Promise((resolve) => setTimeout(resolve, 200)); - - // The important assertion: closing ws1 didn't break ws2 and ws3 - expect(received2).toContain("still alive 2"); - expect(received3).toContain("still alive 3"); - - // Cleanup - ws2.close(); - ws3.close(); - await new Promise((resolve) => setTimeout(resolve, 100)); - localWsServer.close(); - }); - - it("should close proxied WebSockets when devhook client disconnects", async () => { - let externalWsClosed = false; - - const { WebSocketServer, WebSocket: WsClient } = await import("ws"); - const localWsServer = new WebSocketServer({ port: 0 }); - const localWsPort = (localWsServer.address() as { port: number }).port; - - localWsServer.on("connection", (ws) => { - // Keep connection open - ws.on("message", (data) => { - ws.send(data); - }); - }); - - const client = new DevhookClient({ - serverUrl: `http://localhost:${serverPort}`, - secret: "ws-disconnect-test", - transformUrl: (url) => { - url.host = `localhost:${localWsPort}`; - return url; - }, - onRequest: async (req) => { - const url = new URL(req.url); - url.host = `localhost:${localWsPort}`; - return fetch(new Request(url.toString(), req)); - }, - }); - - const disposable = client.connect(); - clientConnections.push(disposable); - await new Promise((resolve) => setTimeout(resolve, 200)); - - const devhookId = await generateDevhookId("ws-disconnect-test", SERVER_SECRET); - - const externalWs = new WsClient( - `ws://localhost:${serverPort}/devhook/${devhookId}/ws` - ); - - await new Promise((resolve, reject) => { - externalWs.on("open", resolve); - externalWs.on("error", reject); - setTimeout(() => reject(new Error("Timeout")), 5000); - }); - - externalWs.on("close", () => { - externalWsClosed = true; - }); - - // Disconnect the devhook client - disposable.dispose(); - clientConnections.pop(); - - // Wait for close to propagate - await new Promise((resolve) => setTimeout(resolve, 300)); - - expect(externalWsClosed).toBe(true); - - localWsServer.close(); - }); - }); - - describe("server callbacks", () => { - it("should call onClientConnect and onClientDisconnect", async () => { - const connectedIds: string[] = []; - const disconnectedIds: string[] = []; - - const serverPort = 20080 + Math.floor(Math.random() * 1000); - const server = createLocalServer({ - port: serverPort, - secret: SERVER_SECRET, - baseUrl: `http://localhost:${serverPort}`, - mode: "subpath", - onClientConnect: (id) => connectedIds.push(id), - onClientDisconnect: (id) => disconnectedIds.push(id), - }); - - await new Promise((resolve) => setTimeout(resolve, 100)); - - const client = new DevhookClient({ - serverUrl: `http://localhost:${serverPort}`, - secret: "callback-test-secret", - onRequest: async () => new Response("OK"), - }); - - const disposable = client.connect(); - await new Promise((resolve) => setTimeout(resolve, 200)); - - expect(connectedIds.length).toBe(1); - expect(connectedIds[0]).toHaveLength(16); - - disposable.dispose(); - await new Promise((resolve) => setTimeout(resolve, 200)); - - expect(disconnectedIds.length).toBe(1); - expect(disconnectedIds[0]).toBe(connectedIds[0]); - - server.close(); - }); - - it("should call onReady with port", async () => { - let readyPort: number | undefined; - const serverPort = 21080 + Math.floor(Math.random() * 1000); - - const server = createLocalServer({ - port: serverPort, - secret: SERVER_SECRET, - baseUrl: `http://localhost:${serverPort}`, - mode: "subpath", - onReady: (port) => { - readyPort = port; - }, - }); - - await new Promise((resolve) => setTimeout(resolve, 100)); - - expect(readyPort).toBe(serverPort); - - server.close(); - }); - }); + // Run shared tests against local server + runSharedTests("local", createLocalServerFactory(SERVER_SECRET), SERVER_SECRET); }); diff --git a/packages/devhook/src/server/cloudflare.test-adapter.ts b/packages/devhook/src/server/cloudflare.test-adapter.ts new file mode 100644 index 0000000..18dd49f --- /dev/null +++ b/packages/devhook/src/server/cloudflare.test-adapter.ts @@ -0,0 +1,78 @@ +/** + * Test adapter for the Cloudflare devhook server using wrangler's unstable_dev. + * + * Note: The unstable_dev API can be finicky. This adapter may need adjustments + * based on the wrangler version and local environment. + */ + +import { unstable_dev, type UnstableDevWorker } from "wrangler"; +import type { TestServer, TestServerFactory } from "../test-utils"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const packageRoot = join(__dirname, "../.."); + +interface CloudflareTestServer extends TestServer { + worker: UnstableDevWorker; +} + +/** + * Create a Cloudflare Worker server for testing using wrangler's unstable_dev. + */ +export async function createCloudflareTestServer(secret: string): Promise { + // Use a specific port for the worker + const port = 9787 + Math.floor(Math.random() * 1000); + + // Use the wrangler.toml for proper Durable Object configuration + const worker = await unstable_dev( + join(__dirname, "cloudflare.ts"), + { + experimental: { + disableExperimentalWarning: true, + }, + // Use wrangler.toml for DO bindings but override vars + config: join(packageRoot, "wrangler.toml"), + vars: { + DEVHOOK_SECRET: secret, + DEVHOOK_BASE_URL: `http://localhost:${port}`, + DEVHOOK_MODE: "subpath", + }, + local: true, + persist: false, + port, + } + ); + + const url = `http://127.0.0.1:${port}`; + + // Wait for worker to be ready by polling health endpoint + const maxAttempts = 30; + for (let i = 0; i < maxAttempts; i++) { + try { + const response = await fetch(`${url}/health`); + if (response.ok) { + break; + } + } catch { + // Worker not ready yet + } + await new Promise((resolve) => setTimeout(resolve, 200)); + } + + return { + url, + secret, + worker, + close: async () => { + await worker.stop(); + }, + }; +} + +/** + * Factory for creating Cloudflare test servers. + */ +export const createCloudflareServerFactory = (secret: string): TestServerFactory => { + return async () => createCloudflareTestServer(secret); +}; diff --git a/packages/devhook/src/server/cloudflare.ts b/packages/devhook/src/server/cloudflare.ts index 0cd66d5..e40083d 100644 --- a/packages/devhook/src/server/cloudflare.ts +++ b/packages/devhook/src/server/cloudflare.ts @@ -87,28 +87,18 @@ async function handleClientConnect(request: Request, env: Env): Promise { }); } - /** - * Initialize the session with a devhook ID and client secret. - */ - public async initialize(id: string, clientSecret: string): Promise { - this.id = id; - this.clientSecret = clientSecret; - await this.ctx.storage.put("id", id); - await this.ctx.storage.put("clientSecret", clientSecret); - } - - /** - * Get the stored client secret for verification. - */ - public getClientSecret(): string | undefined { - return this.clientSecret; - } - /** * Check if a client is currently connected. */ @@ -79,6 +62,17 @@ export class DevhookSession extends DurableObject { // Client connecting via WebSocket if (request.headers.get("upgrade") === "websocket") { + // Initialize session from the headers if needed + const devhookId = request.headers.get("x-devhook-id"); + const clientSecret = request.headers.get("x-devhook-secret"); + + if (devhookId && clientSecret && !this.id) { + this.id = devhookId; + this.clientSecret = clientSecret; + await this.ctx.storage.put("id", devhookId); + await this.ctx.storage.put("clientSecret", clientSecret); + } + return this.handleClientConnect(request); } @@ -118,7 +112,10 @@ export class DevhookSession extends DurableObject { (async () => { // Small delay to ensure WebSocket is ready await new Promise((resolve) => setTimeout(resolve, 10)); - server.send(JSON.stringify(connectionInfo)); + // Check if WebSocket is still open before sending (1 = OPEN) + if (server.readyState === 1) { + server.send(JSON.stringify(connectionInfo)); + } })() ); @@ -324,7 +321,7 @@ export class DevhookSession extends DurableObject { const mode = this.env.DEVHOOK_MODE || "wildcard"; if (mode === "subpath") { - return `${baseUrl}/${this.id}`; + return `${baseUrl}/devhook/${this.id}`; } else { // Wildcard mode: insert ID as subdomain const url = new URL(baseUrl); diff --git a/packages/devhook/src/server/local.test-adapter.ts b/packages/devhook/src/server/local.test-adapter.ts new file mode 100644 index 0000000..d64dabe --- /dev/null +++ b/packages/devhook/src/server/local.test-adapter.ts @@ -0,0 +1,34 @@ +/** + * Test adapter for the local devhook server. + */ + +import { createLocalServer } from "./local"; +import type { TestServer, TestServerFactory } from "../test-utils"; + +let portCounter = 17000; + +/** + * Create a local server for testing. + */ +export function createLocalTestServer(secret: string): TestServer { + const port = portCounter++; + const server = createLocalServer({ + port, + secret, + baseUrl: `http://localhost:${port}`, + mode: "subpath", + }); + + return { + url: `http://localhost:${port}`, + secret, + close: () => server.close(), + }; +} + +/** + * Factory for creating local test servers. + */ +export const createLocalServerFactory = (secret: string): TestServerFactory => { + return async () => createLocalTestServer(secret); +}; diff --git a/packages/devhook/src/shared.test-suite.ts b/packages/devhook/src/shared.test-suite.ts new file mode 100644 index 0000000..49bb2e0 --- /dev/null +++ b/packages/devhook/src/shared.test-suite.ts @@ -0,0 +1,867 @@ +/** + * Shared test suite that runs against any devhook server implementation. + * + * This file exports test functions that can be called with different server factories + * to ensure both local and Cloudflare servers behave identically. + */ + +import { describe, it, expect, beforeAll, afterAll, afterEach } from "vitest"; +import { DevhookClient } from "./client"; +import { + type TestServer, + type TestServerFactory, + getDevhookId, + getDevhookUrl, + getDevhookWsUrl, + delay, +} from "./test-utils"; + +export interface SharedTestOptions { + /** + * Skip WebSocket proxying tests. + * Useful for environments where WebSocket behavior differs (e.g., miniflare). + */ + skipWebSocketTests?: boolean; +} + +/** + * Run the shared test suite against a server implementation. + */ +export function runSharedTests( + serverName: string, + serverFactory: TestServerFactory, + serverSecret: string, + options: SharedTestOptions = {} +) { + const { skipWebSocketTests = false } = options; + describe(`${serverName} server`, () => { + let server: TestServer; + + beforeAll(async () => { + server = await serverFactory(); + await delay(100); // Give server time to start + }); + + afterAll(async () => { + await server?.close(); + }); + + describe("basic endpoints", () => { + it("should respond to health check", async () => { + const response = await fetch(`${server.url}/health`); + expect(response.status).toBe(200); + const body = (await response.json()) as { status: string }; + expect(body.status).toBe("ok"); + }); + + it("should return 404 for unknown routes", async () => { + const response = await fetch(`${server.url}/unknown`); + expect(response.status).toBe(404); + }); + + it("should return 426 for non-WebSocket connect requests", async () => { + const response = await fetch(`${server.url}/api/devhook/connect`); + expect(response.status).toBe(426); + }); + }); + + describe("client-server integration", () => { + let clientConnections: Array<{ dispose: () => void }> = []; + + afterEach(() => { + for (const conn of clientConnections) { + conn.dispose(); + } + clientConnections = []; + }); + + it("should connect and receive public URL", async () => { + let connectedUrl: string | undefined; + let connectedId: string | undefined; + + const client = new DevhookClient({ + serverUrl: server.url, + secret: "test-client", + onRequest: async () => new Response("OK"), + onConnect: ({ url, id }) => { + connectedUrl = url; + connectedId = id; + }, + }); + + const disposable = client.connect(); + clientConnections.push(disposable); + + await delay(200); + + expect(connectedUrl).toBeDefined(); + expect(connectedId).toBeDefined(); + expect(connectedId).toHaveLength(16); + expect(connectedUrl).toContain(connectedId); + }); + + it("should proxy GET requests", async () => { + let receivedRequest: Request | undefined; + + const client = new DevhookClient({ + serverUrl: server.url, + secret: "get-test", + onRequest: async (req) => { + receivedRequest = req; + return new Response("GET response"); + }, + }); + + const disposable = client.connect(); + clientConnections.push(disposable); + await delay(200); + + const devhookId = await getDevhookId("get-test", serverSecret); + const response = await fetch(getDevhookUrl(server, devhookId, "/api/data")); + + expect(response.status).toBe(200); + expect(await response.text()).toBe("GET response"); + expect(receivedRequest?.method).toBe("GET"); + expect(new URL(receivedRequest!.url).pathname).toBe("/api/data"); + }); + + it("should proxy POST requests with JSON body", async () => { + let receivedBody: unknown; + + const client = new DevhookClient({ + serverUrl: server.url, + secret: "post-test", + onRequest: async (req) => { + receivedBody = await req.json(); + return new Response(JSON.stringify({ received: true }), { + headers: { "content-type": "application/json" }, + }); + }, + }); + + const disposable = client.connect(); + clientConnections.push(disposable); + await delay(200); + + const devhookId = await getDevhookId("post-test", serverSecret); + const response = await fetch(getDevhookUrl(server, devhookId, "/api/submit"), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ name: "test", value: 123 }), + }); + + expect(response.status).toBe(200); + const body = (await response.json()) as { received: boolean }; + expect(body.received).toBe(true); + expect(receivedBody).toEqual({ name: "test", value: 123 }); + }); + + it("should preserve query parameters", async () => { + let receivedUrl: string | undefined; + + const client = new DevhookClient({ + serverUrl: server.url, + secret: "query-test", + onRequest: async (req) => { + receivedUrl = req.url; + return new Response("OK"); + }, + }); + + const disposable = client.connect(); + clientConnections.push(disposable); + await delay(200); + + const devhookId = await getDevhookId("query-test", serverSecret); + await fetch(getDevhookUrl(server, devhookId, "/search?q=test&page=1")); + + expect(receivedUrl).toBeDefined(); + const url = new URL(receivedUrl!); + expect(url.searchParams.get("q")).toBe("test"); + expect(url.searchParams.get("page")).toBe("1"); + }); + + it("should preserve request headers", async () => { + let receivedHeaders: Record = {}; + + const client = new DevhookClient({ + serverUrl: server.url, + secret: "headers-test", + onRequest: async (req) => { + req.headers.forEach((value, key) => { + receivedHeaders[key.toLowerCase()] = value; + }); + return new Response("OK"); + }, + }); + + const disposable = client.connect(); + clientConnections.push(disposable); + await delay(200); + + const devhookId = await getDevhookId("headers-test", serverSecret); + await fetch(getDevhookUrl(server, devhookId, "/"), { + headers: { + "x-custom-header": "custom-value", + "authorization": "Bearer token123", + }, + }); + + expect(receivedHeaders["x-custom-header"]).toBe("custom-value"); + expect(receivedHeaders["authorization"]).toBe("Bearer token123"); + }); + + it("should return response headers from client", async () => { + const client = new DevhookClient({ + serverUrl: server.url, + secret: "resp-headers-test", + onRequest: async () => { + return new Response("OK", { + headers: { + "x-custom-response": "response-value", + "cache-control": "no-cache", + }, + }); + }, + }); + + const disposable = client.connect(); + clientConnections.push(disposable); + await delay(200); + + const devhookId = await getDevhookId("resp-headers-test", serverSecret); + const response = await fetch(getDevhookUrl(server, devhookId, "/")); + + expect(response.headers.get("x-custom-response")).toBe("response-value"); + expect(response.headers.get("cache-control")).toBe("no-cache"); + }); + + it("should handle different HTTP status codes", async () => { + const client = new DevhookClient({ + serverUrl: server.url, + secret: "status-test", + onRequest: async (req) => { + const url = new URL(req.url); + const status = parseInt(url.searchParams.get("status") || "200"); + return new Response(null, { status }); + }, + }); + + const disposable = client.connect(); + clientConnections.push(disposable); + await delay(200); + + const devhookId = await getDevhookId("status-test", serverSecret); + + const response201 = await fetch(getDevhookUrl(server, devhookId, "/?status=201")); + expect(response201.status).toBe(201); + + const response404 = await fetch(getDevhookUrl(server, devhookId, "/?status=404")); + expect(response404.status).toBe(404); + + const response500 = await fetch(getDevhookUrl(server, devhookId, "/?status=500")); + expect(response500.status).toBe(500); + }); + + it("should return 503 when no client is connected", async () => { + const devhookId = await getDevhookId("no-client", serverSecret); + const response = await fetch(getDevhookUrl(server, devhookId, "/")); + + expect(response.status).toBe(503); + const body = (await response.json()) as { error: string }; + expect(body.error).toBeDefined(); + }); + + it("should handle client disconnection gracefully", async () => { + const client = new DevhookClient({ + serverUrl: server.url, + secret: "disconnect-test", + onRequest: async () => new Response("OK"), + }); + + const disposable = client.connect(); + await delay(200); + + const devhookId = await getDevhookId("disconnect-test", serverSecret); + + // First request should work + const response1 = await fetch(getDevhookUrl(server, devhookId, "/")); + expect(response1.status).toBe(200); + + // Disconnect + disposable.dispose(); + await delay(100); + + // Second request should fail + const response2 = await fetch(getDevhookUrl(server, devhookId, "/")); + expect(response2.status).toBe(503); + }); + + it("should handle reconnection with same secret", async () => { + const secret = "reconnect-test"; + + const client1 = new DevhookClient({ + serverUrl: server.url, + secret, + onRequest: async () => new Response("client1"), + }); + + const disposable1 = client1.connect(); + await delay(200); + + const devhookId = await getDevhookId(secret, serverSecret); + const response1 = await fetch(getDevhookUrl(server, devhookId, "/")); + expect(await response1.text()).toBe("client1"); + + // Disconnect first client + disposable1.dispose(); + await delay(100); + + // Connect second client with same secret + const client2 = new DevhookClient({ + serverUrl: server.url, + secret, + onRequest: async () => new Response("client2"), + }); + + const disposable2 = client2.connect(); + clientConnections.push(disposable2); + await delay(200); + + // Should get response from new client + const response2 = await fetch(getDevhookUrl(server, devhookId, "/")); + expect(await response2.text()).toBe("client2"); + }); + + it("should handle multiple concurrent clients with different secrets", async () => { + const client1 = new DevhookClient({ + serverUrl: server.url, + secret: "multi-1", + onRequest: async () => new Response("response1"), + }); + + const client2 = new DevhookClient({ + serverUrl: server.url, + secret: "multi-2", + onRequest: async () => new Response("response2"), + }); + + const disposable1 = client1.connect(); + const disposable2 = client2.connect(); + clientConnections.push(disposable1, disposable2); + await delay(200); + + const devhookId1 = await getDevhookId("multi-1", serverSecret); + const devhookId2 = await getDevhookId("multi-2", serverSecret); + + const [response1, response2] = await Promise.all([ + fetch(getDevhookUrl(server, devhookId1, "/")), + fetch(getDevhookUrl(server, devhookId2, "/")), + ]); + + expect(await response1.text()).toBe("response1"); + expect(await response2.text()).toBe("response2"); + }); + + it("should handle request errors gracefully", async () => { + const client = new DevhookClient({ + serverUrl: server.url, + secret: "error-test", + onRequest: async () => { + throw new Error("Handler error"); + }, + }); + + const disposable = client.connect(); + clientConnections.push(disposable); + await delay(200); + + const devhookId = await getDevhookId("error-test", serverSecret); + const response = await fetch(getDevhookUrl(server, devhookId, "/")); + + expect(response.status).toBe(502); + }); + }); + + describe.skipIf(skipWebSocketTests)("websocket proxying", () => { + let clientConnections: Array<{ dispose: () => void }> = []; + + afterEach(() => { + for (const conn of clientConnections) { + conn.dispose(); + } + clientConnections = []; + }); + + it("should proxy WebSocket connections", async () => { + const receivedMessages: string[] = []; + let localWsConnected = false; + + const { WebSocketServer, WebSocket: WsClient } = await import("ws"); + const localWsServer = new WebSocketServer({ port: 0 }); + const localWsPort = (localWsServer.address() as { port: number }).port; + + localWsServer.on("connection", (ws) => { + localWsConnected = true; + ws.on("message", (data) => { + receivedMessages.push(data.toString()); + ws.send(`echo: ${data.toString()}`); + }); + }); + + const client = new DevhookClient({ + serverUrl: server.url, + secret: "ws-test", + transformUrl: (url) => { + url.host = `localhost:${localWsPort}`; + return url; + }, + onRequest: async (req) => { + const url = new URL(req.url); + url.host = `localhost:${localWsPort}`; + return fetch(new Request(url.toString(), req)); + }, + }); + + const disposable = client.connect(); + clientConnections.push(disposable); + await delay(200); + + const devhookId = await getDevhookId("ws-test", serverSecret); + const externalWs = new WsClient(getDevhookWsUrl(server, devhookId, "/ws")); + + const externalMessages: string[] = []; + await new Promise((resolve, reject) => { + externalWs.on("open", () => { + externalWs.send("hello from external"); + }); + + externalWs.on("message", (data) => { + externalMessages.push(data.toString()); + if (externalMessages.length >= 1) { + resolve(); + } + }); + + externalWs.on("error", reject); + setTimeout(() => reject(new Error("Timeout")), 5000); + }); + + expect(localWsConnected).toBe(true); + expect(receivedMessages).toContain("hello from external"); + expect(externalMessages).toContain("echo: hello from external"); + + externalWs.close(); + localWsServer.close(); + }); + + it("should handle WebSocket close from external client", async () => { + let localWsClosed = false; + let closeCode: number | undefined; + + const { WebSocketServer, WebSocket: WsClient } = await import("ws"); + const localWsServer = new WebSocketServer({ port: 0 }); + const localWsPort = (localWsServer.address() as { port: number }).port; + + localWsServer.on("connection", (ws) => { + ws.on("close", (code) => { + localWsClosed = true; + closeCode = code; + }); + }); + + const client = new DevhookClient({ + serverUrl: server.url, + secret: "ws-close-test", + transformUrl: (url) => { + url.host = `localhost:${localWsPort}`; + return url; + }, + onRequest: async (req) => { + const url = new URL(req.url); + url.host = `localhost:${localWsPort}`; + return fetch(new Request(url.toString(), req)); + }, + }); + + const disposable = client.connect(); + clientConnections.push(disposable); + await delay(200); + + const devhookId = await getDevhookId("ws-close-test", serverSecret); + const externalWs = new WsClient(getDevhookWsUrl(server, devhookId, "/ws")); + + await new Promise((resolve, reject) => { + externalWs.on("open", () => { + externalWs.close(1000, "Normal closure"); + }); + + externalWs.on("close", () => { + setTimeout(resolve, 100); + }); + + externalWs.on("error", reject); + setTimeout(() => reject(new Error("Timeout")), 5000); + }); + + expect(localWsClosed).toBe(true); + expect(closeCode).toBe(1000); + + localWsServer.close(); + }); + + it("should handle multiple concurrent WebSocket connections to the same client", async () => { + const localConnections: Set = new Set(); + let connectionCounter = 0; + + const { WebSocketServer, WebSocket: WsClient } = await import("ws"); + const localWsServer = new WebSocketServer({ port: 0 }); + const localWsPort = (localWsServer.address() as { port: number }).port; + + localWsServer.on("connection", (ws) => { + const connId = connectionCounter++; + localConnections.add(connId); + + ws.on("message", (data) => { + ws.send(`conn${connId}: ${data.toString()}`); + }); + + ws.on("close", () => { + localConnections.delete(connId); + }); + }); + + const client = new DevhookClient({ + serverUrl: server.url, + secret: "ws-concurrent-test", + transformUrl: (url) => { + url.host = `localhost:${localWsPort}`; + return url; + }, + onRequest: async (req) => { + const url = new URL(req.url); + url.host = `localhost:${localWsPort}`; + return fetch(new Request(url.toString(), req)); + }, + }); + + const disposable = client.connect(); + clientConnections.push(disposable); + await delay(200); + + const devhookId = await getDevhookId("ws-concurrent-test", serverSecret); + + // Create 5 concurrent WebSocket connections + const numConnections = 5; + const externalWsConnections: InstanceType[] = []; + const receivedMessages: Map = new Map(); + + for (let i = 0; i < numConnections; i++) { + receivedMessages.set(i, []); + const ws = new WsClient(getDevhookWsUrl(server, devhookId, `/ws${i}`)); + externalWsConnections.push(ws); + } + + // Wait for all connections to open + await Promise.all( + externalWsConnections.map( + (ws, i) => + new Promise((resolve, reject) => { + ws.on("open", resolve); + ws.on("error", reject); + ws.on("message", (data) => { + receivedMessages.get(i)!.push(data.toString()); + }); + setTimeout(() => reject(new Error(`Connection ${i} timeout`)), 5000); + }) + ) + ); + + expect(localConnections.size).toBe(numConnections); + + // Send messages from each connection + for (let i = 0; i < numConnections; i++) { + externalWsConnections[i]!.send(`hello from ws${i}`); + } + + await delay(200); + + // Verify each connection received its own response + for (let i = 0; i < numConnections; i++) { + expect(receivedMessages.get(i)!.length).toBeGreaterThanOrEqual(1); + expect(receivedMessages.get(i)![0]).toContain(`hello from ws${i}`); + } + + // Close all connections + for (const ws of externalWsConnections) { + ws.close(); + } + + await delay(100); + localWsServer.close(); + }); + + it("should handle WebSocket connections from multiple devhook clients simultaneously", async () => { + const { WebSocketServer, WebSocket: WsClient } = await import("ws"); + + const localWsServer1 = new WebSocketServer({ port: 0 }); + const localWsPort1 = (localWsServer1.address() as { port: number }).port; + const localWsServer2 = new WebSocketServer({ port: 0 }); + const localWsPort2 = (localWsServer2.address() as { port: number }).port; + + const messages1: string[] = []; + const messages2: string[] = []; + + localWsServer1.on("connection", (ws) => { + ws.on("message", (data) => { + messages1.push(data.toString()); + ws.send(`server1: ${data.toString()}`); + }); + }); + + localWsServer2.on("connection", (ws) => { + ws.on("message", (data) => { + messages2.push(data.toString()); + ws.send(`server2: ${data.toString()}`); + }); + }); + + const client1 = new DevhookClient({ + serverUrl: server.url, + secret: "ws-multi-1", + transformUrl: (url) => { + url.host = `localhost:${localWsPort1}`; + return url; + }, + onRequest: async (req) => { + const url = new URL(req.url); + url.host = `localhost:${localWsPort1}`; + return fetch(new Request(url.toString(), req)); + }, + }); + + const client2 = new DevhookClient({ + serverUrl: server.url, + secret: "ws-multi-2", + transformUrl: (url) => { + url.host = `localhost:${localWsPort2}`; + return url; + }, + onRequest: async (req) => { + const url = new URL(req.url); + url.host = `localhost:${localWsPort2}`; + return fetch(new Request(url.toString(), req)); + }, + }); + + const disposable1 = client1.connect(); + const disposable2 = client2.connect(); + clientConnections.push(disposable1, disposable2); + await delay(200); + + const devhookId1 = await getDevhookId("ws-multi-1", serverSecret); + const devhookId2 = await getDevhookId("ws-multi-2", serverSecret); + + const externalWs1 = new WsClient(getDevhookWsUrl(server, devhookId1, "/ws")); + const externalWs2 = new WsClient(getDevhookWsUrl(server, devhookId2, "/ws")); + + const received1: string[] = []; + const received2: string[] = []; + + await Promise.all([ + new Promise((resolve, reject) => { + externalWs1.on("open", resolve); + externalWs1.on("error", reject); + externalWs1.on("message", (data) => received1.push(data.toString())); + setTimeout(() => reject(new Error("Timeout ws1")), 5000); + }), + new Promise((resolve, reject) => { + externalWs2.on("open", resolve); + externalWs2.on("error", reject); + externalWs2.on("message", (data) => received2.push(data.toString())); + setTimeout(() => reject(new Error("Timeout ws2")), 5000); + }), + ]); + + externalWs1.send("message to client 1"); + externalWs2.send("message to client 2"); + + await delay(200); + + // Verify messages were routed correctly + expect(messages1).toContain("message to client 1"); + expect(messages2).toContain("message to client 2"); + expect(messages1).not.toContain("message to client 2"); + expect(messages2).not.toContain("message to client 1"); + + // Verify responses came from correct servers + expect(received1.length).toBeGreaterThanOrEqual(1); + expect(received1[0]).toContain("server1:"); + expect(received2.length).toBeGreaterThanOrEqual(1); + expect(received2[0]).toContain("server2:"); + + externalWs1.close(); + externalWs2.close(); + await delay(100); + localWsServer1.close(); + localWsServer2.close(); + }); + + it("should isolate WebSocket connections - closing one doesn't affect others", async () => { + const { WebSocketServer, WebSocket: WsClient } = await import("ws"); + const localWsServer = new WebSocketServer({ port: 0 }); + const localWsPort = (localWsServer.address() as { port: number }).port; + + localWsServer.on("connection", (ws) => { + ws.on("message", (data) => ws.send(data)); + }); + + const client = new DevhookClient({ + serverUrl: server.url, + secret: "ws-isolate-test", + transformUrl: (url) => { + url.host = `localhost:${localWsPort}`; + return url; + }, + onRequest: async (req) => { + const url = new URL(req.url); + url.host = `localhost:${localWsPort}`; + return fetch(new Request(url.toString(), req)); + }, + }); + + const disposable = client.connect(); + clientConnections.push(disposable); + await delay(200); + + const devhookId = await getDevhookId("ws-isolate-test", serverSecret); + + const ws1 = new WsClient(getDevhookWsUrl(server, devhookId, "/a")); + const ws2 = new WsClient(getDevhookWsUrl(server, devhookId, "/b")); + const ws3 = new WsClient(getDevhookWsUrl(server, devhookId, "/c")); + + const received2: string[] = []; + const received3: string[] = []; + + await Promise.all([ + new Promise((resolve, reject) => { + ws1.on("open", resolve); + ws1.on("error", reject); + setTimeout(() => reject(new Error("Timeout")), 5000); + }), + new Promise((resolve, reject) => { + ws2.on("open", resolve); + ws2.on("error", reject); + ws2.on("message", (data) => received2.push(data.toString())); + setTimeout(() => reject(new Error("Timeout")), 5000); + }), + new Promise((resolve, reject) => { + ws3.on("open", resolve); + ws3.on("error", reject); + ws3.on("message", (data) => received3.push(data.toString())); + setTimeout(() => reject(new Error("Timeout")), 5000); + }), + ]); + + // Close ws1 + ws1.close(); + await delay(200); + + // ws2 and ws3 should still work + expect(ws2.readyState).toBe(WsClient.OPEN); + expect(ws3.readyState).toBe(WsClient.OPEN); + + // Send messages on remaining connections + ws2.send("still alive 2"); + ws3.send("still alive 3"); + + await delay(200); + + expect(received2).toContain("still alive 2"); + expect(received3).toContain("still alive 3"); + + ws2.close(); + ws3.close(); + await delay(100); + localWsServer.close(); + }); + + it("should close proxied WebSockets when devhook client disconnects", async () => { + let externalWsClosed = false; + + const { WebSocketServer, WebSocket: WsClient } = await import("ws"); + const localWsServer = new WebSocketServer({ port: 0 }); + const localWsPort = (localWsServer.address() as { port: number }).port; + + localWsServer.on("connection", (ws) => { + ws.on("message", (data) => { + ws.send(data); + }); + }); + + const client = new DevhookClient({ + serverUrl: server.url, + secret: "ws-disconnect-test", + transformUrl: (url) => { + url.host = `localhost:${localWsPort}`; + return url; + }, + onRequest: async (req) => { + const url = new URL(req.url); + url.host = `localhost:${localWsPort}`; + return fetch(new Request(url.toString(), req)); + }, + }); + + const disposable = client.connect(); + // Don't add to clientConnections - we'll manually dispose + await delay(200); + + const devhookId = await getDevhookId("ws-disconnect-test", serverSecret); + const externalWs = new WsClient(getDevhookWsUrl(server, devhookId, "/ws")); + + await new Promise((resolve, reject) => { + externalWs.on("open", resolve); + externalWs.on("error", reject); + setTimeout(() => reject(new Error("Timeout")), 5000); + }); + + externalWs.on("close", () => { + externalWsClosed = true; + }); + + // Disconnect the devhook client + disposable.dispose(); + + // Wait for close to propagate + await delay(300); + + expect(externalWsClosed).toBe(true); + + localWsServer.close(); + }); + + it("should return 503 when no client is connected for WebSocket", async () => { + const { WebSocket: WsClient } = await import("ws"); + const devhookId = await getDevhookId("nonexistent-ws", serverSecret); + + const externalWs = new WsClient(getDevhookWsUrl(server, devhookId, "/ws")); + + await new Promise((resolve) => { + externalWs.on("error", () => { + resolve(); + }); + + externalWs.on("open", () => { + externalWs.close(); + resolve(); + }); + + setTimeout(resolve, 1000); + }); + + expect(externalWs.readyState).not.toBe(WsClient.OPEN); + }); + }); + }); +} diff --git a/packages/devhook/src/test-utils.ts b/packages/devhook/src/test-utils.ts new file mode 100644 index 0000000..b928190 --- /dev/null +++ b/packages/devhook/src/test-utils.ts @@ -0,0 +1,118 @@ +/** + * Shared test utilities for testing both local and Cloudflare servers. + * + * This module provides a common interface for server implementations + * so the same tests can run against both. + */ + +import { DevhookClient, type DevhookClientOptions } from "./client"; +import { generateDevhookId } from "./server/crypto"; + +/** + * Common interface for devhook server implementations. + * Both local and Cloudflare servers should implement this. + */ +export interface TestServer { + /** The base URL of the server (e.g., http://localhost:8080) */ + readonly url: string; + + /** The server secret used for HMAC signing */ + readonly secret: string; + + /** Close/cleanup the server */ + close(): Promise | void; +} + +/** + * Factory function type for creating test servers. + */ +export type TestServerFactory = () => Promise; + +/** + * Options for creating a test client. + */ +export interface TestClientOptions { + server: TestServer; + secret: string; + localTargetPort?: number; + transformUrl?: (url: URL) => URL; + onRequest?: DevhookClientOptions["onRequest"]; + onConnect?: DevhookClientOptions["onConnect"]; + onDisconnect?: DevhookClientOptions["onDisconnect"]; + onError?: DevhookClientOptions["onError"]; +} + +/** + * Create a DevhookClient configured for testing. + */ +export function createTestClient(opts: TestClientOptions): DevhookClient { + const { server, secret, localTargetPort, transformUrl, onRequest, ...rest } = opts; + + return new DevhookClient({ + serverUrl: server.url, + secret, + transformUrl: transformUrl ?? (localTargetPort + ? (url) => { + url.host = `localhost:${localTargetPort}`; + return url; + } + : undefined), + onRequest: onRequest ?? (async (req) => { + if (localTargetPort) { + const url = new URL(req.url); + url.host = `localhost:${localTargetPort}`; + return fetch(new Request(url.toString(), req)); + } + return new Response("No handler configured", { status: 500 }); + }), + ...rest, + }); +} + +/** + * Helper to generate a devhook ID for testing. + */ +export async function getDevhookId(clientSecret: string, serverSecret: string): Promise { + return generateDevhookId(clientSecret, serverSecret); +} + +/** + * Helper to build the devhook URL for a given ID. + */ +export function getDevhookUrl(server: TestServer, devhookId: string, path = ""): string { + return `${server.url}/devhook/${devhookId}${path}`; +} + +/** + * Helper to build the WebSocket URL for a devhook. + */ +export function getDevhookWsUrl(server: TestServer, devhookId: string, path = ""): string { + const url = new URL(getDevhookUrl(server, devhookId, path)); + url.protocol = url.protocol === "https:" ? "wss:" : "ws:"; + return url.toString(); +} + +/** + * Wait for a condition with timeout. + */ +export async function waitFor( + condition: () => boolean | Promise, + timeoutMs = 5000, + intervalMs = 50 +): Promise { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + if (await condition()) { + return; + } + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + } + throw new Error(`Timeout waiting for condition after ${timeoutMs}ms`); +} + +/** + * Delay helper. + */ +export function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} From 2149fdb4a2188d9ccce89265f864fdc583cfc1e4 Mon Sep 17 00:00:00 2001 From: Hugo Dutka Date: Mon, 15 Dec 2025 16:34:28 +0000 Subject: [PATCH 08/14] fix(devhook): fix WebSocket proxying for Cloudflare Worker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Reorder DO fetch handler to check proxy requests before WebSocket upgrade (proxied WebSocket connections also have upgrade header) - Handle binary messages sent as strings in miniflare environment - Complete WebSocket close handshake on server side of WebSocketPair - Add proper timeout handling for slow WebSocket close propagation - Remove WebSocket test skip options now that all tests pass 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- packages/devhook/src/client/index.ts | 1 - packages/devhook/src/cloudflare.test.ts | 6 +--- packages/devhook/src/server/durable-object.ts | 30 +++++++++++++------ packages/devhook/src/shared.test-suite.ts | 29 +++++++++++------- 4 files changed, 41 insertions(+), 25 deletions(-) diff --git a/packages/devhook/src/client/index.ts b/packages/devhook/src/client/index.ts index a3cadd3..aa3264d 100644 --- a/packages/devhook/src/client/index.ts +++ b/packages/devhook/src/client/index.ts @@ -490,7 +490,6 @@ export class DevhookClient { ws.close(); } } catch { - // Ignore close errors ws.close(); } break; diff --git a/packages/devhook/src/cloudflare.test.ts b/packages/devhook/src/cloudflare.test.ts index ff4e56a..0b4f7e4 100644 --- a/packages/devhook/src/cloudflare.test.ts +++ b/packages/devhook/src/cloudflare.test.ts @@ -24,9 +24,6 @@ const SERVER_SECRET = "test-server-secret"; // Check if we should skip tests (e.g., in CI without wrangler) const SKIP_TESTS = process.env.SKIP_CLOUDFLARE_TESTS === "1"; -// Skip WebSocket tests in local mode as miniflare has different WebSocket behavior -const SKIP_WEBSOCKET_TESTS = process.env.SKIP_WEBSOCKET_TESTS !== "0"; - if (SKIP_TESTS) { describe("devhook cloudflare (skipped)", () => { it("cloudflare tests are skipped - set SKIP_CLOUDFLARE_TESTS=0 to enable", () => { @@ -39,7 +36,6 @@ if (SKIP_TESTS) { runSharedTests( "cloudflare", createCloudflareServerFactory(SERVER_SECRET), - SERVER_SECRET, - { skipWebSocketTests: SKIP_WEBSOCKET_TESTS } + SERVER_SECRET ); } diff --git a/packages/devhook/src/server/durable-object.ts b/packages/devhook/src/server/durable-object.ts index a5bf6d1..1011534 100644 --- a/packages/devhook/src/server/durable-object.ts +++ b/packages/devhook/src/server/durable-object.ts @@ -60,6 +60,11 @@ export class DevhookSession extends DurableObject { public override async fetch(request: Request): Promise { const url = new URL(request.url); + // Proxy request (check BEFORE WebSocket upgrade since proxied WS also has upgrade header) + if (url.pathname === "/proxy" || request.headers.has("x-devhook-proxy-url")) { + return this.handleProxyRequest(request); + } + // Client connecting via WebSocket if (request.headers.get("upgrade") === "websocket") { // Initialize session from the headers if needed @@ -76,11 +81,6 @@ export class DevhookSession extends DurableObject { return this.handleClientConnect(request); } - // Proxy request - if (url.pathname === "/proxy" || request.headers.has("x-devhook-proxy-url")) { - return this.handleProxyRequest(request); - } - return new Response("Not found", { status: 404 }); } @@ -216,12 +216,18 @@ export class DevhookSession extends DurableObject { switch (state.type) { case "client": { + let bytes: Uint8Array; if (typeof message === "string") { - // Clients should not send string messages - console.warn("Received unexpected string message from client"); - return; + // Node.js ws library may send binary as string in some workerd/miniflare environments + // Convert string to binary assuming Latin-1 encoding (each char is one byte) + bytes = new Uint8Array(message.length); + for (let i = 0; i < message.length; i++) { + bytes[i] = message.charCodeAt(i); + } + } else { + bytes = new Uint8Array(message); } - worker.handleClientMessage(new Uint8Array(message)); + worker.handleClientMessage(bytes); break; } case "proxied": { @@ -254,6 +260,12 @@ export class DevhookSession extends DurableObject { case "proxied": { const worker = this.getWorker(); worker.sendProxiedWebSocketClose(state.streamID, code); + // Close the server side of the WebSocketPair to complete the handshake + try { + ws.close(code, "Closed"); + } catch { + // Already closed + } break; } } diff --git a/packages/devhook/src/shared.test-suite.ts b/packages/devhook/src/shared.test-suite.ts index 49bb2e0..1088b10 100644 --- a/packages/devhook/src/shared.test-suite.ts +++ b/packages/devhook/src/shared.test-suite.ts @@ -786,7 +786,9 @@ export function runSharedTests( localWsServer.close(); }); - it("should close proxied WebSockets when devhook client disconnects", async () => { + // Note: miniflare/wrangler dev can be slow with WebSocket close propagation + // See: https://github.com/cloudflare/workers-sdk/issues/10307 + it("should close proxied WebSockets when devhook client disconnects", { timeout: 30000 }, async () => { let externalWsClosed = false; const { WebSocketServer, WebSocket: WsClient } = await import("ws"); @@ -821,20 +823,27 @@ export function runSharedTests( const externalWs = new WsClient(getDevhookWsUrl(server, devhookId, "/ws")); await new Promise((resolve, reject) => { - externalWs.on("open", resolve); + externalWs.on("open", () => { + resolve(); + }); externalWs.on("error", reject); - setTimeout(() => reject(new Error("Timeout")), 5000); + setTimeout(() => reject(new Error("Timeout connecting")), 5000); }); - externalWs.on("close", () => { - externalWsClosed = true; - }); + // Disconnect the devhook client and wait for external WS to close + await new Promise((resolve, reject) => { + externalWs.on("close", () => { + externalWsClosed = true; + resolve(); + }); - // Disconnect the devhook client - disposable.dispose(); + disposable.dispose(); - // Wait for close to propagate - await delay(300); + // Longer timeout for miniflare's slow WebSocket close handling + setTimeout(() => { + reject(new Error("External WS did not close after devhook client disconnect")); + }, 20000); + }); expect(externalWsClosed).toBe(true); From 9308ecad9b9941df5037d92139b1f082fb980c57 Mon Sep 17 00:00:00 2001 From: Hugo Dutka Date: Mon, 15 Dec 2025 17:04:44 +0000 Subject: [PATCH 09/14] test(devhook): add comprehensive edge case test suite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add ~50 new tests covering edge cases for both local and Cloudflare adapters: - Multi-value headers: Set-Cookie with multiple cookies, Expires dates, attributes - Cookie handling: URL-encoded values, long cookies, unicode, empty values - Header edge cases: empty values, long headers, many headers, case sensitivity - WebSocket edge cases: UTF-8 messages, large binaries, close codes, query params - Body edge cases: large bodies (5MB), binary data, null bytes, unicode JSON - Connection edge cases: rapid reconnects, high concurrency, client replacement - Error handling: 502 responses, rejected promises, various status codes - URL handling: encoded paths, special query chars, double slashes - HTTP methods: all standard methods, HEAD, OPTIONS/CORS All 75 tests pass on local adapter, 68 tests pass on Cloudflare adapter. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- packages/devhook/src/shared.test-suite.ts | 1678 +++++++++++++++++++++ 1 file changed, 1678 insertions(+) diff --git a/packages/devhook/src/shared.test-suite.ts b/packages/devhook/src/shared.test-suite.ts index 1088b10..8bf9c43 100644 --- a/packages/devhook/src/shared.test-suite.ts +++ b/packages/devhook/src/shared.test-suite.ts @@ -872,5 +872,1683 @@ export function runSharedTests( expect(externalWs.readyState).not.toBe(WsClient.OPEN); }); }); + + describe("multi-value headers", () => { + let clientConnections: Array<{ dispose: () => void }> = []; + + afterEach(() => { + for (const conn of clientConnections) { + conn.dispose(); + } + clientConnections = []; + }); + + it.fails("should preserve multiple Set-Cookie headers", async () => { + const client = new DevhookClient({ + serverUrl: server.url, + secret: "multi-cookie-test", + onRequest: async () => { + const headers = new Headers(); + headers.append("Set-Cookie", "a=1; Path=/"); + headers.append("Set-Cookie", "b=2; Path=/"); + headers.append("Set-Cookie", "c=3; Path=/"); + return new Response("OK", { headers }); + }, + }); + + const disposable = client.connect(); + clientConnections.push(disposable); + await delay(200); + + const devhookId = await getDevhookId("multi-cookie-test", serverSecret); + const response = await fetch(getDevhookUrl(server, devhookId, "/")); + + expect(response.status).toBe(200); + + // Get all Set-Cookie headers - this will fail because Record loses duplicates + const setCookieHeaders = response.headers.getSetCookie(); + expect(setCookieHeaders).toHaveLength(3); + expect(setCookieHeaders).toContain("a=1; Path=/"); + expect(setCookieHeaders).toContain("b=2; Path=/"); + expect(setCookieHeaders).toContain("c=3; Path=/"); + }); + + it.fails("should handle Set-Cookie with comma in Expires date", async () => { + const client = new DevhookClient({ + serverUrl: server.url, + secret: "cookie-expires-test", + onRequest: async () => { + const headers = new Headers(); + // Expires date contains a comma - if joined with ", " this breaks + headers.append("Set-Cookie", "session=abc123; Expires=Thu, 01 Jan 2026 00:00:00 GMT; Path=/"); + headers.append("Set-Cookie", "user=xyz; Expires=Fri, 02 Jan 2026 00:00:00 GMT; Path=/"); + return new Response("OK", { headers }); + }, + }); + + const disposable = client.connect(); + clientConnections.push(disposable); + await delay(200); + + const devhookId = await getDevhookId("cookie-expires-test", serverSecret); + const response = await fetch(getDevhookUrl(server, devhookId, "/")); + + expect(response.status).toBe(200); + + const setCookieHeaders = response.headers.getSetCookie(); + expect(setCookieHeaders).toHaveLength(2); + // Each cookie should be intact with its Expires date + expect(setCookieHeaders.some(c => c.includes("session=abc123") && c.includes("Thu, 01 Jan 2026"))).toBe(true); + expect(setCookieHeaders.some(c => c.includes("user=xyz") && c.includes("Fri, 02 Jan 2026"))).toBe(true); + }); + + it("should preserve Set-Cookie with all attributes", async () => { + const client = new DevhookClient({ + serverUrl: server.url, + secret: "cookie-attrs-test", + onRequest: async () => { + return new Response("OK", { + headers: { + "Set-Cookie": "session=abc; Path=/app; Domain=example.com; Secure; HttpOnly; SameSite=Strict; Max-Age=3600", + }, + }); + }, + }); + + const disposable = client.connect(); + clientConnections.push(disposable); + await delay(200); + + const devhookId = await getDevhookId("cookie-attrs-test", serverSecret); + const response = await fetch(getDevhookUrl(server, devhookId, "/")); + + expect(response.status).toBe(200); + const cookie = response.headers.get("set-cookie"); + expect(cookie).toContain("session=abc"); + expect(cookie).toContain("Path=/app"); + expect(cookie).toContain("Domain=example.com"); + expect(cookie).toContain("Secure"); + expect(cookie).toContain("HttpOnly"); + expect(cookie).toContain("SameSite=Strict"); + expect(cookie).toContain("Max-Age=3600"); + }); + + it("should handle multiple values for headers that can be combined", async () => { + const client = new DevhookClient({ + serverUrl: server.url, + secret: "vary-header-test", + onRequest: async () => { + const headers = new Headers(); + headers.append("Vary", "Accept"); + headers.append("Vary", "Accept-Encoding"); + headers.append("Cache-Control", "no-cache"); + headers.append("Cache-Control", "no-store"); + return new Response("OK", { headers }); + }, + }); + + const disposable = client.connect(); + clientConnections.push(disposable); + await delay(200); + + const devhookId = await getDevhookId("vary-header-test", serverSecret); + const response = await fetch(getDevhookUrl(server, devhookId, "/")); + + expect(response.status).toBe(200); + // Vary headers can be combined with commas + const vary = response.headers.get("vary"); + expect(vary).toContain("Accept"); + expect(vary).toContain("Accept-Encoding"); + }); + }); + + describe("cookie handling", () => { + let clientConnections: Array<{ dispose: () => void }> = []; + + afterEach(() => { + for (const conn of clientConnections) { + conn.dispose(); + } + clientConnections = []; + }); + + it("should preserve multiple cookies in request Cookie header", async () => { + let receivedCookies: string | null = null; + + const client = new DevhookClient({ + serverUrl: server.url, + secret: "multi-req-cookie-test", + onRequest: async (req) => { + receivedCookies = req.headers.get("cookie"); + return new Response("OK"); + }, + }); + + const disposable = client.connect(); + clientConnections.push(disposable); + await delay(200); + + const devhookId = await getDevhookId("multi-req-cookie-test", serverSecret); + await fetch(getDevhookUrl(server, devhookId, "/"), { + headers: { + "Cookie": "a=1; b=2; c=3", + }, + }); + + expect(receivedCookies).toBe("a=1; b=2; c=3"); + }); + + it("should handle cookies with URL-encoded special characters", async () => { + let receivedCookies: string | null = null; + + const client = new DevhookClient({ + serverUrl: server.url, + secret: "encoded-cookie-test", + onRequest: async (req) => { + receivedCookies = req.headers.get("cookie"); + return new Response("OK"); + }, + }); + + const disposable = client.connect(); + clientConnections.push(disposable); + await delay(200); + + const devhookId = await getDevhookId("encoded-cookie-test", serverSecret); + // URL-encoded value with special chars: hello=world; foo=bar + await fetch(getDevhookUrl(server, devhookId, "/"), { + headers: { + "Cookie": "data=hello%3Dworld%3B%20foo%3Dbar", + }, + }); + + expect(receivedCookies).toBe("data=hello%3Dworld%3B%20foo%3Dbar"); + }); + + it("should handle long cookie values", async () => { + let receivedCookies: string | null = null; + const longValue = "x".repeat(4000); // Near 4KB limit + + const client = new DevhookClient({ + serverUrl: server.url, + secret: "long-cookie-test", + onRequest: async (req) => { + receivedCookies = req.headers.get("cookie"); + return new Response("OK"); + }, + }); + + const disposable = client.connect(); + clientConnections.push(disposable); + await delay(200); + + const devhookId = await getDevhookId("long-cookie-test", serverSecret); + await fetch(getDevhookUrl(server, devhookId, "/"), { + headers: { + "Cookie": `longcookie=${longValue}`, + }, + }); + + expect(receivedCookies).toBe(`longcookie=${longValue}`); + }); + + it("should handle empty cookie value", async () => { + let receivedCookies: string | null = null; + + const client = new DevhookClient({ + serverUrl: server.url, + secret: "empty-cookie-test", + onRequest: async (req) => { + receivedCookies = req.headers.get("cookie"); + return new Response("OK"); + }, + }); + + const disposable = client.connect(); + clientConnections.push(disposable); + await delay(200); + + const devhookId = await getDevhookId("empty-cookie-test", serverSecret); + await fetch(getDevhookUrl(server, devhookId, "/"), { + headers: { + "Cookie": "empty=", + }, + }); + + expect(receivedCookies).toBe("empty="); + }); + + it("should handle cookies with unicode characters (URL-encoded)", async () => { + let receivedCookies: string | null = null; + + const client = new DevhookClient({ + serverUrl: server.url, + secret: "unicode-cookie-test", + onRequest: async (req) => { + receivedCookies = req.headers.get("cookie"); + return new Response("OK"); + }, + }); + + const disposable = client.connect(); + clientConnections.push(disposable); + await delay(200); + + const devhookId = await getDevhookId("unicode-cookie-test", serverSecret); + // URL-encoded "值" (Chinese character for "value") + await fetch(getDevhookUrl(server, devhookId, "/"), { + headers: { + "Cookie": "name=%E5%80%BC", + }, + }); + + expect(receivedCookies).toBe("name=%E5%80%BC"); + }); + }); + + describe("header edge cases", () => { + let clientConnections: Array<{ dispose: () => void }> = []; + + afterEach(() => { + for (const conn of clientConnections) { + conn.dispose(); + } + clientConnections = []; + }); + + it("should handle empty header value", async () => { + let receivedHeader: string | null = null; + + const client = new DevhookClient({ + serverUrl: server.url, + secret: "empty-header-test", + onRequest: async (req) => { + receivedHeader = req.headers.get("x-empty"); + return new Response("OK", { + headers: { "x-empty-response": "" }, + }); + }, + }); + + const disposable = client.connect(); + clientConnections.push(disposable); + await delay(200); + + const devhookId = await getDevhookId("empty-header-test", serverSecret); + const response = await fetch(getDevhookUrl(server, devhookId, "/"), { + headers: { "x-empty": "" }, + }); + + expect(response.status).toBe(200); + // Empty headers may be preserved or stripped depending on implementation + expect(receivedHeader === "" || receivedHeader === null).toBe(true); + }); + + it("should handle very long header values", async () => { + const longValue = "x".repeat(8000); + let receivedHeader: string | null = null; + + const client = new DevhookClient({ + serverUrl: server.url, + secret: "long-header-test", + onRequest: async (req) => { + receivedHeader = req.headers.get("x-long"); + return new Response("OK", { + headers: { "x-long-response": longValue }, + }); + }, + }); + + const disposable = client.connect(); + clientConnections.push(disposable); + await delay(200); + + const devhookId = await getDevhookId("long-header-test", serverSecret); + const response = await fetch(getDevhookUrl(server, devhookId, "/"), { + headers: { "x-long": longValue }, + }); + + expect(response.status).toBe(200); + expect(receivedHeader).toBe(longValue); + expect(response.headers.get("x-long-response")).toBe(longValue); + }); + + it("should handle many headers", async () => { + const numHeaders = 100; + const sentHeaders: Record = {}; + for (let i = 0; i < numHeaders; i++) { + sentHeaders[`x-header-${i}`] = `value-${i}`; + } + + let receivedCount = 0; + + const client = new DevhookClient({ + serverUrl: server.url, + secret: "many-headers-test", + onRequest: async (req) => { + for (let i = 0; i < numHeaders; i++) { + if (req.headers.get(`x-header-${i}`) === `value-${i}`) { + receivedCount++; + } + } + return new Response("OK", { headers: sentHeaders }); + }, + }); + + const disposable = client.connect(); + clientConnections.push(disposable); + await delay(200); + + const devhookId = await getDevhookId("many-headers-test", serverSecret); + const response = await fetch(getDevhookUrl(server, devhookId, "/"), { + headers: sentHeaders, + }); + + expect(response.status).toBe(200); + expect(receivedCount).toBe(numHeaders); + + // Check response headers + for (let i = 0; i < numHeaders; i++) { + expect(response.headers.get(`x-header-${i}`)).toBe(`value-${i}`); + } + }); + + it("should preserve header value case", async () => { + let receivedValue: string | null = null; + + const client = new DevhookClient({ + serverUrl: server.url, + secret: "header-case-test", + onRequest: async (req) => { + receivedValue = req.headers.get("x-mixed-case"); + return new Response("OK", { + headers: { "X-Response-Mixed": "MixedCaseValue" }, + }); + }, + }); + + const disposable = client.connect(); + clientConnections.push(disposable); + await delay(200); + + const devhookId = await getDevhookId("header-case-test", serverSecret); + const response = await fetch(getDevhookUrl(server, devhookId, "/"), { + headers: { "X-Mixed-Case": "MixedCaseValue" }, + }); + + expect(response.status).toBe(200); + expect(receivedValue).toBe("MixedCaseValue"); + // Header names are case-insensitive, but values should be preserved + expect(response.headers.get("x-response-mixed")).toBe("MixedCaseValue"); + }); + + it("should preserve Content-Type with charset", async () => { + const client = new DevhookClient({ + serverUrl: server.url, + secret: "content-type-charset-test", + onRequest: async () => { + return new Response('{"test": true}', { + headers: { "Content-Type": "application/json; charset=utf-8" }, + }); + }, + }); + + const disposable = client.connect(); + clientConnections.push(disposable); + await delay(200); + + const devhookId = await getDevhookId("content-type-charset-test", serverSecret); + const response = await fetch(getDevhookUrl(server, devhookId, "/")); + + expect(response.status).toBe(200); + const contentType = response.headers.get("content-type"); + expect(contentType).toContain("application/json"); + expect(contentType).toContain("charset=utf-8"); + }); + + it("should preserve Accept header with quality values", async () => { + let receivedAccept: string | null = null; + + const client = new DevhookClient({ + serverUrl: server.url, + secret: "accept-quality-test", + onRequest: async (req) => { + receivedAccept = req.headers.get("accept"); + return new Response("OK"); + }, + }); + + const disposable = client.connect(); + clientConnections.push(disposable); + await delay(200); + + const devhookId = await getDevhookId("accept-quality-test", serverSecret); + await fetch(getDevhookUrl(server, devhookId, "/"), { + headers: { "Accept": "text/html, application/json;q=0.9, */*;q=0.8" }, + }); + + expect(receivedAccept).toBe("text/html, application/json;q=0.9, */*;q=0.8"); + }); + + it("should handle headers with leading/trailing whitespace in values", async () => { + let receivedHeader: string | null = null; + + const client = new DevhookClient({ + serverUrl: server.url, + secret: "whitespace-header-test", + onRequest: async (req) => { + receivedHeader = req.headers.get("x-whitespace"); + return new Response("OK"); + }, + }); + + const disposable = client.connect(); + clientConnections.push(disposable); + await delay(200); + + const devhookId = await getDevhookId("whitespace-header-test", serverSecret); + await fetch(getDevhookUrl(server, devhookId, "/"), { + headers: { "x-whitespace": " value with spaces " }, + }); + + // HTTP spec says leading/trailing whitespace should be trimmed + // but the exact behavior depends on implementation + expect(receivedHeader?.includes("value with spaces")).toBe(true); + }); + }); + + describe.skipIf(skipWebSocketTests)("websocket edge cases", () => { + let clientConnections: Array<{ dispose: () => void }> = []; + + afterEach(() => { + for (const conn of clientConnections) { + conn.dispose(); + } + clientConnections = []; + }); + + it("should handle text messages with UTF-8 multi-byte characters", async () => { + const testMessage = "Hello 世界 🌍 مرحبا"; + let receivedOnServer: string | undefined; + let receivedOnClient: string | undefined; + + const { WebSocketServer, WebSocket: WsClient } = await import("ws"); + const localWsServer = new WebSocketServer({ port: 0 }); + const localWsPort = (localWsServer.address() as { port: number }).port; + + localWsServer.on("connection", (ws) => { + ws.on("message", (data) => { + receivedOnServer = data.toString(); + ws.send(testMessage); + }); + }); + + const client = new DevhookClient({ + serverUrl: server.url, + secret: "ws-utf8-test", + transformUrl: (url) => { + url.host = `localhost:${localWsPort}`; + return url; + }, + onRequest: async (req) => { + const url = new URL(req.url); + url.host = `localhost:${localWsPort}`; + return fetch(new Request(url.toString(), req)); + }, + }); + + const disposable = client.connect(); + clientConnections.push(disposable); + await delay(200); + + const devhookId = await getDevhookId("ws-utf8-test", serverSecret); + const externalWs = new WsClient(getDevhookWsUrl(server, devhookId, "/ws")); + + await new Promise((resolve, reject) => { + externalWs.on("open", () => { + externalWs.send(testMessage); + }); + + externalWs.on("message", (data) => { + receivedOnClient = data.toString(); + resolve(); + }); + + externalWs.on("error", reject); + setTimeout(() => reject(new Error("Timeout")), 5000); + }); + + expect(receivedOnServer).toBe(testMessage); + expect(receivedOnClient).toBe(testMessage); + + externalWs.close(); + localWsServer.close(); + }); + + it("should handle large binary messages", { timeout: 30000 }, async () => { + // Use 64KB - a reasonable size that should work across implementations + const largeData = new Uint8Array(64 * 1024); + for (let i = 0; i < largeData.length; i++) { + largeData[i] = i % 256; + } + + let receivedSize = 0; + + const { WebSocketServer, WebSocket: WsClient } = await import("ws"); + const localWsServer = new WebSocketServer({ port: 0 }); + const localWsPort = (localWsServer.address() as { port: number }).port; + + localWsServer.on("connection", (ws) => { + ws.on("message", (data) => { + const buf = data as Buffer; + receivedSize = buf.length; + ws.send(buf); + }); + }); + + const client = new DevhookClient({ + serverUrl: server.url, + secret: "ws-large-binary-test", + transformUrl: (url) => { + url.host = `localhost:${localWsPort}`; + return url; + }, + onRequest: async (req) => { + const url = new URL(req.url); + url.host = `localhost:${localWsPort}`; + return fetch(new Request(url.toString(), req)); + }, + }); + + const disposable = client.connect(); + clientConnections.push(disposable); + await delay(200); + + const devhookId = await getDevhookId("ws-large-binary-test", serverSecret); + const externalWs = new WsClient(getDevhookWsUrl(server, devhookId, "/ws")); + + let echoedSize = 0; + await new Promise((resolve, reject) => { + externalWs.on("open", () => { + externalWs.send(largeData); + }); + + externalWs.on("message", (data) => { + echoedSize = (data as Buffer).length; + resolve(); + }); + + externalWs.on("error", reject); + setTimeout(() => reject(new Error("Timeout")), 25000); + }); + + expect(receivedSize).toBe(64 * 1024); + expect(echoedSize).toBe(64 * 1024); + + externalWs.close(); + localWsServer.close(); + }); + + it("should handle empty WebSocket messages", async () => { + let receivedEmpty = false; + + const { WebSocketServer, WebSocket: WsClient } = await import("ws"); + const localWsServer = new WebSocketServer({ port: 0 }); + const localWsPort = (localWsServer.address() as { port: number }).port; + + localWsServer.on("connection", (ws) => { + ws.on("message", (data) => { + if (data.toString() === "") { + receivedEmpty = true; + ws.send(""); + } + }); + }); + + const client = new DevhookClient({ + serverUrl: server.url, + secret: "ws-empty-msg-test", + transformUrl: (url) => { + url.host = `localhost:${localWsPort}`; + return url; + }, + onRequest: async (req) => { + const url = new URL(req.url); + url.host = `localhost:${localWsPort}`; + return fetch(new Request(url.toString(), req)); + }, + }); + + const disposable = client.connect(); + clientConnections.push(disposable); + await delay(200); + + const devhookId = await getDevhookId("ws-empty-msg-test", serverSecret); + const externalWs = new WsClient(getDevhookWsUrl(server, devhookId, "/ws")); + + let receivedEmptyEcho = false; + await new Promise((resolve, reject) => { + externalWs.on("open", () => { + externalWs.send(""); + }); + + externalWs.on("message", (data) => { + if (data.toString() === "") { + receivedEmptyEcho = true; + } + resolve(); + }); + + externalWs.on("error", reject); + setTimeout(() => reject(new Error("Timeout")), 5000); + }); + + expect(receivedEmpty).toBe(true); + expect(receivedEmptyEcho).toBe(true); + + externalWs.close(); + localWsServer.close(); + }); + + it("should handle rapid sequential messages", async () => { + const messageCount = 50; // Reduced count for reliability + const receivedMessages: Set = new Set(); + + const { WebSocketServer, WebSocket: WsClient } = await import("ws"); + const localWsServer = new WebSocketServer({ port: 0 }); + const localWsPort = (localWsServer.address() as { port: number }).port; + + localWsServer.on("connection", (ws) => { + ws.on("message", (data) => { + ws.send(`echo:${data.toString()}`); + }); + }); + + const client = new DevhookClient({ + serverUrl: server.url, + secret: "ws-rapid-test", + transformUrl: (url) => { + url.host = `localhost:${localWsPort}`; + return url; + }, + onRequest: async (req) => { + const url = new URL(req.url); + url.host = `localhost:${localWsPort}`; + return fetch(new Request(url.toString(), req)); + }, + }); + + const disposable = client.connect(); + clientConnections.push(disposable); + await delay(200); + + const devhookId = await getDevhookId("ws-rapid-test", serverSecret); + const externalWs = new WsClient(getDevhookWsUrl(server, devhookId, "/ws")); + + await new Promise((resolve, reject) => { + externalWs.on("open", () => { + for (let i = 0; i < messageCount; i++) { + externalWs.send(`msg-${i}`); + } + }); + + externalWs.on("message", (data) => { + receivedMessages.add(data.toString()); + if (receivedMessages.size >= messageCount) { + resolve(); + } + }); + + externalWs.on("error", reject); + setTimeout(() => reject(new Error("Timeout")), 10000); + }); + + expect(receivedMessages.size).toBe(messageCount); + // Verify all messages were received + for (let i = 0; i < messageCount; i++) { + expect(receivedMessages.has(`echo:msg-${i}`)).toBe(true); + } + + externalWs.close(); + localWsServer.close(); + }); + + it("should handle WebSocket close code 3000 (registered)", async () => { + let receivedCloseCode: number | undefined; + + const { WebSocketServer, WebSocket: WsClient } = await import("ws"); + const localWsServer = new WebSocketServer({ port: 0 }); + const localWsPort = (localWsServer.address() as { port: number }).port; + + localWsServer.on("connection", (ws) => { + ws.on("close", (code) => { + receivedCloseCode = code; + }); + }); + + const client = new DevhookClient({ + serverUrl: server.url, + secret: "ws-close-3000-test", + transformUrl: (url) => { + url.host = `localhost:${localWsPort}`; + return url; + }, + onRequest: async (req) => { + const url = new URL(req.url); + url.host = `localhost:${localWsPort}`; + return fetch(new Request(url.toString(), req)); + }, + }); + + const disposable = client.connect(); + clientConnections.push(disposable); + await delay(200); + + const devhookId = await getDevhookId("ws-close-3000-test", serverSecret); + const externalWs = new WsClient(getDevhookWsUrl(server, devhookId, "/ws")); + + await new Promise((resolve, reject) => { + externalWs.on("open", () => { + externalWs.close(3000, "Custom registered close"); + }); + + externalWs.on("close", () => { + setTimeout(resolve, 100); + }); + + externalWs.on("error", reject); + setTimeout(() => reject(new Error("Timeout")), 5000); + }); + + expect(receivedCloseCode).toBe(3000); + + localWsServer.close(); + }); + + it("should handle WebSocket close code 4000 (private use)", async () => { + let receivedCloseCode: number | undefined; + + const { WebSocketServer, WebSocket: WsClient } = await import("ws"); + const localWsServer = new WebSocketServer({ port: 0 }); + const localWsPort = (localWsServer.address() as { port: number }).port; + + localWsServer.on("connection", (ws) => { + ws.on("close", (code) => { + receivedCloseCode = code; + }); + }); + + const client = new DevhookClient({ + serverUrl: server.url, + secret: "ws-close-4000-test", + transformUrl: (url) => { + url.host = `localhost:${localWsPort}`; + return url; + }, + onRequest: async (req) => { + const url = new URL(req.url); + url.host = `localhost:${localWsPort}`; + return fetch(new Request(url.toString(), req)); + }, + }); + + const disposable = client.connect(); + clientConnections.push(disposable); + await delay(200); + + const devhookId = await getDevhookId("ws-close-4000-test", serverSecret); + const externalWs = new WsClient(getDevhookWsUrl(server, devhookId, "/ws")); + + await new Promise((resolve, reject) => { + externalWs.on("open", () => { + externalWs.close(4000, "Private use close"); + }); + + externalWs.on("close", () => { + setTimeout(resolve, 100); + }); + + externalWs.on("error", reject); + setTimeout(() => reject(new Error("Timeout")), 5000); + }); + + expect(receivedCloseCode).toBe(4000); + + localWsServer.close(); + }); + + it("should handle server-initiated WebSocket close", { timeout: 15000 }, async () => { + let clientReceivedClose = false; + let clientCloseCode: number | undefined; + + const { WebSocketServer, WebSocket: WsClient } = await import("ws"); + const localWsServer = new WebSocketServer({ port: 0 }); + const localWsPort = (localWsServer.address() as { port: number }).port; + + localWsServer.on("connection", (ws) => { + // Server initiates close after connection + setTimeout(() => { + ws.close(1000, "Server closing"); + }, 100); + }); + + const client = new DevhookClient({ + serverUrl: server.url, + secret: "ws-server-close-test", + transformUrl: (url) => { + url.host = `localhost:${localWsPort}`; + return url; + }, + onRequest: async (req) => { + const url = new URL(req.url); + url.host = `localhost:${localWsPort}`; + return fetch(new Request(url.toString(), req)); + }, + }); + + const disposable = client.connect(); + clientConnections.push(disposable); + await delay(200); + + const devhookId = await getDevhookId("ws-server-close-test", serverSecret); + const externalWs = new WsClient(getDevhookWsUrl(server, devhookId, "/ws")); + + await new Promise((resolve, reject) => { + externalWs.on("close", (code) => { + clientReceivedClose = true; + clientCloseCode = code; + resolve(); + }); + + externalWs.on("error", reject); + // Miniflare can be slow with WebSocket close propagation + setTimeout(() => reject(new Error("Timeout")), 12000); + }); + + expect(clientReceivedClose).toBe(true); + expect(clientCloseCode).toBe(1000); + + localWsServer.close(); + }); + + it("should handle multiple WebSocket message exchanges", async () => { + const { WebSocketServer, WebSocket: WsClient } = await import("ws"); + const localWsServer = new WebSocketServer({ port: 0 }); + const localWsPort = (localWsServer.address() as { port: number }).port; + + let serverMessageCount = 0; + localWsServer.on("connection", (ws) => { + ws.on("message", (data) => { + serverMessageCount++; + ws.send(`reply:${data.toString()}`); + }); + }); + + const client = new DevhookClient({ + serverUrl: server.url, + secret: "ws-exchange-test", + transformUrl: (url) => { + url.host = `localhost:${localWsPort}`; + return url; + }, + onRequest: async (req) => { + const url = new URL(req.url); + url.host = `localhost:${localWsPort}`; + return fetch(new Request(url.toString(), req)); + }, + }); + + const disposable = client.connect(); + clientConnections.push(disposable); + await delay(200); + + const devhookId = await getDevhookId("ws-exchange-test", serverSecret); + const externalWs = new WsClient(getDevhookWsUrl(server, devhookId, "/ws")); + + let clientMessageCount = 0; + + await new Promise((resolve, reject) => { + externalWs.on("open", () => { + // Send first message + externalWs.send("hello"); + }); + + externalWs.on("message", (data) => { + clientMessageCount++; + const msg = data.toString(); + + // After receiving reply to first message, send second + if (msg === "reply:hello") { + externalWs.send("world"); + } else if (msg === "reply:world") { + resolve(); + } + }); + + externalWs.on("error", reject); + setTimeout(() => reject(new Error("Timeout")), 5000); + }); + + // Verify bidirectional communication worked + // Note: Message counts may be higher due to proxy behavior + expect(serverMessageCount).toBeGreaterThanOrEqual(2); + expect(clientMessageCount).toBeGreaterThanOrEqual(2); + + externalWs.close(); + localWsServer.close(); + }); + + it("should handle WebSocket with query parameters", { timeout: 10000 }, async () => { + let receivedUrl: string | undefined; + + const { WebSocketServer, WebSocket: WsClient } = await import("ws"); + const localWsServer = new WebSocketServer({ port: 0 }); + const localWsPort = (localWsServer.address() as { port: number }).port; + + localWsServer.on("connection", (ws, req) => { + receivedUrl = req.url; + ws.send("connected"); + }); + + const client = new DevhookClient({ + serverUrl: server.url, + secret: "ws-query-test", + transformUrl: (url) => { + url.host = `localhost:${localWsPort}`; + return url; + }, + onRequest: async (req) => { + const url = new URL(req.url); + url.host = `localhost:${localWsPort}`; + return fetch(new Request(url.toString(), req)); + }, + }); + + const disposable = client.connect(); + clientConnections.push(disposable); + await delay(200); + + const devhookId = await getDevhookId("ws-query-test", serverSecret); + const externalWs = new WsClient(getDevhookWsUrl(server, devhookId, "/ws?token=abc123&user=test")); + + await new Promise((resolve, reject) => { + externalWs.on("open", () => { + // Give some time for the message to arrive + setTimeout(() => { + if (receivedUrl) { + resolve(); + } + }, 500); + }); + + externalWs.on("message", () => { + resolve(); + }); + + externalWs.on("error", reject); + setTimeout(() => reject(new Error("Timeout")), 8000); + }); + + expect(receivedUrl).toContain("token=abc123"); + expect(receivedUrl).toContain("user=test"); + + externalWs.close(); + localWsServer.close(); + }); + }); + + describe("request/response body edge cases", () => { + let clientConnections: Array<{ dispose: () => void }> = []; + + afterEach(() => { + for (const conn of clientConnections) { + conn.dispose(); + } + clientConnections = []; + }); + + it("should handle empty body with Content-Length: 0", async () => { + let receivedBody: string | undefined; + let receivedContentLength: string | null = null; + + const client = new DevhookClient({ + serverUrl: server.url, + secret: "empty-body-test", + onRequest: async (req) => { + receivedContentLength = req.headers.get("content-length"); + receivedBody = await req.text(); + return new Response("OK"); + }, + }); + + const disposable = client.connect(); + clientConnections.push(disposable); + await delay(200); + + const devhookId = await getDevhookId("empty-body-test", serverSecret); + const response = await fetch(getDevhookUrl(server, devhookId, "/"), { + method: "POST", + headers: { "Content-Length": "0" }, + body: "", + }); + + expect(response.status).toBe(200); + expect(receivedBody).toBe(""); + }); + + it("should handle large request body", { timeout: 30000 }, async () => { + const largeBody = "x".repeat(5 * 1024 * 1024); // 5MB + let receivedLength = 0; + + const client = new DevhookClient({ + serverUrl: server.url, + secret: "large-req-body-test", + onRequest: async (req) => { + const body = await req.text(); + receivedLength = body.length; + return new Response("OK"); + }, + }); + + const disposable = client.connect(); + clientConnections.push(disposable); + await delay(200); + + const devhookId = await getDevhookId("large-req-body-test", serverSecret); + const response = await fetch(getDevhookUrl(server, devhookId, "/"), { + method: "POST", + body: largeBody, + }); + + expect(response.status).toBe(200); + expect(receivedLength).toBe(5 * 1024 * 1024); + }); + + it("should handle large response body", { timeout: 30000 }, async () => { + const largeBody = "y".repeat(5 * 1024 * 1024); // 5MB + + const client = new DevhookClient({ + serverUrl: server.url, + secret: "large-resp-body-test", + onRequest: async () => { + return new Response(largeBody); + }, + }); + + const disposable = client.connect(); + clientConnections.push(disposable); + await delay(200); + + const devhookId = await getDevhookId("large-resp-body-test", serverSecret); + const response = await fetch(getDevhookUrl(server, devhookId, "/")); + + expect(response.status).toBe(200); + const body = await response.text(); + expect(body.length).toBe(5 * 1024 * 1024); + }); + + it("should handle binary request/response bodies", async () => { + // PNG-like binary data with null bytes + const binaryData = new Uint8Array([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, + 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, + ]); + let receivedBinary: Uint8Array | undefined; + + const client = new DevhookClient({ + serverUrl: server.url, + secret: "binary-body-test", + onRequest: async (req) => { + const buffer = await req.arrayBuffer(); + receivedBinary = new Uint8Array(buffer); + return new Response(binaryData, { + headers: { "Content-Type": "application/octet-stream" }, + }); + }, + }); + + const disposable = client.connect(); + clientConnections.push(disposable); + await delay(200); + + const devhookId = await getDevhookId("binary-body-test", serverSecret); + const response = await fetch(getDevhookUrl(server, devhookId, "/"), { + method: "POST", + headers: { "Content-Type": "application/octet-stream" }, + body: binaryData, + }); + + expect(response.status).toBe(200); + expect(receivedBinary).toEqual(binaryData); + + const responseBuffer = await response.arrayBuffer(); + expect(new Uint8Array(responseBuffer)).toEqual(binaryData); + }); + + it("should handle body with null bytes", async () => { + const dataWithNulls = new Uint8Array([0x00, 0x01, 0x00, 0x02, 0x00, 0x03]); + let receivedData: Uint8Array | undefined; + + const client = new DevhookClient({ + serverUrl: server.url, + secret: "null-bytes-test", + onRequest: async (req) => { + const buffer = await req.arrayBuffer(); + receivedData = new Uint8Array(buffer); + return new Response(dataWithNulls); + }, + }); + + const disposable = client.connect(); + clientConnections.push(disposable); + await delay(200); + + const devhookId = await getDevhookId("null-bytes-test", serverSecret); + const response = await fetch(getDevhookUrl(server, devhookId, "/"), { + method: "POST", + body: dataWithNulls, + }); + + expect(response.status).toBe(200); + expect(receivedData).toEqual(dataWithNulls); + + const responseBuffer = await response.arrayBuffer(); + expect(new Uint8Array(responseBuffer)).toEqual(dataWithNulls); + }); + + it("should handle JSON with unicode characters", async () => { + const jsonData = { name: "日本語", emoji: "🎉", arabic: "مرحبا" }; + let receivedJson: unknown; + + const client = new DevhookClient({ + serverUrl: server.url, + secret: "unicode-json-test", + onRequest: async (req) => { + receivedJson = await req.json(); + return new Response(JSON.stringify(jsonData), { + headers: { "Content-Type": "application/json" }, + }); + }, + }); + + const disposable = client.connect(); + clientConnections.push(disposable); + await delay(200); + + const devhookId = await getDevhookId("unicode-json-test", serverSecret); + const response = await fetch(getDevhookUrl(server, devhookId, "/"), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(jsonData), + }); + + expect(response.status).toBe(200); + expect(receivedJson).toEqual(jsonData); + + const responseJson = await response.json(); + expect(responseJson).toEqual(jsonData); + }); + + it("should handle URL-encoded form data", async () => { + let receivedBody: string | undefined; + + const client = new DevhookClient({ + serverUrl: server.url, + secret: "form-data-test", + onRequest: async (req) => { + receivedBody = await req.text(); + return new Response("OK"); + }, + }); + + const disposable = client.connect(); + clientConnections.push(disposable); + await delay(200); + + const devhookId = await getDevhookId("form-data-test", serverSecret); + const response = await fetch(getDevhookUrl(server, devhookId, "/"), { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: "name=test&value=hello%20world&special=%26%3D%3F", + }); + + expect(response.status).toBe(200); + expect(receivedBody).toBe("name=test&value=hello%20world&special=%26%3D%3F"); + }); + }); + + describe("connection edge cases", () => { + let clientConnections: Array<{ dispose: () => void }> = []; + + afterEach(() => { + for (const conn of clientConnections) { + conn.dispose(); + } + clientConnections = []; + }); + + it("should start processing requests before client disconnects", async () => { + let requestStarted = false; + + const client = new DevhookClient({ + serverUrl: server.url, + secret: "slow-request-test", + onRequest: async () => { + requestStarted = true; + // Short delay to verify request started + await delay(100); + return new Response("OK"); + }, + }); + + const disposable = client.connect(); + clientConnections.push(disposable); + await delay(200); + + const devhookId = await getDevhookId("slow-request-test", serverSecret); + + // Make a request + const response = await fetch(getDevhookUrl(server, devhookId, "/")); + + expect(requestStarted).toBe(true); + expect(response.status).toBe(200); + }); + + it("should handle rapid reconnect cycles", async () => { + const cycles = 5; + const devhookId = await getDevhookId("rapid-reconnect-test", serverSecret); + + for (let i = 0; i < cycles; i++) { + const client = new DevhookClient({ + serverUrl: server.url, + secret: "rapid-reconnect-test", + onRequest: async () => new Response(`cycle-${i}`), + }); + + const disposable = client.connect(); + await delay(200); + + const response = await fetch(getDevhookUrl(server, devhookId, "/")); + expect(response.status).toBe(200); + expect(await response.text()).toBe(`cycle-${i}`); + + disposable.dispose(); + await delay(50); + } + }); + + it("should handle many concurrent requests", { timeout: 30000 }, async () => { + const numRequests = 50; + let requestCount = 0; + + const client = new DevhookClient({ + serverUrl: server.url, + secret: "concurrent-test", + onRequest: async (req) => { + requestCount++; + const url = new URL(req.url); + return new Response(`request-${url.searchParams.get("n")}`); + }, + }); + + const disposable = client.connect(); + clientConnections.push(disposable); + await delay(200); + + const devhookId = await getDevhookId("concurrent-test", serverSecret); + + const promises = Array.from({ length: numRequests }, (_, i) => + fetch(getDevhookUrl(server, devhookId, `/?n=${i}`)) + ); + + const responses = await Promise.all(promises); + + for (let i = 0; i < numRequests; i++) { + expect(responses[i]!.status).toBe(200); + } + + expect(requestCount).toBe(numRequests); + }); + + it("should return 503 immediately after client disconnect", async () => { + const client = new DevhookClient({ + serverUrl: server.url, + secret: "immediate-503-test", + onRequest: async () => new Response("OK"), + }); + + const disposable = client.connect(); + await delay(200); + + const devhookId = await getDevhookId("immediate-503-test", serverSecret); + + // Verify client works + const response1 = await fetch(getDevhookUrl(server, devhookId, "/")); + expect(response1.status).toBe(200); + + // Disconnect + disposable.dispose(); + + // Immediate request should fail + const response2 = await fetch(getDevhookUrl(server, devhookId, "/")); + expect(response2.status).toBe(503); + }); + + it("should handle new client connection with same secret", { timeout: 10000 }, async () => { + const client1 = new DevhookClient({ + serverUrl: server.url, + secret: "replace-client-test", + onRequest: async () => new Response("client1"), + }); + + const disposable1 = client1.connect(); + await delay(300); + + const devhookId = await getDevhookId("replace-client-test", serverSecret); + + // Verify client1 works + const response1 = await fetch(getDevhookUrl(server, devhookId, "/")); + expect(await response1.text()).toBe("client1"); + + // Disconnect client1 first + disposable1.dispose(); + await delay(100); + + // Connect client2 with same secret + const client2 = new DevhookClient({ + serverUrl: server.url, + secret: "replace-client-test", + onRequest: async () => new Response("client2"), + }); + + const disposable2 = client2.connect(); + clientConnections.push(disposable2); + await delay(300); + + // Requests should now go to client2 + const response2 = await fetch(getDevhookUrl(server, devhookId, "/")); + expect(await response2.text()).toBe("client2"); + }); + }); + + describe("error handling", () => { + let clientConnections: Array<{ dispose: () => void }> = []; + + afterEach(() => { + for (const conn of clientConnections) { + conn.dispose(); + } + clientConnections = []; + }); + + it("should return 502 for handler errors", async () => { + const client = new DevhookClient({ + serverUrl: server.url, + secret: "error-message-test", + onRequest: async () => { + throw new Error("Specific error message"); + }, + }); + + const disposable = client.connect(); + clientConnections.push(disposable); + await delay(200); + + const devhookId = await getDevhookId("error-message-test", serverSecret); + const response = await fetch(getDevhookUrl(server, devhookId, "/")); + + expect(response.status).toBe(502); + // Error message format varies - just verify we got a 502 + const body = await response.text(); + expect(body.length).toBeGreaterThan(0); + }); + + it("should handle handler that returns rejected promise", async () => { + const client = new DevhookClient({ + serverUrl: server.url, + secret: "rejected-promise-test", + onRequest: async () => { + return Promise.reject(new Error("Async rejection")); + }, + }); + + const disposable = client.connect(); + clientConnections.push(disposable); + await delay(200); + + const devhookId = await getDevhookId("rejected-promise-test", serverSecret); + const response = await fetch(getDevhookUrl(server, devhookId, "/")); + + expect(response.status).toBe(502); + }); + + it("should handle various HTTP status codes correctly", async () => { + const statusCodes = [200, 201, 204, 301, 302, 400, 401, 403, 404, 500, 502, 503]; + + const client = new DevhookClient({ + serverUrl: server.url, + secret: "status-codes-test", + onRequest: async (req) => { + const url = new URL(req.url); + const status = parseInt(url.searchParams.get("status") || "200"); + // 204 should have no body + if (status === 204) { + return new Response(null, { status }); + } + return new Response(`Status: ${status}`, { status }); + }, + }); + + const disposable = client.connect(); + clientConnections.push(disposable); + await delay(200); + + const devhookId = await getDevhookId("status-codes-test", serverSecret); + + for (const status of statusCodes) { + const response = await fetch(getDevhookUrl(server, devhookId, `/?status=${status}`)); + expect(response.status).toBe(status); + } + }); + }); + + describe("URL handling", () => { + let clientConnections: Array<{ dispose: () => void }> = []; + + afterEach(() => { + for (const conn of clientConnections) { + conn.dispose(); + } + clientConnections = []; + }); + + it("should handle path with URL-encoded special characters", async () => { + let receivedPath: string | undefined; + + const client = new DevhookClient({ + serverUrl: server.url, + secret: "encoded-path-test", + onRequest: async (req) => { + receivedPath = new URL(req.url).pathname; + return new Response("OK"); + }, + }); + + const disposable = client.connect(); + clientConnections.push(disposable); + await delay(200); + + const devhookId = await getDevhookId("encoded-path-test", serverSecret); + const response = await fetch(getDevhookUrl(server, devhookId, "/api/users/name%20with%20spaces")); + + expect(response.status).toBe(200); + // Path should be decoded or preserved depending on implementation + expect(receivedPath).toMatch(/name(%20| )with(%20| )spaces/); + }); + + it("should handle query string with special characters", async () => { + let receivedQuery: string | undefined; + + const client = new DevhookClient({ + serverUrl: server.url, + secret: "special-query-test", + onRequest: async (req) => { + receivedQuery = new URL(req.url).search; + return new Response("OK"); + }, + }); + + const disposable = client.connect(); + clientConnections.push(disposable); + await delay(200); + + const devhookId = await getDevhookId("special-query-test", serverSecret); + // Encoded: & = ? in values + const response = await fetch(getDevhookUrl(server, devhookId, "/?search=hello%26world&name=foo%3Dbar")); + + expect(response.status).toBe(200); + expect(receivedQuery).toContain("search=hello%26world"); + expect(receivedQuery).toContain("name=foo%3Dbar"); + }); + + it("should handle double slashes in path", async () => { + let receivedPath: string | undefined; + + const client = new DevhookClient({ + serverUrl: server.url, + secret: "double-slash-test", + onRequest: async (req) => { + receivedPath = new URL(req.url).pathname; + return new Response("OK"); + }, + }); + + const disposable = client.connect(); + clientConnections.push(disposable); + await delay(200); + + const devhookId = await getDevhookId("double-slash-test", serverSecret); + const response = await fetch(getDevhookUrl(server, devhookId, "/api//data///test")); + + expect(response.status).toBe(200); + // Browsers/fetch may normalize slashes, but we should handle it + expect(receivedPath).toBeDefined(); + }); + }); + + describe("HTTP methods", () => { + let clientConnections: Array<{ dispose: () => void }> = []; + + afterEach(() => { + for (const conn of clientConnections) { + conn.dispose(); + } + clientConnections = []; + }); + + it("should handle all standard HTTP methods", async () => { + const methods = ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]; + const receivedMethods: string[] = []; + + const client = new DevhookClient({ + serverUrl: server.url, + secret: "http-methods-test", + onRequest: async (req) => { + receivedMethods.push(req.method); + return new Response("OK"); + }, + }); + + const disposable = client.connect(); + clientConnections.push(disposable); + await delay(200); + + const devhookId = await getDevhookId("http-methods-test", serverSecret); + + for (const method of methods) { + const response = await fetch(getDevhookUrl(server, devhookId, "/"), { + method, + }); + expect(response.status).toBe(200); + } + + expect(receivedMethods).toEqual(methods); + }); + + it("should handle HEAD request correctly", async () => { + const client = new DevhookClient({ + serverUrl: server.url, + secret: "head-request-test", + onRequest: async (req) => { + if (req.method === "HEAD") { + return new Response(null, { + headers: { + "Content-Length": "1000", + "Content-Type": "text/plain", + }, + }); + } + return new Response("body content", { + headers: { + "Content-Type": "text/plain", + }, + }); + }, + }); + + const disposable = client.connect(); + clientConnections.push(disposable); + await delay(200); + + const devhookId = await getDevhookId("head-request-test", serverSecret); + const response = await fetch(getDevhookUrl(server, devhookId, "/"), { + method: "HEAD", + }); + + expect(response.status).toBe(200); + // HEAD should return headers but no body + expect(response.headers.get("content-type")).toBe("text/plain"); + const body = await response.text(); + expect(body).toBe(""); + }); + + it("should handle OPTIONS request for CORS", async () => { + const client = new DevhookClient({ + serverUrl: server.url, + secret: "options-cors-test", + onRequest: async (req) => { + if (req.method === "OPTIONS") { + return new Response(null, { + status: 204, + headers: { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE", + "Access-Control-Allow-Headers": "Content-Type, Authorization", + "Access-Control-Max-Age": "86400", + }, + }); + } + return new Response("OK"); + }, + }); + + const disposable = client.connect(); + clientConnections.push(disposable); + await delay(200); + + const devhookId = await getDevhookId("options-cors-test", serverSecret); + const response = await fetch(getDevhookUrl(server, devhookId, "/"), { + method: "OPTIONS", + headers: { + "Access-Control-Request-Method": "POST", + "Access-Control-Request-Headers": "Content-Type", + }, + }); + + expect(response.status).toBe(204); + expect(response.headers.get("access-control-allow-origin")).toBe("*"); + expect(response.headers.get("access-control-allow-methods")).toContain("POST"); + }); + }); }); } From 73c68f7fb537f4f6b451107fd24db80ac1af4d6f Mon Sep 17 00:00:00 2001 From: Hugo Dutka Date: Mon, 15 Dec 2025 17:10:02 +0000 Subject: [PATCH 10/14] fix(devhook): prevent duplicate WebSocket message emissions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove duplicate PROXY_WEBSOCKET_MESSAGE and PROXY_WEBSOCKET_CLOSE handling from proxy() method. For WebSocket upgrades, bindStream() is already called, which registers an onData handler for these message types. Having both handlers caused duplicate event emissions. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- packages/devhook/src/server/worker.ts | 21 +++------------------ 1 file changed, 3 insertions(+), 18 deletions(-) diff --git a/packages/devhook/src/server/worker.ts b/packages/devhook/src/server/worker.ts index 93d9813..cac53c1 100644 --- a/packages/devhook/src/server/worker.ts +++ b/packages/devhook/src/server/worker.ts @@ -115,24 +115,9 @@ export class Worker { writeQueue = writeQueue.then(() => writer.write(payload)); break; } - case ClientMessageType.PROXY_WEBSOCKET_MESSAGE: { - this._onWebSocketMessage.emit({ - stream: stream.id, - message: parseWebSocketMessagePayload(payload, this.decoder), - }); - break; - } - case ClientMessageType.PROXY_WEBSOCKET_CLOSE: { - const closePayload = JSON.parse( - this.decoder.decode(payload) - ) as WebSocketClosePayload; - this._onWebSocketClose.emit({ - stream: stream.id, - code: closePayload.code ?? 1000, - reason: closePayload.reason ?? "", - }); - break; - } + // Note: PROXY_WEBSOCKET_MESSAGE and PROXY_WEBSOCKET_CLOSE are handled + // by bindStream() which is called for WebSocket upgrades. We don't + // handle them here to avoid duplicate event emissions. } }); From 96bfc343bd1cf9ecd4779097e7d63092d028f303 Mon Sep 17 00:00:00 2001 From: Hugo Dutka Date: Mon, 15 Dec 2025 17:13:16 +0000 Subject: [PATCH 11/14] test(devhook): use exact assertions now that duplication bug is fixed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Change WebSocket message count assertions from toBeGreaterThanOrEqual to toBe for exact counts, now that the duplicate event emission bug has been fixed in worker.ts. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- packages/devhook/src/shared.test-suite.ts | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/packages/devhook/src/shared.test-suite.ts b/packages/devhook/src/shared.test-suite.ts index 8bf9c43..f021f41 100644 --- a/packages/devhook/src/shared.test-suite.ts +++ b/packages/devhook/src/shared.test-suite.ts @@ -586,9 +586,9 @@ export function runSharedTests( await delay(200); - // Verify each connection received its own response + // Verify each connection received exactly one response for (let i = 0; i < numConnections; i++) { - expect(receivedMessages.get(i)!.length).toBeGreaterThanOrEqual(1); + expect(receivedMessages.get(i)!.length).toBe(1); expect(receivedMessages.get(i)![0]).toContain(`hello from ws${i}`); } @@ -694,10 +694,10 @@ export function runSharedTests( expect(messages1).not.toContain("message to client 2"); expect(messages2).not.toContain("message to client 1"); - // Verify responses came from correct servers - expect(received1.length).toBeGreaterThanOrEqual(1); + // Verify exactly one response from each server + expect(received1.length).toBe(1); expect(received1[0]).toContain("server1:"); - expect(received2.length).toBeGreaterThanOrEqual(1); + expect(received2.length).toBe(1); expect(received2[0]).toContain("server2:"); externalWs1.close(); @@ -1829,10 +1829,9 @@ export function runSharedTests( setTimeout(() => reject(new Error("Timeout")), 5000); }); - // Verify bidirectional communication worked - // Note: Message counts may be higher due to proxy behavior - expect(serverMessageCount).toBeGreaterThanOrEqual(2); - expect(clientMessageCount).toBeGreaterThanOrEqual(2); + // Verify bidirectional communication worked with exact message counts + expect(serverMessageCount).toBe(2); + expect(clientMessageCount).toBe(2); externalWs.close(); localWsServer.close(); From 11bce3635de48719f48e79f31a08d3266ae78264 Mon Sep 17 00:00:00 2001 From: Hugo Dutka Date: Mon, 15 Dec 2025 17:25:06 +0000 Subject: [PATCH 12/14] fix(devhook): preserve multiple Set-Cookie headers in proxy responses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Set-Cookie headers require special handling because: - Multiple cookies MUST be sent as separate headers (not comma-joined) - Cookie Expires dates contain commas (e.g., "Thu, 01 Jan 2026") Changes: - Add set_cookies field to ProxyInitResponse schema - Extract Set-Cookie headers separately using getSetCookie() on client - Reconstruct multiple Set-Cookie headers using headers.append() on server - Handle Set-Cookie as array in local server HTTP response 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- packages/devhook/src/client/index.ts | 9 ++++++++- packages/devhook/src/schema.ts | 2 ++ packages/devhook/src/server/local.ts | 14 ++++++++++++-- packages/devhook/src/server/worker.ts | 12 +++++++++++- packages/devhook/src/shared.test-suite.ts | 4 ++-- 5 files changed, 35 insertions(+), 6 deletions(-) diff --git a/packages/devhook/src/client/index.ts b/packages/devhook/src/client/index.ts index aa3264d..c420a6c 100644 --- a/packages/devhook/src/client/index.ts +++ b/packages/devhook/src/client/index.ts @@ -331,13 +331,20 @@ export class DevhookClient { // Send response headers const headers: Record = {}; response.headers.forEach((value, key) => { - headers[key] = value; + // Skip Set-Cookie - handled separately to preserve multiple cookies + if (key.toLowerCase() !== "set-cookie") { + headers[key] = value; + } }); + // Extract Set-Cookie headers separately (preserves multiple cookies) + const setCookies = response.headers.getSetCookie(); + const proxyInit: ProxyInitResponse = { status_code: response.status, status_message: response.statusText, headers, + set_cookies: setCookies.length > 0 ? setCookies : undefined, }; stream.writeTyped( diff --git a/packages/devhook/src/schema.ts b/packages/devhook/src/schema.ts index 946fe8d..0e1de0b 100644 --- a/packages/devhook/src/schema.ts +++ b/packages/devhook/src/schema.ts @@ -49,6 +49,8 @@ export interface ProxyInitResponse { status_code: number; status_message: string; headers: Record; + /** Set-Cookie headers must be sent separately to preserve multiple cookies */ + set_cookies?: string[]; } /** diff --git a/packages/devhook/src/server/local.ts b/packages/devhook/src/server/local.ts index e4e8c54..3f77d50 100644 --- a/packages/devhook/src/server/local.ts +++ b/packages/devhook/src/server/local.ts @@ -182,10 +182,20 @@ export function createLocalServer(opts: LocalServerOptions): { } // Write response headers - const responseHeaders: Record = {}; + const responseHeaders: Record = {}; response.headers.forEach((value, key) => { - responseHeaders[key] = value; + // Skip Set-Cookie - handled separately to preserve multiple cookies + if (key.toLowerCase() !== "set-cookie") { + responseHeaders[key] = value; + } }); + + // Handle multiple Set-Cookie headers (Node.js requires array for multiple values) + const setCookies = response.headers.getSetCookie(); + if (setCookies.length > 0) { + responseHeaders["Set-Cookie"] = setCookies; + } + res.writeHead(response.status, response.statusText, responseHeaders); // Stream response body diff --git a/packages/devhook/src/server/worker.ts b/packages/devhook/src/server/worker.ts index cac53c1..06c5e1b 100644 --- a/packages/devhook/src/server/worker.ts +++ b/packages/devhook/src/server/worker.ts @@ -101,9 +101,19 @@ export class Worker { const parsed = JSON.parse( this.decoder.decode(payload) ) as ProxyInitResponse; + + const headers = new Headers(parsed.headers); + + // Restore multiple Set-Cookie headers + if (parsed.set_cookies) { + for (const cookie of parsed.set_cookies) { + headers.append("Set-Cookie", cookie); + } + } + resolveResponse({ status: parsed.status_code, - headers: new Headers(parsed.headers), + headers, statusText: parsed.status_message, body: body.readable, stream: stream.id, diff --git a/packages/devhook/src/shared.test-suite.ts b/packages/devhook/src/shared.test-suite.ts index f021f41..1ac909c 100644 --- a/packages/devhook/src/shared.test-suite.ts +++ b/packages/devhook/src/shared.test-suite.ts @@ -883,7 +883,7 @@ export function runSharedTests( clientConnections = []; }); - it.fails("should preserve multiple Set-Cookie headers", async () => { + it("should preserve multiple Set-Cookie headers", async () => { const client = new DevhookClient({ serverUrl: server.url, secret: "multi-cookie-test", @@ -913,7 +913,7 @@ export function runSharedTests( expect(setCookieHeaders).toContain("c=3; Path=/"); }); - it.fails("should handle Set-Cookie with comma in Expires date", async () => { + it("should handle Set-Cookie with comma in Expires date", async () => { const client = new DevhookClient({ serverUrl: server.url, secret: "cookie-expires-test", From e0e83e3d66cf60bf7a29279d6e4d8341581fd7ca Mon Sep 17 00:00:00 2001 From: Hugo Dutka Date: Mon, 15 Dec 2025 18:11:00 +0000 Subject: [PATCH 13/14] refactor(devhook): use rejection sampling for uniform ID generation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the previous base36 conversion with a new algorithm that uses rejection sampling to avoid modulo bias, ensuring perfectly uniform distribution of devhook IDs. Also adds domain separation with "blink-devhook" for better cryptographic hygiene. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- packages/devhook/src/server/crypto.ts | 122 +++++++++++++++----------- 1 file changed, 69 insertions(+), 53 deletions(-) diff --git a/packages/devhook/src/server/crypto.ts b/packages/devhook/src/server/crypto.ts index a606c6b..d68d701 100644 --- a/packages/devhook/src/server/crypto.ts +++ b/packages/devhook/src/server/crypto.ts @@ -1,62 +1,39 @@ /** * Cryptographic utilities for devhook URL generation. * - * The client presents a secret, and the server signs it with HMAC-SHA256 - * using its own server secret. The resulting signature is used to generate - * a deterministic 16-character subdomain that cannot be guessed without - * knowing the client secret. + * Deterministically derives a uniform 16-character base36 string from: + * - a client secret (password), and + * - a server secret key for HMAC. + * + * Core idea: + * - HMAC-SHA-256 is used as a deterministic pseudorandom function (PRF). + * - We map the PRF output into [0, 36^16) using rejection sampling to avoid modulo bias. * * We use base36 encoding (a-z, 0-9) to maximize entropy per character. * With 16 characters and 36 possible values each, we get: * 36^16 ≈ 7.96 × 10^24 ≈ 2^82.7 possible IDs - * - * This is significantly better than hex encoding which only provides: - * 16^16 = 2^64 possible IDs */ -/** Base36 alphabet: 0-9, a-z */ -const BASE36_ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyz"; +/** Domain separation constant for devhook ID generation */ +const DOMAIN = "blink-devhook"; /** - * Convert a Uint8Array to a base36 string. - * Uses the full entropy of the input bytes. - * - * @param bytes - The bytes to convert - * @param length - The desired output length - * @returns A base36 string of the specified length + * Convert 16 bytes to an unsigned 128-bit BigInt (big-endian). */ -function bytesToBase36(bytes: Uint8Array, length: number): string { - // We need to convert arbitrary bytes to base36. - // To do this properly and use all entropy, we treat the bytes as a big integer - // and repeatedly divide by 36, taking the remainder as each character. - // - // For 16 base36 characters, we need at least ceil(16 * log2(36) / 8) = ceil(82.7 / 8) = 11 bytes - // SHA-256 gives us 32 bytes, so we have plenty of entropy. - - // Convert bytes to a BigInt (big-endian) - let num = BigInt(0); - for (const byte of bytes) { - num = (num << BigInt(8)) | BigInt(byte); +function bytesToBigInt128(bytes: Uint8Array, off: number): bigint { + let x = 0n; + for (let i = 0; i < 16; i++) { + x = (x << 8n) + BigInt(bytes[off + i]!); } - - // Convert to base36 - const result: string[] = []; - const base = BigInt(36); - - for (let i = 0; i < length; i++) { - const remainder = num % base; - result.unshift(BASE36_ALPHABET[Number(remainder)]!); - num = num / base; - } - - return result.join(""); + return x; } /** * Generate a secure devhook ID from a client secret. - * Uses HMAC-SHA256 with the server secret, then converts to base36. + * Uses HMAC-SHA256 with the server secret, then converts to base36 using + * rejection sampling to ensure uniform distribution. * - * @param clientSecret - The secret provided by the client + * @param clientSecret - The secret provided by the client (password) * @param serverSecret - The server's secret key for signing * @returns A 16-character base36 devhook ID (a-z, 0-9) */ @@ -64,26 +41,65 @@ export async function generateDevhookId( clientSecret: string, serverSecret: string ): Promise { - const encoder = new TextEncoder(); + const enc = new TextEncoder(); + + // WebCrypto works with bytes; TextEncoder gives deterministic UTF-8 bytes. + const keyBytes = enc.encode(serverSecret); - // Import the server secret as an HMAC key - const key = await crypto.subtle.importKey( + // Import the HMAC key into WebCrypto. + const cryptoKey = await crypto.subtle.importKey( "raw", - encoder.encode(serverSecret), + keyBytes, { name: "HMAC", hash: "SHA-256" }, false, ["sign"] ); - // Sign the client secret - const signature = await crypto.subtle.sign( - "HMAC", - key, - encoder.encode(clientSecret) - ); + // We want a 16-character base36 string. That's exactly the integers in [0, 36^16). + const N = 36n ** 16n; // size of the output space + + // We'll draw 128-bit candidates from the PRF output and "reject" ones that would bias the mapping. + const TWO128 = 1n << 128n; + + // Rejection sampling threshold: + // limit is the largest multiple of N that is < 2^128. + // If we only accept x < limit, then x % N is perfectly uniform in [0, N). + const limit = (TWO128 / N) * N; + + // One HMAC-SHA-256 output is 32 bytes, which conveniently contains two independent + // 128-bit candidates (first 16 bytes and last 16 bytes). + // + // Rejection sampling very rarely rejects when N is close to 2^k, but we still implement + // the correct rejection loop to guarantee uniformity. + for (let ctr = 0; ctr < 1000; ctr++) { + // Build the message to MAC: + // - DOMAIN: separates different uses / versions + // - clientSecret: user input + // - ctr: deterministic retry stream + // + // The \0 separators avoid ambiguous concatenations like ("ab", "c") vs ("a", "bc"). + const msg = enc.encode(`${DOMAIN}\0${clientSecret}\0${ctr}`); + + // HMAC the message. WebCrypto returns an ArrayBuffer; wrap in Uint8Array for byte access. + const mac = new Uint8Array(await crypto.subtle.sign("HMAC", cryptoKey, msg)); + + // Try two 128-bit candidates per MAC output. + for (const off of [0, 16]) { + const x = bytesToBigInt128(mac, off); + + // Reject values in the "tail" [limit, 2^128) because modulo would make some outputs + // slightly more likely than others (modulo bias). + if (x < limit) { + const y = x % N; // now y is uniform in [0, 36^16) + + // Convert to base36 and left-pad with '0' to ensure fixed length of 16 chars. + return y.toString(36).padStart(16, "0"); + } + } + } - // Convert to base36 (using all 32 bytes of SHA-256 output) - return bytesToBase36(new Uint8Array(signature), 16); + // If you ever hit this (extremely unlikely), increase the loop bound. + throw new Error("Unexpected: too many rejections; increase loop bound."); } /** From 59c571b94816f05bda454b8946fb70d430e3a5cf Mon Sep 17 00:00:00 2001 From: Hugo Dutka Date: Tue, 16 Dec 2025 13:47:34 +0100 Subject: [PATCH 14/14] transformUrl -> transformWebSocketRequest --- bun.lock | 15 +- packages/devhook/README.md | 14 +- packages/devhook/examples/client.ts | 78 ++ packages/devhook/examples/server.ts | 40 + packages/devhook/src/client/index.ts | 49 +- packages/devhook/src/devhook.test.ts | 12 +- packages/devhook/src/index.ts | 6 +- .../src/server/cloudflare.test-adapter.ts | 41 +- packages/devhook/src/server/cloudflare.ts | 21 +- packages/devhook/src/server/crypto.ts | 4 +- packages/devhook/src/server/durable-object.ts | 15 +- packages/devhook/src/server/local.ts | 39 +- packages/devhook/src/shared.test-suite.ts | 865 +++++++++++------- packages/devhook/src/test-utils.ts | 62 +- 14 files changed, 838 insertions(+), 423 deletions(-) create mode 100644 packages/devhook/examples/client.ts create mode 100644 packages/devhook/examples/server.ts diff --git a/bun.lock b/bun.lock index ed04fa8..5942e29 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "blink-repo", @@ -1725,7 +1726,7 @@ "@types/mysql": ["@types/mysql@2.15.27", "", { "dependencies": { "@types/node": "*" } }, "sha512-YfWiV16IY0OeBfBCk8+hXKmdTKrKlwKN1MNKAPBu5JYxLwBEZl7QzeEpGnlZb3VMGJrrGmB84gXiH+ofs/TezA=="], - "@types/node": ["@types/node@25.0.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-rl78HwuZlaDIUSeUKkmogkhebA+8K1Hy7tddZuJ3D0xV8pZSfsYGTsliGUol1JPzu9EKnTxPC4L1fiWouStRew=="], + "@types/node": ["@types/node@25.0.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-gWEkeiyYE4vqjON/+Obqcoeffmk0NF15WSBwSs7zwVA2bAbTaE0SJ7P0WNGoJn8uE7fiaV5a7dKYIJriEqOrmA=="], "@types/normalize-package-data": ["@types/normalize-package-data@2.4.4", "", {}, "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA=="], @@ -1851,7 +1852,7 @@ "aggregate-error": ["aggregate-error@3.1.0", "", { "dependencies": { "clean-stack": "^2.0.0", "indent-string": "^4.0.0" } }, "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA=="], - "ai": ["ai@5.0.110", "", { "dependencies": { "@ai-sdk/gateway": "2.0.19", "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.18", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZBq+5bvef4e5qoIG4U6NJ1UpCPWGjuaWERHXbHu2T2ND3c02nJ2zlnjm+N6zAAplQPxwqm7Sb16mrRX5uQNWtQ=="], + "ai": ["ai@5.0.113", "", { "dependencies": { "@ai-sdk/gateway": "2.0.21", "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-26vivpSO/mzZj0k1Si2IpsFspp26ttQICHRySQiMrtWcRd5mnJMX2a8sG28vmZ38C+JUn1cWmfZrsLMxkSMw9g=="], "ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="], @@ -4065,7 +4066,7 @@ "youch-core": ["youch-core@0.3.3", "", { "dependencies": { "@poppinss/exception": "^1.2.2", "error-stack-parser-es": "^1.0.5" } }, "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA=="], - "zod": ["zod@4.1.13", "", {}, "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig=="], + "zod": ["zod@4.2.1", "", {}, "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw=="], "zod-to-json-schema": ["zod-to-json-schema@3.24.6", "", { "peerDependencies": { "zod": "^3.24.1" } }, "sha512-h/z3PKvcTcTetyjl1fkj79MHNEjm+HpD6NXheWjzOekY7kV+lwDYnHw+ivHkijnCSMz1yJaWBD9vu/Fcmk+vEg=="], @@ -4131,6 +4132,8 @@ "@blink-sdk/scout-agent/tsdown": ["tsdown@0.3.1", "", { "dependencies": { "cac": "^6.7.14", "chokidar": "^4.0.1", "consola": "^3.2.3", "debug": "^4.3.7", "picocolors": "^1.1.1", "pkg-types": "^1.2.1", "rolldown": "nightly", "tinyglobby": "^0.2.10", "unconfig": "^0.6.0", "unplugin-isolated-decl": "^0.7.2", "unplugin-unused": "^0.2.3" }, "bin": { "tsdown": "bin/tsdown.js" } }, "sha512-5WLFU7f2NRnsez0jxi7m2lEQNPvBOdos0W8vHvKDnS6tYTfOfmZ5D2z/G9pFTQSjeBhoi6BFRMybc4LzCOKR8A=="], + "@blink.so/api/zod": ["zod@4.1.13", "", {}, "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig=="], + "@blink.so/compute-protocol-worker/@blink-sdk/compute-protocol": ["@blink-sdk/compute-protocol@0.0.2", "", { "peerDependencies": { "ws": ">= 8", "zod": ">= 4" } }, "sha512-QD89Y4b3EbZjncROb6kwUr1uQV4N3UD9q7Hp2PzL4A2BAzsqk50w7KfN9RxfDiZ3fU7Pectg71T4M8ZCwdJcdQ=="], "@blink.so/site/dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="], @@ -4141,6 +4144,8 @@ "@blink/desktop/@blink.so/api": ["@blink.so/api@0.0.11", "", { "optionalDependencies": { "@blink-sdk/compute-protocol": ">= 0.0.2" }, "peerDependencies": { "ai": ">= 5", "react": ">= 18", "zod": ">= 4" }, "optionalPeers": ["react"] }, "sha512-4JW0fsGFn8IN5r+FpdbkqXkFqyCXQ8sDXoETdIBczLe3/+JP0Q2ItvN9XtR/eLNIshIL9Yz+gZtB6AVWQIcIWg=="], + "@blink/desktop/ai": ["ai@5.0.110", "", { "dependencies": { "@ai-sdk/gateway": "2.0.19", "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.18", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZBq+5bvef4e5qoIG4U6NJ1UpCPWGjuaWERHXbHu2T2ND3c02nJ2zlnjm+N6zAAplQPxwqm7Sb16mrRX5uQNWtQ=="], + "@blink/desktop/esbuild": ["esbuild@0.25.10", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.10", "@esbuild/android-arm": "0.25.10", "@esbuild/android-arm64": "0.25.10", "@esbuild/android-x64": "0.25.10", "@esbuild/darwin-arm64": "0.25.10", "@esbuild/darwin-x64": "0.25.10", "@esbuild/freebsd-arm64": "0.25.10", "@esbuild/freebsd-x64": "0.25.10", "@esbuild/linux-arm": "0.25.10", "@esbuild/linux-arm64": "0.25.10", "@esbuild/linux-ia32": "0.25.10", "@esbuild/linux-loong64": "0.25.10", "@esbuild/linux-mips64el": "0.25.10", "@esbuild/linux-ppc64": "0.25.10", "@esbuild/linux-riscv64": "0.25.10", "@esbuild/linux-s390x": "0.25.10", "@esbuild/linux-x64": "0.25.10", "@esbuild/netbsd-arm64": "0.25.10", "@esbuild/netbsd-x64": "0.25.10", "@esbuild/openbsd-arm64": "0.25.10", "@esbuild/openbsd-x64": "0.25.10", "@esbuild/openharmony-arm64": "0.25.10", "@esbuild/sunos-x64": "0.25.10", "@esbuild/win32-arm64": "0.25.10", "@esbuild/win32-ia32": "0.25.10", "@esbuild/win32-x64": "0.25.10" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-9RiGKvCwaqxO2owP61uQ4BgNborAQskMR6QusfWzQqv7AZOg5oGehdY2pRJMTKuwxd1IDBP4rSbI5lHzU7SMsQ=="], "@blink/desktop/lucide-react": ["lucide-react@0.544.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-t5tS44bqd825zAW45UQxpG2CvcC4urOwn2TrwSH8u+MjeE+1NnWl6QqeQ/6NdjMqdOygyiT9p3Ev0p1NJykxjw=="], @@ -4535,6 +4540,10 @@ "aggregate-error/indent-string": ["indent-string@4.0.0", "", {}, "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg=="], + "ai/@ai-sdk/gateway": ["@ai-sdk/gateway@2.0.21", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19", "@vercel/oidc": "3.0.5" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-BwV7DU/lAm3Xn6iyyvZdWgVxgLu3SNXzl5y57gMvkW4nGhAOV5269IrJzQwGt03bb107sa6H6uJwWxc77zXoGA=="], + + "ai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.19", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-W41Wc9/jbUVXVwCN/7bWa4IKe8MtxO3EyA0Hfhx6grnmiYlCvpI8neSYWFE0zScXJkgA/YK3BRybzgyiXuu6JA=="], + "ajv-keywords/ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="], "ansi-align/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], diff --git a/packages/devhook/README.md b/packages/devhook/README.md index ec9acf6..a700ca3 100644 --- a/packages/devhook/README.md +++ b/packages/devhook/README.md @@ -24,10 +24,10 @@ import { DevhookClient } from "@blink-sdk/devhook"; const client = new DevhookClient({ serverUrl: "https://devhook.example.com", secret: "my-secret-key", - // Transform URLs to point to your local server (used for WebSocket connections) - transformUrl: (url) => { + // Transform WebSocket requests to point to your local server + transformWebSocketRequest: ({ url, headers }) => { url.host = "localhost:3000"; - return url; + return { url, headers }; }, // Handle HTTP requests onRequest: async (req) => { @@ -132,6 +132,7 @@ const server = createLocalServer({ 4. This becomes the devhook ID (subdomain or path prefix) This means: + - The same client secret always produces the same URL - URLs cannot be guessed without knowing the client secret - Different server secrets produce different URLs @@ -178,8 +179,11 @@ interface DevhookClientOptions { /** Client secret for URL generation */ secret: string; - /** Transform URL to point to local server (used for WebSocket connections) */ - transformUrl?: (url: URL) => URL; + /** Transform WebSocket requests before proxying to local server */ + transformWebSocketRequest?: (request: { + url: URL; + headers: Record; + }) => { url: URL; headers: Record }; /** Handle incoming proxied HTTP requests */ onRequest: (request: Request) => Promise; diff --git a/packages/devhook/examples/client.ts b/packages/devhook/examples/client.ts new file mode 100644 index 0000000..f6b38c2 --- /dev/null +++ b/packages/devhook/examples/client.ts @@ -0,0 +1,78 @@ +/** + * Example devhook client that proxies requests to localhost:8000. + * + * Run with: npx tsx examples/client.ts + * + * Make sure you have: + * 1. The devhook server running (npx tsx examples/server.ts) + * 2. A local server running on port 8000 (e.g., python -m http.server 8000) + */ + +import { DevhookClient } from "../src/client"; + +const SERVER_URL = "http://localhost:8080"; +const CLIENT_SECRET = "example-client-secret"; +const LOCAL_SERVER_PORT = 8000; + +const client = new DevhookClient({ + serverUrl: SERVER_URL, + secret: CLIENT_SECRET, + onRequest: async (request) => { + // Forward requests to the local server + const url = new URL(request.url); + url.host = `localhost:${LOCAL_SERVER_PORT}`; + url.protocol = "http:"; + + const newRequest = new Request(url.toString(), { + method: request.method, + headers: request.headers, + body: request.body, + // @ts-expect-error duplex is needed for streaming bodies + duplex: "half", + }); + + try { + return await fetch(newRequest); + } catch (error) { + console.error("Error forwarding request:", error); + return new Response( + JSON.stringify({ error: "Failed to connect to local server" }), + { + status: 502, + headers: { "Content-Type": "application/json" }, + } + ); + } + }, + onConnect: ({ url, id }) => { + console.log(`Connected to devhook server!`); + console.log(`Public URL: ${url}`); + console.log(`Devhook ID: ${id}`); + console.log( + `\nRequests to ${url}/* will be proxied to http://localhost:${LOCAL_SERVER_PORT}/*` + ); + }, + onDisconnect: () => { + console.log("Disconnected from devhook server"); + }, + onError: (error) => { + console.error("Devhook error:", error); + }, +}); + +console.log(`Connecting to devhook server at ${SERVER_URL}...`); +console.log(`Will proxy requests to http://localhost:${LOCAL_SERVER_PORT}`); + +const disposable = client.connect(); + +// Handle graceful shutdown +process.on("SIGINT", () => { + console.log("\nDisconnecting..."); + disposable.dispose(); + process.exit(0); +}); + +process.on("SIGTERM", () => { + disposable.dispose(); + process.exit(0); +}); diff --git a/packages/devhook/examples/server.ts b/packages/devhook/examples/server.ts new file mode 100644 index 0000000..7ec8614 --- /dev/null +++ b/packages/devhook/examples/server.ts @@ -0,0 +1,40 @@ +/** + * Example devhook server for local testing. + * + * Run with: npx tsx examples/server.ts + */ + +import { createLocalServer } from "../src/server/local"; + +const PORT = 8080; +const SERVER_SECRET = "example-server-secret"; + +const { close } = createLocalServer({ + port: PORT, + secret: SERVER_SECRET, + baseUrl: `http://localhost:${PORT}`, + mode: "subpath", + onReady: (port) => { + console.log(`Devhook server running on http://localhost:${port}`); + console.log(`Waiting for clients to connect...`); + }, + onClientConnect: (id) => { + console.log(`Client connected: ${id}`); + console.log(`Public URL: http://localhost:${PORT}/devhook/${id}`); + }, + onClientDisconnect: (id) => { + console.log(`Client disconnected: ${id}`); + }, +}); + +// Handle graceful shutdown +process.on("SIGINT", () => { + console.log("\nShutting down server..."); + close(); + process.exit(0); +}); + +process.on("SIGTERM", () => { + close(); + process.exit(0); +}); diff --git a/packages/devhook/src/client/index.ts b/packages/devhook/src/client/index.ts index c420a6c..2e47246 100644 --- a/packages/devhook/src/client/index.ts +++ b/packages/devhook/src/client/index.ts @@ -12,6 +12,15 @@ import { type WebSocketClosePayload, } from "../schema"; +/** + * Represents a WebSocket connection request that can be transformed + * before the connection is established to the local server. + */ +export interface WebSocketRequest { + url: URL; + headers: Record; +} + export interface DevhookClientOptions { /** * The devhook server URL. @@ -49,19 +58,22 @@ export interface DevhookClientOptions { onError?: (error: unknown) => void; /** - * Transform the incoming URL to point to the local target. - * This is used for both HTTP requests (via onRequest) and WebSocket connections. - * If not provided, URLs are used as-is. + * Transform WebSocket connection requests before they are proxied to the local server. + * Allows modifying both the target URL and headers. + * If not provided, WebSocket requests are proxied as-is. + * + * Note: This only applies to WebSocket connections. HTTP requests should be + * transformed in the `onRequest` callback. * * @example * ```ts - * transformUrl: (url) => { + * transformWebSocketRequest: ({ url, headers }) => { * url.host = "localhost:3000"; - * return url; + * return { url, headers }; * } * ``` */ - transformUrl?: (url: URL) => URL; + transformWebSocketRequest?: (request: WebSocketRequest) => WebSocketRequest; } /** @@ -400,20 +412,29 @@ export class DevhookClient { init: ProxyInitRequest ): Promise { try { - // Transform the URL using the same pattern as onRequest - // This allows the user to map the devhook URL to the local target + // Transform the WebSocket request (URL and headers) before connecting let targetUrl = new URL(init.url); + let targetHeaders = { ...init.headers }; - if (this.opts.transformUrl) { - targetUrl = this.opts.transformUrl(targetUrl); + if (this.opts.transformWebSocketRequest) { + const transformed = this.opts.transformWebSocketRequest({ + url: targetUrl, + headers: targetHeaders, + }); + targetUrl = transformed.url; + targetHeaders = transformed.headers; } targetUrl.protocol = targetUrl.protocol === "https:" ? "wss:" : "ws:"; - const ws = new WebSocket(targetUrl.toString(), init.headers["sec-websocket-protocol"], { - headers: init.headers, - perMessageDeflate: false, - }); + const ws = new WebSocket( + targetUrl.toString(), + targetHeaders["sec-websocket-protocol"], + { + headers: targetHeaders, + perMessageDeflate: false, + } + ); ws.on("open", () => { const proxyInit: ProxyInitResponse = { diff --git a/packages/devhook/src/devhook.test.ts b/packages/devhook/src/devhook.test.ts index f3fa41e..7ff91e3 100644 --- a/packages/devhook/src/devhook.test.ts +++ b/packages/devhook/src/devhook.test.ts @@ -45,7 +45,11 @@ describe("devhook", () => { const isValid = await verifyDevhookId(id, CLIENT_SECRET, SERVER_SECRET); expect(isValid).toBe(true); - const isInvalid = await verifyDevhookId(id, "wrong-secret", SERVER_SECRET); + const isInvalid = await verifyDevhookId( + id, + "wrong-secret", + SERVER_SECRET + ); expect(isInvalid).toBe(false); }); @@ -80,5 +84,9 @@ describe("devhook", () => { }); // Run shared tests against local server - runSharedTests("local", createLocalServerFactory(SERVER_SECRET), SERVER_SECRET); + runSharedTests( + "local", + createLocalServerFactory(SERVER_SECRET), + SERVER_SECRET + ); }); diff --git a/packages/devhook/src/index.ts b/packages/devhook/src/index.ts index b13aa69..fe5898b 100644 --- a/packages/devhook/src/index.ts +++ b/packages/devhook/src/index.ts @@ -62,6 +62,10 @@ * ``` */ -export { DevhookClient, type DevhookClientOptions } from "./client"; +export { + DevhookClient, + type DevhookClientOptions, + type WebSocketRequest, +} from "./client"; export type { Disposable } from "./emitter"; export type { ConnectionEstablished } from "./schema"; diff --git a/packages/devhook/src/server/cloudflare.test-adapter.ts b/packages/devhook/src/server/cloudflare.test-adapter.ts index 18dd49f..750ffdd 100644 --- a/packages/devhook/src/server/cloudflare.test-adapter.ts +++ b/packages/devhook/src/server/cloudflare.test-adapter.ts @@ -20,29 +20,28 @@ interface CloudflareTestServer extends TestServer { /** * Create a Cloudflare Worker server for testing using wrangler's unstable_dev. */ -export async function createCloudflareTestServer(secret: string): Promise { +export async function createCloudflareTestServer( + secret: string +): Promise { // Use a specific port for the worker const port = 9787 + Math.floor(Math.random() * 1000); // Use the wrangler.toml for proper Durable Object configuration - const worker = await unstable_dev( - join(__dirname, "cloudflare.ts"), - { - experimental: { - disableExperimentalWarning: true, - }, - // Use wrangler.toml for DO bindings but override vars - config: join(packageRoot, "wrangler.toml"), - vars: { - DEVHOOK_SECRET: secret, - DEVHOOK_BASE_URL: `http://localhost:${port}`, - DEVHOOK_MODE: "subpath", - }, - local: true, - persist: false, - port, - } - ); + const worker = await unstable_dev(join(__dirname, "cloudflare.ts"), { + experimental: { + disableExperimentalWarning: true, + }, + // Use wrangler.toml for DO bindings but override vars + config: join(packageRoot, "wrangler.toml"), + vars: { + DEVHOOK_SECRET: secret, + DEVHOOK_BASE_URL: `http://localhost:${port}`, + DEVHOOK_MODE: "subpath", + }, + local: true, + persist: false, + port, + }); const url = `http://127.0.0.1:${port}`; @@ -73,6 +72,8 @@ export async function createCloudflareTestServer(secret: string): Promise { +export const createCloudflareServerFactory = ( + secret: string +): TestServerFactory => { return async () => createCloudflareTestServer(secret); }; diff --git a/packages/devhook/src/server/cloudflare.ts b/packages/devhook/src/server/cloudflare.ts index e40083d..e71c33f 100644 --- a/packages/devhook/src/server/cloudflare.ts +++ b/packages/devhook/src/server/cloudflare.ts @@ -52,7 +52,10 @@ export default { /** * Handle a client connecting to establish a devhook. */ -async function handleClientConnect(request: Request, env: Env): Promise { +async function handleClientConnect( + request: Request, + env: Env +): Promise { // Verify WebSocket upgrade if (request.headers.get("upgrade") !== "websocket") { return new Response( @@ -94,11 +97,13 @@ async function handleClientConnect(request: Request, env: Env): Promise { const sessionId = env.DEVHOOK_SESSION.idFromName(devhookId); - const session = env.DEVHOOK_SESSION.get(sessionId) as unknown as DevhookSession; + const session = env.DEVHOOK_SESSION.get( + sessionId + ) as unknown as DevhookSession; // Build the proxy URL const url = new URL(request.url); diff --git a/packages/devhook/src/server/crypto.ts b/packages/devhook/src/server/crypto.ts index d68d701..524c57e 100644 --- a/packages/devhook/src/server/crypto.ts +++ b/packages/devhook/src/server/crypto.ts @@ -81,7 +81,9 @@ export async function generateDevhookId( const msg = enc.encode(`${DOMAIN}\0${clientSecret}\0${ctr}`); // HMAC the message. WebCrypto returns an ArrayBuffer; wrap in Uint8Array for byte access. - const mac = new Uint8Array(await crypto.subtle.sign("HMAC", cryptoKey, msg)); + const mac = new Uint8Array( + await crypto.subtle.sign("HMAC", cryptoKey, msg) + ); // Try two 128-bit candidates per MAC output. for (const off of [0, 16]) { diff --git a/packages/devhook/src/server/durable-object.ts b/packages/devhook/src/server/durable-object.ts index 1011534..7edb902 100644 --- a/packages/devhook/src/server/durable-object.ts +++ b/packages/devhook/src/server/durable-object.ts @@ -61,7 +61,10 @@ export class DevhookSession extends DurableObject { const url = new URL(request.url); // Proxy request (check BEFORE WebSocket upgrade since proxied WS also has upgrade header) - if (url.pathname === "/proxy" || request.headers.has("x-devhook-proxy-url")) { + if ( + url.pathname === "/proxy" || + request.headers.has("x-devhook-proxy-url") + ) { return this.handleProxyRequest(request); } @@ -168,7 +171,10 @@ export class DevhookSession extends DurableObject { type: "proxied", streamID: response.stream, }); - this.ctx.acceptWebSocket(server, ["proxied", response.stream.toString()]); + this.ctx.acceptWebSocket(server, [ + "proxied", + response.stream.toString(), + ]); return new Response(null, { status: 101, @@ -241,7 +247,10 @@ export class DevhookSession extends DurableObject { /** * Handle WebSocket close. */ - public override async webSocketClose(ws: WebSocket, code: number): Promise { + public override async webSocketClose( + ws: WebSocket, + code: number + ): Promise { const state = ws.deserializeAttachment(); switch (state.type) { diff --git a/packages/devhook/src/server/local.ts b/packages/devhook/src/server/local.ts index 3f77d50..c9050be 100644 --- a/packages/devhook/src/server/local.ts +++ b/packages/devhook/src/server/local.ts @@ -107,7 +107,12 @@ export function createLocalServer(opts: LocalServerOptions): { } // Extract devhook ID - const devhookId = extractDevhookId(url, opts.baseUrl, mode, req.headers.host); + const devhookId = extractDevhookId( + url, + opts.baseUrl, + mode, + req.headers.host + ); if (!devhookId) { res.writeHead(404, { "content-type": "application/json" }); res.end( @@ -175,7 +180,8 @@ export function createLocalServer(opts: LocalServerOptions): { res.end( JSON.stringify({ error: "Bad request", - message: "WebSocket upgrade requests must use the WebSocket protocol.", + message: + "WebSocket upgrade requests must use the WebSocket protocol.", }) ); return; @@ -331,7 +337,12 @@ export function createLocalServer(opts: LocalServerOptions): { } // Handle proxied WebSocket connections (external -> devhook -> local) - const devhookId = extractDevhookId(url, opts.baseUrl, mode, req.headers.host); + const devhookId = extractDevhookId( + url, + opts.baseUrl, + mode, + req.headers.host + ); if (!devhookId) { socket.write("HTTP/1.1 404 Not Found\r\n\r\n"); socket.destroy(); @@ -339,7 +350,12 @@ export function createLocalServer(opts: LocalServerOptions): { } const session = sessions.get(devhookId); - if (!session || !session.ws || session.ws.readyState !== WebSocket.OPEN || !session.worker) { + if ( + !session || + !session.ws || + session.ws.readyState !== WebSocket.OPEN || + !session.worker + ) { socket.write("HTTP/1.1 503 Service Unavailable\r\n\r\n"); socket.destroy(); return; @@ -374,7 +390,9 @@ export function createLocalServer(opts: LocalServerOptions): { if (!response.upgrade) { // The local server didn't accept the WebSocket upgrade - socket.write(`HTTP/1.1 ${response.status} ${response.statusText}\r\n\r\n`); + socket.write( + `HTTP/1.1 ${response.status} ${response.statusText}\r\n\r\n` + ); socket.destroy(); return; } @@ -388,11 +406,12 @@ export function createLocalServer(opts: LocalServerOptions): { // Forward messages from external WebSocket to devhook client externalWs.on("message", (data: Buffer | ArrayBuffer | Buffer[]) => { - const payload = data instanceof ArrayBuffer - ? new Uint8Array(data) - : Array.isArray(data) - ? Buffer.concat(data) - : data; + const payload = + data instanceof ArrayBuffer + ? new Uint8Array(data) + : Array.isArray(data) + ? Buffer.concat(data) + : data; worker.sendProxiedWebSocketMessage(streamID, payload); }); diff --git a/packages/devhook/src/shared.test-suite.ts b/packages/devhook/src/shared.test-suite.ts index 1ac909c..53be8e3 100644 --- a/packages/devhook/src/shared.test-suite.ts +++ b/packages/devhook/src/shared.test-suite.ts @@ -117,7 +117,9 @@ export function runSharedTests( await delay(200); const devhookId = await getDevhookId("get-test", serverSecret); - const response = await fetch(getDevhookUrl(server, devhookId, "/api/data")); + const response = await fetch( + getDevhookUrl(server, devhookId, "/api/data") + ); expect(response.status).toBe(200); expect(await response.text()).toBe("GET response"); @@ -144,11 +146,14 @@ export function runSharedTests( await delay(200); const devhookId = await getDevhookId("post-test", serverSecret); - const response = await fetch(getDevhookUrl(server, devhookId, "/api/submit"), { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ name: "test", value: 123 }), - }); + const response = await fetch( + getDevhookUrl(server, devhookId, "/api/submit"), + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ name: "test", value: 123 }), + } + ); expect(response.status).toBe(200); const body = (await response.json()) as { received: boolean }; @@ -203,7 +208,7 @@ export function runSharedTests( await fetch(getDevhookUrl(server, devhookId, "/"), { headers: { "x-custom-header": "custom-value", - "authorization": "Bearer token123", + authorization: "Bearer token123", }, }); @@ -232,7 +237,9 @@ export function runSharedTests( const devhookId = await getDevhookId("resp-headers-test", serverSecret); const response = await fetch(getDevhookUrl(server, devhookId, "/")); - expect(response.headers.get("x-custom-response")).toBe("response-value"); + expect(response.headers.get("x-custom-response")).toBe( + "response-value" + ); expect(response.headers.get("cache-control")).toBe("no-cache"); }); @@ -253,13 +260,19 @@ export function runSharedTests( const devhookId = await getDevhookId("status-test", serverSecret); - const response201 = await fetch(getDevhookUrl(server, devhookId, "/?status=201")); + const response201 = await fetch( + getDevhookUrl(server, devhookId, "/?status=201") + ); expect(response201.status).toBe(201); - const response404 = await fetch(getDevhookUrl(server, devhookId, "/?status=404")); + const response404 = await fetch( + getDevhookUrl(server, devhookId, "/?status=404") + ); expect(response404.status).toBe(404); - const response500 = await fetch(getDevhookUrl(server, devhookId, "/?status=500")); + const response500 = await fetch( + getDevhookUrl(server, devhookId, "/?status=500") + ); expect(response500.status).toBe(500); }); @@ -412,9 +425,9 @@ export function runSharedTests( const client = new DevhookClient({ serverUrl: server.url, secret: "ws-test", - transformUrl: (url) => { + transformWebSocketRequest: ({ url, headers }) => { url.host = `localhost:${localWsPort}`; - return url; + return { url, headers }; }, onRequest: async (req) => { const url = new URL(req.url); @@ -428,7 +441,9 @@ export function runSharedTests( await delay(200); const devhookId = await getDevhookId("ws-test", serverSecret); - const externalWs = new WsClient(getDevhookWsUrl(server, devhookId, "/ws")); + const externalWs = new WsClient( + getDevhookWsUrl(server, devhookId, "/ws") + ); const externalMessages: string[] = []; await new Promise((resolve, reject) => { @@ -473,9 +488,9 @@ export function runSharedTests( const client = new DevhookClient({ serverUrl: server.url, secret: "ws-close-test", - transformUrl: (url) => { + transformWebSocketRequest: ({ url, headers }) => { url.host = `localhost:${localWsPort}`; - return url; + return { url, headers }; }, onRequest: async (req) => { const url = new URL(req.url); @@ -489,7 +504,9 @@ export function runSharedTests( await delay(200); const devhookId = await getDevhookId("ws-close-test", serverSecret); - const externalWs = new WsClient(getDevhookWsUrl(server, devhookId, "/ws")); + const externalWs = new WsClient( + getDevhookWsUrl(server, devhookId, "/ws") + ); await new Promise((resolve, reject) => { externalWs.on("open", () => { @@ -534,9 +551,9 @@ export function runSharedTests( const client = new DevhookClient({ serverUrl: server.url, secret: "ws-concurrent-test", - transformUrl: (url) => { + transformWebSocketRequest: ({ url, headers }) => { url.host = `localhost:${localWsPort}`; - return url; + return { url, headers }; }, onRequest: async (req) => { const url = new URL(req.url); @@ -549,7 +566,10 @@ export function runSharedTests( clientConnections.push(disposable); await delay(200); - const devhookId = await getDevhookId("ws-concurrent-test", serverSecret); + const devhookId = await getDevhookId( + "ws-concurrent-test", + serverSecret + ); // Create 5 concurrent WebSocket connections const numConnections = 5; @@ -558,7 +578,9 @@ export function runSharedTests( for (let i = 0; i < numConnections; i++) { receivedMessages.set(i, []); - const ws = new WsClient(getDevhookWsUrl(server, devhookId, `/ws${i}`)); + const ws = new WsClient( + getDevhookWsUrl(server, devhookId, `/ws${i}`) + ); externalWsConnections.push(ws); } @@ -572,7 +594,10 @@ export function runSharedTests( ws.on("message", (data) => { receivedMessages.get(i)!.push(data.toString()); }); - setTimeout(() => reject(new Error(`Connection ${i} timeout`)), 5000); + setTimeout( + () => reject(new Error(`Connection ${i} timeout`)), + 5000 + ); }) ) ); @@ -605,9 +630,11 @@ export function runSharedTests( const { WebSocketServer, WebSocket: WsClient } = await import("ws"); const localWsServer1 = new WebSocketServer({ port: 0 }); - const localWsPort1 = (localWsServer1.address() as { port: number }).port; + const localWsPort1 = (localWsServer1.address() as { port: number }) + .port; const localWsServer2 = new WebSocketServer({ port: 0 }); - const localWsPort2 = (localWsServer2.address() as { port: number }).port; + const localWsPort2 = (localWsServer2.address() as { port: number }) + .port; const messages1: string[] = []; const messages2: string[] = []; @@ -629,9 +656,9 @@ export function runSharedTests( const client1 = new DevhookClient({ serverUrl: server.url, secret: "ws-multi-1", - transformUrl: (url) => { + transformWebSocketRequest: ({ url, headers }) => { url.host = `localhost:${localWsPort1}`; - return url; + return { url, headers }; }, onRequest: async (req) => { const url = new URL(req.url); @@ -643,9 +670,9 @@ export function runSharedTests( const client2 = new DevhookClient({ serverUrl: server.url, secret: "ws-multi-2", - transformUrl: (url) => { + transformWebSocketRequest: ({ url, headers }) => { url.host = `localhost:${localWsPort2}`; - return url; + return { url, headers }; }, onRequest: async (req) => { const url = new URL(req.url); @@ -662,8 +689,12 @@ export function runSharedTests( const devhookId1 = await getDevhookId("ws-multi-1", serverSecret); const devhookId2 = await getDevhookId("ws-multi-2", serverSecret); - const externalWs1 = new WsClient(getDevhookWsUrl(server, devhookId1, "/ws")); - const externalWs2 = new WsClient(getDevhookWsUrl(server, devhookId2, "/ws")); + const externalWs1 = new WsClient( + getDevhookWsUrl(server, devhookId1, "/ws") + ); + const externalWs2 = new WsClient( + getDevhookWsUrl(server, devhookId2, "/ws") + ); const received1: string[] = []; const received2: string[] = []; @@ -672,13 +703,17 @@ export function runSharedTests( new Promise((resolve, reject) => { externalWs1.on("open", resolve); externalWs1.on("error", reject); - externalWs1.on("message", (data) => received1.push(data.toString())); + externalWs1.on("message", (data) => + received1.push(data.toString()) + ); setTimeout(() => reject(new Error("Timeout ws1")), 5000); }), new Promise((resolve, reject) => { externalWs2.on("open", resolve); externalWs2.on("error", reject); - externalWs2.on("message", (data) => received2.push(data.toString())); + externalWs2.on("message", (data) => + received2.push(data.toString()) + ); setTimeout(() => reject(new Error("Timeout ws2")), 5000); }), ]); @@ -719,9 +754,9 @@ export function runSharedTests( const client = new DevhookClient({ serverUrl: server.url, secret: "ws-isolate-test", - transformUrl: (url) => { + transformWebSocketRequest: ({ url, headers }) => { url.host = `localhost:${localWsPort}`; - return url; + return { url, headers }; }, onRequest: async (req) => { const url = new URL(req.url); @@ -788,73 +823,89 @@ export function runSharedTests( // Note: miniflare/wrangler dev can be slow with WebSocket close propagation // See: https://github.com/cloudflare/workers-sdk/issues/10307 - it("should close proxied WebSockets when devhook client disconnects", { timeout: 30000 }, async () => { - let externalWsClosed = false; - - const { WebSocketServer, WebSocket: WsClient } = await import("ws"); - const localWsServer = new WebSocketServer({ port: 0 }); - const localWsPort = (localWsServer.address() as { port: number }).port; - - localWsServer.on("connection", (ws) => { - ws.on("message", (data) => { - ws.send(data); + it( + "should close proxied WebSockets when devhook client disconnects", + { timeout: 30000 }, + async () => { + let externalWsClosed = false; + + const { WebSocketServer, WebSocket: WsClient } = await import("ws"); + const localWsServer = new WebSocketServer({ port: 0 }); + const localWsPort = (localWsServer.address() as { port: number }) + .port; + + localWsServer.on("connection", (ws) => { + ws.on("message", (data) => { + ws.send(data); + }); }); - }); - const client = new DevhookClient({ - serverUrl: server.url, - secret: "ws-disconnect-test", - transformUrl: (url) => { - url.host = `localhost:${localWsPort}`; - return url; - }, - onRequest: async (req) => { - const url = new URL(req.url); - url.host = `localhost:${localWsPort}`; - return fetch(new Request(url.toString(), req)); - }, - }); + const client = new DevhookClient({ + serverUrl: server.url, + secret: "ws-disconnect-test", + transformWebSocketRequest: ({ url, headers }) => { + url.host = `localhost:${localWsPort}`; + return { url, headers }; + }, + onRequest: async (req) => { + const url = new URL(req.url); + url.host = `localhost:${localWsPort}`; + return fetch(new Request(url.toString(), req)); + }, + }); - const disposable = client.connect(); - // Don't add to clientConnections - we'll manually dispose - await delay(200); + const disposable = client.connect(); + // Don't add to clientConnections - we'll manually dispose + await delay(200); - const devhookId = await getDevhookId("ws-disconnect-test", serverSecret); - const externalWs = new WsClient(getDevhookWsUrl(server, devhookId, "/ws")); + const devhookId = await getDevhookId( + "ws-disconnect-test", + serverSecret + ); + const externalWs = new WsClient( + getDevhookWsUrl(server, devhookId, "/ws") + ); - await new Promise((resolve, reject) => { - externalWs.on("open", () => { - resolve(); + await new Promise((resolve, reject) => { + externalWs.on("open", () => { + resolve(); + }); + externalWs.on("error", reject); + setTimeout(() => reject(new Error("Timeout connecting")), 5000); }); - externalWs.on("error", reject); - setTimeout(() => reject(new Error("Timeout connecting")), 5000); - }); - // Disconnect the devhook client and wait for external WS to close - await new Promise((resolve, reject) => { - externalWs.on("close", () => { - externalWsClosed = true; - resolve(); - }); + // Disconnect the devhook client and wait for external WS to close + await new Promise((resolve, reject) => { + externalWs.on("close", () => { + externalWsClosed = true; + resolve(); + }); - disposable.dispose(); + disposable.dispose(); - // Longer timeout for miniflare's slow WebSocket close handling - setTimeout(() => { - reject(new Error("External WS did not close after devhook client disconnect")); - }, 20000); - }); + // Longer timeout for miniflare's slow WebSocket close handling + setTimeout(() => { + reject( + new Error( + "External WS did not close after devhook client disconnect" + ) + ); + }, 20000); + }); - expect(externalWsClosed).toBe(true); + expect(externalWsClosed).toBe(true); - localWsServer.close(); - }); + localWsServer.close(); + } + ); it("should return 503 when no client is connected for WebSocket", async () => { const { WebSocket: WsClient } = await import("ws"); const devhookId = await getDevhookId("nonexistent-ws", serverSecret); - const externalWs = new WsClient(getDevhookWsUrl(server, devhookId, "/ws")); + const externalWs = new WsClient( + getDevhookWsUrl(server, devhookId, "/ws") + ); await new Promise((resolve) => { externalWs.on("error", () => { @@ -920,8 +971,14 @@ export function runSharedTests( onRequest: async () => { const headers = new Headers(); // Expires date contains a comma - if joined with ", " this breaks - headers.append("Set-Cookie", "session=abc123; Expires=Thu, 01 Jan 2026 00:00:00 GMT; Path=/"); - headers.append("Set-Cookie", "user=xyz; Expires=Fri, 02 Jan 2026 00:00:00 GMT; Path=/"); + headers.append( + "Set-Cookie", + "session=abc123; Expires=Thu, 01 Jan 2026 00:00:00 GMT; Path=/" + ); + headers.append( + "Set-Cookie", + "user=xyz; Expires=Fri, 02 Jan 2026 00:00:00 GMT; Path=/" + ); return new Response("OK", { headers }); }, }); @@ -930,7 +987,10 @@ export function runSharedTests( clientConnections.push(disposable); await delay(200); - const devhookId = await getDevhookId("cookie-expires-test", serverSecret); + const devhookId = await getDevhookId( + "cookie-expires-test", + serverSecret + ); const response = await fetch(getDevhookUrl(server, devhookId, "/")); expect(response.status).toBe(200); @@ -938,8 +998,17 @@ export function runSharedTests( const setCookieHeaders = response.headers.getSetCookie(); expect(setCookieHeaders).toHaveLength(2); // Each cookie should be intact with its Expires date - expect(setCookieHeaders.some(c => c.includes("session=abc123") && c.includes("Thu, 01 Jan 2026"))).toBe(true); - expect(setCookieHeaders.some(c => c.includes("user=xyz") && c.includes("Fri, 02 Jan 2026"))).toBe(true); + expect( + setCookieHeaders.some( + (c) => + c.includes("session=abc123") && c.includes("Thu, 01 Jan 2026") + ) + ).toBe(true); + expect( + setCookieHeaders.some( + (c) => c.includes("user=xyz") && c.includes("Fri, 02 Jan 2026") + ) + ).toBe(true); }); it("should preserve Set-Cookie with all attributes", async () => { @@ -949,7 +1018,8 @@ export function runSharedTests( onRequest: async () => { return new Response("OK", { headers: { - "Set-Cookie": "session=abc; Path=/app; Domain=example.com; Secure; HttpOnly; SameSite=Strict; Max-Age=3600", + "Set-Cookie": + "session=abc; Path=/app; Domain=example.com; Secure; HttpOnly; SameSite=Strict; Max-Age=3600", }, }); }, @@ -1028,10 +1098,13 @@ export function runSharedTests( clientConnections.push(disposable); await delay(200); - const devhookId = await getDevhookId("multi-req-cookie-test", serverSecret); + const devhookId = await getDevhookId( + "multi-req-cookie-test", + serverSecret + ); await fetch(getDevhookUrl(server, devhookId, "/"), { headers: { - "Cookie": "a=1; b=2; c=3", + Cookie: "a=1; b=2; c=3", }, }); @@ -1054,11 +1127,14 @@ export function runSharedTests( clientConnections.push(disposable); await delay(200); - const devhookId = await getDevhookId("encoded-cookie-test", serverSecret); + const devhookId = await getDevhookId( + "encoded-cookie-test", + serverSecret + ); // URL-encoded value with special chars: hello=world; foo=bar await fetch(getDevhookUrl(server, devhookId, "/"), { headers: { - "Cookie": "data=hello%3Dworld%3B%20foo%3Dbar", + Cookie: "data=hello%3Dworld%3B%20foo%3Dbar", }, }); @@ -1085,7 +1161,7 @@ export function runSharedTests( const devhookId = await getDevhookId("long-cookie-test", serverSecret); await fetch(getDevhookUrl(server, devhookId, "/"), { headers: { - "Cookie": `longcookie=${longValue}`, + Cookie: `longcookie=${longValue}`, }, }); @@ -1111,7 +1187,7 @@ export function runSharedTests( const devhookId = await getDevhookId("empty-cookie-test", serverSecret); await fetch(getDevhookUrl(server, devhookId, "/"), { headers: { - "Cookie": "empty=", + Cookie: "empty=", }, }); @@ -1134,11 +1210,14 @@ export function runSharedTests( clientConnections.push(disposable); await delay(200); - const devhookId = await getDevhookId("unicode-cookie-test", serverSecret); + const devhookId = await getDevhookId( + "unicode-cookie-test", + serverSecret + ); // URL-encoded "值" (Chinese character for "value") await fetch(getDevhookUrl(server, devhookId, "/"), { headers: { - "Cookie": "name=%E5%80%BC", + Cookie: "name=%E5%80%BC", }, }); @@ -1297,7 +1376,10 @@ export function runSharedTests( clientConnections.push(disposable); await delay(200); - const devhookId = await getDevhookId("content-type-charset-test", serverSecret); + const devhookId = await getDevhookId( + "content-type-charset-test", + serverSecret + ); const response = await fetch(getDevhookUrl(server, devhookId, "/")); expect(response.status).toBe(200); @@ -1322,12 +1404,17 @@ export function runSharedTests( clientConnections.push(disposable); await delay(200); - const devhookId = await getDevhookId("accept-quality-test", serverSecret); + const devhookId = await getDevhookId( + "accept-quality-test", + serverSecret + ); await fetch(getDevhookUrl(server, devhookId, "/"), { - headers: { "Accept": "text/html, application/json;q=0.9, */*;q=0.8" }, + headers: { Accept: "text/html, application/json;q=0.9, */*;q=0.8" }, }); - expect(receivedAccept).toBe("text/html, application/json;q=0.9, */*;q=0.8"); + expect(receivedAccept).toBe( + "text/html, application/json;q=0.9, */*;q=0.8" + ); }); it("should handle headers with leading/trailing whitespace in values", async () => { @@ -1346,7 +1433,10 @@ export function runSharedTests( clientConnections.push(disposable); await delay(200); - const devhookId = await getDevhookId("whitespace-header-test", serverSecret); + const devhookId = await getDevhookId( + "whitespace-header-test", + serverSecret + ); await fetch(getDevhookUrl(server, devhookId, "/"), { headers: { "x-whitespace": " value with spaces " }, }); @@ -1386,9 +1476,9 @@ export function runSharedTests( const client = new DevhookClient({ serverUrl: server.url, secret: "ws-utf8-test", - transformUrl: (url) => { + transformWebSocketRequest: ({ url, headers }) => { url.host = `localhost:${localWsPort}`; - return url; + return { url, headers }; }, onRequest: async (req) => { const url = new URL(req.url); @@ -1402,7 +1492,9 @@ export function runSharedTests( await delay(200); const devhookId = await getDevhookId("ws-utf8-test", serverSecret); - const externalWs = new WsClient(getDevhookWsUrl(server, devhookId, "/ws")); + const externalWs = new WsClient( + getDevhookWsUrl(server, devhookId, "/ws") + ); await new Promise((resolve, reject) => { externalWs.on("open", () => { @@ -1425,69 +1517,79 @@ export function runSharedTests( localWsServer.close(); }); - it("should handle large binary messages", { timeout: 30000 }, async () => { - // Use 64KB - a reasonable size that should work across implementations - const largeData = new Uint8Array(64 * 1024); - for (let i = 0; i < largeData.length; i++) { - largeData[i] = i % 256; - } - - let receivedSize = 0; - - const { WebSocketServer, WebSocket: WsClient } = await import("ws"); - const localWsServer = new WebSocketServer({ port: 0 }); - const localWsPort = (localWsServer.address() as { port: number }).port; - - localWsServer.on("connection", (ws) => { - ws.on("message", (data) => { - const buf = data as Buffer; - receivedSize = buf.length; - ws.send(buf); + it( + "should handle large binary messages", + { timeout: 30000 }, + async () => { + // Use 64KB - a reasonable size that should work across implementations + const largeData = new Uint8Array(64 * 1024); + for (let i = 0; i < largeData.length; i++) { + largeData[i] = i % 256; + } + + let receivedSize = 0; + + const { WebSocketServer, WebSocket: WsClient } = await import("ws"); + const localWsServer = new WebSocketServer({ port: 0 }); + const localWsPort = (localWsServer.address() as { port: number }) + .port; + + localWsServer.on("connection", (ws) => { + ws.on("message", (data) => { + const buf = data as Buffer; + receivedSize = buf.length; + ws.send(buf); + }); }); - }); - const client = new DevhookClient({ - serverUrl: server.url, - secret: "ws-large-binary-test", - transformUrl: (url) => { - url.host = `localhost:${localWsPort}`; - return url; - }, - onRequest: async (req) => { - const url = new URL(req.url); - url.host = `localhost:${localWsPort}`; - return fetch(new Request(url.toString(), req)); - }, - }); + const client = new DevhookClient({ + serverUrl: server.url, + secret: "ws-large-binary-test", + transformWebSocketRequest: ({ url, headers }) => { + url.host = `localhost:${localWsPort}`; + return { url, headers }; + }, + onRequest: async (req) => { + const url = new URL(req.url); + url.host = `localhost:${localWsPort}`; + return fetch(new Request(url.toString(), req)); + }, + }); - const disposable = client.connect(); - clientConnections.push(disposable); - await delay(200); + const disposable = client.connect(); + clientConnections.push(disposable); + await delay(200); - const devhookId = await getDevhookId("ws-large-binary-test", serverSecret); - const externalWs = new WsClient(getDevhookWsUrl(server, devhookId, "/ws")); + const devhookId = await getDevhookId( + "ws-large-binary-test", + serverSecret + ); + const externalWs = new WsClient( + getDevhookWsUrl(server, devhookId, "/ws") + ); + + let echoedSize = 0; + await new Promise((resolve, reject) => { + externalWs.on("open", () => { + externalWs.send(largeData); + }); - let echoedSize = 0; - await new Promise((resolve, reject) => { - externalWs.on("open", () => { - externalWs.send(largeData); - }); + externalWs.on("message", (data) => { + echoedSize = (data as Buffer).length; + resolve(); + }); - externalWs.on("message", (data) => { - echoedSize = (data as Buffer).length; - resolve(); + externalWs.on("error", reject); + setTimeout(() => reject(new Error("Timeout")), 25000); }); - externalWs.on("error", reject); - setTimeout(() => reject(new Error("Timeout")), 25000); - }); + expect(receivedSize).toBe(64 * 1024); + expect(echoedSize).toBe(64 * 1024); - expect(receivedSize).toBe(64 * 1024); - expect(echoedSize).toBe(64 * 1024); - - externalWs.close(); - localWsServer.close(); - }); + externalWs.close(); + localWsServer.close(); + } + ); it("should handle empty WebSocket messages", async () => { let receivedEmpty = false; @@ -1508,9 +1610,9 @@ export function runSharedTests( const client = new DevhookClient({ serverUrl: server.url, secret: "ws-empty-msg-test", - transformUrl: (url) => { + transformWebSocketRequest: ({ url, headers }) => { url.host = `localhost:${localWsPort}`; - return url; + return { url, headers }; }, onRequest: async (req) => { const url = new URL(req.url); @@ -1524,7 +1626,9 @@ export function runSharedTests( await delay(200); const devhookId = await getDevhookId("ws-empty-msg-test", serverSecret); - const externalWs = new WsClient(getDevhookWsUrl(server, devhookId, "/ws")); + const externalWs = new WsClient( + getDevhookWsUrl(server, devhookId, "/ws") + ); let receivedEmptyEcho = false; await new Promise((resolve, reject) => { @@ -1567,9 +1671,9 @@ export function runSharedTests( const client = new DevhookClient({ serverUrl: server.url, secret: "ws-rapid-test", - transformUrl: (url) => { + transformWebSocketRequest: ({ url, headers }) => { url.host = `localhost:${localWsPort}`; - return url; + return { url, headers }; }, onRequest: async (req) => { const url = new URL(req.url); @@ -1583,7 +1687,9 @@ export function runSharedTests( await delay(200); const devhookId = await getDevhookId("ws-rapid-test", serverSecret); - const externalWs = new WsClient(getDevhookWsUrl(server, devhookId, "/ws")); + const externalWs = new WsClient( + getDevhookWsUrl(server, devhookId, "/ws") + ); await new Promise((resolve, reject) => { externalWs.on("open", () => { @@ -1629,9 +1735,9 @@ export function runSharedTests( const client = new DevhookClient({ serverUrl: server.url, secret: "ws-close-3000-test", - transformUrl: (url) => { + transformWebSocketRequest: ({ url, headers }) => { url.host = `localhost:${localWsPort}`; - return url; + return { url, headers }; }, onRequest: async (req) => { const url = new URL(req.url); @@ -1644,8 +1750,13 @@ export function runSharedTests( clientConnections.push(disposable); await delay(200); - const devhookId = await getDevhookId("ws-close-3000-test", serverSecret); - const externalWs = new WsClient(getDevhookWsUrl(server, devhookId, "/ws")); + const devhookId = await getDevhookId( + "ws-close-3000-test", + serverSecret + ); + const externalWs = new WsClient( + getDevhookWsUrl(server, devhookId, "/ws") + ); await new Promise((resolve, reject) => { externalWs.on("open", () => { @@ -1681,9 +1792,9 @@ export function runSharedTests( const client = new DevhookClient({ serverUrl: server.url, secret: "ws-close-4000-test", - transformUrl: (url) => { + transformWebSocketRequest: ({ url, headers }) => { url.host = `localhost:${localWsPort}`; - return url; + return { url, headers }; }, onRequest: async (req) => { const url = new URL(req.url); @@ -1696,8 +1807,13 @@ export function runSharedTests( clientConnections.push(disposable); await delay(200); - const devhookId = await getDevhookId("ws-close-4000-test", serverSecret); - const externalWs = new WsClient(getDevhookWsUrl(server, devhookId, "/ws")); + const devhookId = await getDevhookId( + "ws-close-4000-test", + serverSecret + ); + const externalWs = new WsClient( + getDevhookWsUrl(server, devhookId, "/ws") + ); await new Promise((resolve, reject) => { externalWs.on("open", () => { @@ -1717,59 +1833,69 @@ export function runSharedTests( localWsServer.close(); }); - it("should handle server-initiated WebSocket close", { timeout: 15000 }, async () => { - let clientReceivedClose = false; - let clientCloseCode: number | undefined; + it( + "should handle server-initiated WebSocket close", + { timeout: 15000 }, + async () => { + let clientReceivedClose = false; + let clientCloseCode: number | undefined; - const { WebSocketServer, WebSocket: WsClient } = await import("ws"); - const localWsServer = new WebSocketServer({ port: 0 }); - const localWsPort = (localWsServer.address() as { port: number }).port; + const { WebSocketServer, WebSocket: WsClient } = await import("ws"); + const localWsServer = new WebSocketServer({ port: 0 }); + const localWsPort = (localWsServer.address() as { port: number }) + .port; - localWsServer.on("connection", (ws) => { - // Server initiates close after connection - setTimeout(() => { - ws.close(1000, "Server closing"); - }, 100); - }); + localWsServer.on("connection", (ws) => { + // Server initiates close after connection + setTimeout(() => { + ws.close(1000, "Server closing"); + }, 100); + }); - const client = new DevhookClient({ - serverUrl: server.url, - secret: "ws-server-close-test", - transformUrl: (url) => { - url.host = `localhost:${localWsPort}`; - return url; - }, - onRequest: async (req) => { - const url = new URL(req.url); - url.host = `localhost:${localWsPort}`; - return fetch(new Request(url.toString(), req)); - }, - }); + const client = new DevhookClient({ + serverUrl: server.url, + secret: "ws-server-close-test", + transformWebSocketRequest: ({ url, headers }) => { + url.host = `localhost:${localWsPort}`; + return { url, headers }; + }, + onRequest: async (req) => { + const url = new URL(req.url); + url.host = `localhost:${localWsPort}`; + return fetch(new Request(url.toString(), req)); + }, + }); - const disposable = client.connect(); - clientConnections.push(disposable); - await delay(200); + const disposable = client.connect(); + clientConnections.push(disposable); + await delay(200); - const devhookId = await getDevhookId("ws-server-close-test", serverSecret); - const externalWs = new WsClient(getDevhookWsUrl(server, devhookId, "/ws")); + const devhookId = await getDevhookId( + "ws-server-close-test", + serverSecret + ); + const externalWs = new WsClient( + getDevhookWsUrl(server, devhookId, "/ws") + ); + + await new Promise((resolve, reject) => { + externalWs.on("close", (code) => { + clientReceivedClose = true; + clientCloseCode = code; + resolve(); + }); - await new Promise((resolve, reject) => { - externalWs.on("close", (code) => { - clientReceivedClose = true; - clientCloseCode = code; - resolve(); + externalWs.on("error", reject); + // Miniflare can be slow with WebSocket close propagation + setTimeout(() => reject(new Error("Timeout")), 12000); }); - externalWs.on("error", reject); - // Miniflare can be slow with WebSocket close propagation - setTimeout(() => reject(new Error("Timeout")), 12000); - }); - - expect(clientReceivedClose).toBe(true); - expect(clientCloseCode).toBe(1000); + expect(clientReceivedClose).toBe(true); + expect(clientCloseCode).toBe(1000); - localWsServer.close(); - }); + localWsServer.close(); + } + ); it("should handle multiple WebSocket message exchanges", async () => { const { WebSocketServer, WebSocket: WsClient } = await import("ws"); @@ -1787,9 +1913,9 @@ export function runSharedTests( const client = new DevhookClient({ serverUrl: server.url, secret: "ws-exchange-test", - transformUrl: (url) => { + transformWebSocketRequest: ({ url, headers }) => { url.host = `localhost:${localWsPort}`; - return url; + return { url, headers }; }, onRequest: async (req) => { const url = new URL(req.url); @@ -1803,7 +1929,9 @@ export function runSharedTests( await delay(200); const devhookId = await getDevhookId("ws-exchange-test", serverSecret); - const externalWs = new WsClient(getDevhookWsUrl(server, devhookId, "/ws")); + const externalWs = new WsClient( + getDevhookWsUrl(server, devhookId, "/ws") + ); let clientMessageCount = 0; @@ -1837,63 +1965,70 @@ export function runSharedTests( localWsServer.close(); }); - it("should handle WebSocket with query parameters", { timeout: 10000 }, async () => { - let receivedUrl: string | undefined; + it( + "should handle WebSocket with query parameters", + { timeout: 10000 }, + async () => { + let receivedUrl: string | undefined; - const { WebSocketServer, WebSocket: WsClient } = await import("ws"); - const localWsServer = new WebSocketServer({ port: 0 }); - const localWsPort = (localWsServer.address() as { port: number }).port; + const { WebSocketServer, WebSocket: WsClient } = await import("ws"); + const localWsServer = new WebSocketServer({ port: 0 }); + const localWsPort = (localWsServer.address() as { port: number }) + .port; - localWsServer.on("connection", (ws, req) => { - receivedUrl = req.url; - ws.send("connected"); - }); + localWsServer.on("connection", (ws, req) => { + receivedUrl = req.url; + ws.send("connected"); + }); - const client = new DevhookClient({ - serverUrl: server.url, - secret: "ws-query-test", - transformUrl: (url) => { - url.host = `localhost:${localWsPort}`; - return url; - }, - onRequest: async (req) => { - const url = new URL(req.url); - url.host = `localhost:${localWsPort}`; - return fetch(new Request(url.toString(), req)); - }, - }); + const client = new DevhookClient({ + serverUrl: server.url, + secret: "ws-query-test", + transformWebSocketRequest: ({ url, headers }) => { + url.host = `localhost:${localWsPort}`; + return { url, headers }; + }, + onRequest: async (req) => { + const url = new URL(req.url); + url.host = `localhost:${localWsPort}`; + return fetch(new Request(url.toString(), req)); + }, + }); - const disposable = client.connect(); - clientConnections.push(disposable); - await delay(200); + const disposable = client.connect(); + clientConnections.push(disposable); + await delay(200); - const devhookId = await getDevhookId("ws-query-test", serverSecret); - const externalWs = new WsClient(getDevhookWsUrl(server, devhookId, "/ws?token=abc123&user=test")); + const devhookId = await getDevhookId("ws-query-test", serverSecret); + const externalWs = new WsClient( + getDevhookWsUrl(server, devhookId, "/ws?token=abc123&user=test") + ); + + await new Promise((resolve, reject) => { + externalWs.on("open", () => { + // Give some time for the message to arrive + setTimeout(() => { + if (receivedUrl) { + resolve(); + } + }, 500); + }); - await new Promise((resolve, reject) => { - externalWs.on("open", () => { - // Give some time for the message to arrive - setTimeout(() => { - if (receivedUrl) { - resolve(); - } - }, 500); - }); + externalWs.on("message", () => { + resolve(); + }); - externalWs.on("message", () => { - resolve(); + externalWs.on("error", reject); + setTimeout(() => reject(new Error("Timeout")), 8000); }); - externalWs.on("error", reject); - setTimeout(() => reject(new Error("Timeout")), 8000); - }); - - expect(receivedUrl).toContain("token=abc123"); - expect(receivedUrl).toContain("user=test"); + expect(receivedUrl).toContain("token=abc123"); + expect(receivedUrl).toContain("user=test"); - externalWs.close(); - localWsServer.close(); - }); + externalWs.close(); + localWsServer.close(); + } + ); }); describe("request/response body edge cases", () => { @@ -1953,7 +2088,10 @@ export function runSharedTests( clientConnections.push(disposable); await delay(200); - const devhookId = await getDevhookId("large-req-body-test", serverSecret); + const devhookId = await getDevhookId( + "large-req-body-test", + serverSecret + ); const response = await fetch(getDevhookUrl(server, devhookId, "/"), { method: "POST", body: largeBody, @@ -1978,7 +2116,10 @@ export function runSharedTests( clientConnections.push(disposable); await delay(200); - const devhookId = await getDevhookId("large-resp-body-test", serverSecret); + const devhookId = await getDevhookId( + "large-resp-body-test", + serverSecret + ); const response = await fetch(getDevhookUrl(server, devhookId, "/")); expect(response.status).toBe(200); @@ -1989,8 +2130,8 @@ export function runSharedTests( it("should handle binary request/response bodies", async () => { // PNG-like binary data with null bytes const binaryData = new Uint8Array([ - 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, - 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, + 0x0d, 0x49, 0x48, 0x44, 0x52, ]); let receivedBinary: Uint8Array | undefined; @@ -2025,7 +2166,9 @@ export function runSharedTests( }); it("should handle body with null bytes", async () => { - const dataWithNulls = new Uint8Array([0x00, 0x01, 0x00, 0x02, 0x00, 0x03]); + const dataWithNulls = new Uint8Array([ + 0x00, 0x01, 0x00, 0x02, 0x00, 0x03, + ]); let receivedData: Uint8Array | undefined; const client = new DevhookClient({ @@ -2112,7 +2255,9 @@ export function runSharedTests( }); expect(response.status).toBe(200); - expect(receivedBody).toBe("name=test&value=hello%20world&special=%26%3D%3F"); + expect(receivedBody).toBe( + "name=test&value=hello%20world&special=%26%3D%3F" + ); }); }); @@ -2155,7 +2300,10 @@ export function runSharedTests( it("should handle rapid reconnect cycles", async () => { const cycles = 5; - const devhookId = await getDevhookId("rapid-reconnect-test", serverSecret); + const devhookId = await getDevhookId( + "rapid-reconnect-test", + serverSecret + ); for (let i = 0; i < cycles; i++) { const client = new DevhookClient({ @@ -2176,38 +2324,42 @@ export function runSharedTests( } }); - it("should handle many concurrent requests", { timeout: 30000 }, async () => { - const numRequests = 50; - let requestCount = 0; + it( + "should handle many concurrent requests", + { timeout: 30000 }, + async () => { + const numRequests = 50; + let requestCount = 0; - const client = new DevhookClient({ - serverUrl: server.url, - secret: "concurrent-test", - onRequest: async (req) => { - requestCount++; - const url = new URL(req.url); - return new Response(`request-${url.searchParams.get("n")}`); - }, - }); + const client = new DevhookClient({ + serverUrl: server.url, + secret: "concurrent-test", + onRequest: async (req) => { + requestCount++; + const url = new URL(req.url); + return new Response(`request-${url.searchParams.get("n")}`); + }, + }); - const disposable = client.connect(); - clientConnections.push(disposable); - await delay(200); + const disposable = client.connect(); + clientConnections.push(disposable); + await delay(200); - const devhookId = await getDevhookId("concurrent-test", serverSecret); + const devhookId = await getDevhookId("concurrent-test", serverSecret); - const promises = Array.from({ length: numRequests }, (_, i) => - fetch(getDevhookUrl(server, devhookId, `/?n=${i}`)) - ); + const promises = Array.from({ length: numRequests }, (_, i) => + fetch(getDevhookUrl(server, devhookId, `/?n=${i}`)) + ); - const responses = await Promise.all(promises); + const responses = await Promise.all(promises); - for (let i = 0; i < numRequests; i++) { - expect(responses[i]!.status).toBe(200); - } + for (let i = 0; i < numRequests; i++) { + expect(responses[i]!.status).toBe(200); + } - expect(requestCount).toBe(numRequests); - }); + expect(requestCount).toBe(numRequests); + } + ); it("should return 503 immediately after client disconnect", async () => { const client = new DevhookClient({ @@ -2219,7 +2371,10 @@ export function runSharedTests( const disposable = client.connect(); await delay(200); - const devhookId = await getDevhookId("immediate-503-test", serverSecret); + const devhookId = await getDevhookId( + "immediate-503-test", + serverSecret + ); // Verify client works const response1 = await fetch(getDevhookUrl(server, devhookId, "/")); @@ -2233,41 +2388,48 @@ export function runSharedTests( expect(response2.status).toBe(503); }); - it("should handle new client connection with same secret", { timeout: 10000 }, async () => { - const client1 = new DevhookClient({ - serverUrl: server.url, - secret: "replace-client-test", - onRequest: async () => new Response("client1"), - }); + it( + "should handle new client connection with same secret", + { timeout: 10000 }, + async () => { + const client1 = new DevhookClient({ + serverUrl: server.url, + secret: "replace-client-test", + onRequest: async () => new Response("client1"), + }); - const disposable1 = client1.connect(); - await delay(300); + const disposable1 = client1.connect(); + await delay(300); - const devhookId = await getDevhookId("replace-client-test", serverSecret); + const devhookId = await getDevhookId( + "replace-client-test", + serverSecret + ); - // Verify client1 works - const response1 = await fetch(getDevhookUrl(server, devhookId, "/")); - expect(await response1.text()).toBe("client1"); + // Verify client1 works + const response1 = await fetch(getDevhookUrl(server, devhookId, "/")); + expect(await response1.text()).toBe("client1"); - // Disconnect client1 first - disposable1.dispose(); - await delay(100); + // Disconnect client1 first + disposable1.dispose(); + await delay(100); - // Connect client2 with same secret - const client2 = new DevhookClient({ - serverUrl: server.url, - secret: "replace-client-test", - onRequest: async () => new Response("client2"), - }); + // Connect client2 with same secret + const client2 = new DevhookClient({ + serverUrl: server.url, + secret: "replace-client-test", + onRequest: async () => new Response("client2"), + }); - const disposable2 = client2.connect(); - clientConnections.push(disposable2); - await delay(300); + const disposable2 = client2.connect(); + clientConnections.push(disposable2); + await delay(300); - // Requests should now go to client2 - const response2 = await fetch(getDevhookUrl(server, devhookId, "/")); - expect(await response2.text()).toBe("client2"); - }); + // Requests should now go to client2 + const response2 = await fetch(getDevhookUrl(server, devhookId, "/")); + expect(await response2.text()).toBe("client2"); + } + ); }); describe("error handling", () => { @@ -2293,7 +2455,10 @@ export function runSharedTests( clientConnections.push(disposable); await delay(200); - const devhookId = await getDevhookId("error-message-test", serverSecret); + const devhookId = await getDevhookId( + "error-message-test", + serverSecret + ); const response = await fetch(getDevhookUrl(server, devhookId, "/")); expect(response.status).toBe(502); @@ -2315,14 +2480,19 @@ export function runSharedTests( clientConnections.push(disposable); await delay(200); - const devhookId = await getDevhookId("rejected-promise-test", serverSecret); + const devhookId = await getDevhookId( + "rejected-promise-test", + serverSecret + ); const response = await fetch(getDevhookUrl(server, devhookId, "/")); expect(response.status).toBe(502); }); it("should handle various HTTP status codes correctly", async () => { - const statusCodes = [200, 201, 204, 301, 302, 400, 401, 403, 404, 500, 502, 503]; + const statusCodes = [ + 200, 201, 204, 301, 302, 400, 401, 403, 404, 500, 502, 503, + ]; const client = new DevhookClient({ serverUrl: server.url, @@ -2345,7 +2515,9 @@ export function runSharedTests( const devhookId = await getDevhookId("status-codes-test", serverSecret); for (const status of statusCodes) { - const response = await fetch(getDevhookUrl(server, devhookId, `/?status=${status}`)); + const response = await fetch( + getDevhookUrl(server, devhookId, `/?status=${status}`) + ); expect(response.status).toBe(status); } }); @@ -2378,7 +2550,9 @@ export function runSharedTests( await delay(200); const devhookId = await getDevhookId("encoded-path-test", serverSecret); - const response = await fetch(getDevhookUrl(server, devhookId, "/api/users/name%20with%20spaces")); + const response = await fetch( + getDevhookUrl(server, devhookId, "/api/users/name%20with%20spaces") + ); expect(response.status).toBe(200); // Path should be decoded or preserved depending on implementation @@ -2401,9 +2575,18 @@ export function runSharedTests( clientConnections.push(disposable); await delay(200); - const devhookId = await getDevhookId("special-query-test", serverSecret); + const devhookId = await getDevhookId( + "special-query-test", + serverSecret + ); // Encoded: & = ? in values - const response = await fetch(getDevhookUrl(server, devhookId, "/?search=hello%26world&name=foo%3Dbar")); + const response = await fetch( + getDevhookUrl( + server, + devhookId, + "/?search=hello%26world&name=foo%3Dbar" + ) + ); expect(response.status).toBe(200); expect(receivedQuery).toContain("search=hello%26world"); @@ -2427,7 +2610,9 @@ export function runSharedTests( await delay(200); const devhookId = await getDevhookId("double-slash-test", serverSecret); - const response = await fetch(getDevhookUrl(server, devhookId, "/api//data///test")); + const response = await fetch( + getDevhookUrl(server, devhookId, "/api//data///test") + ); expect(response.status).toBe(200); // Browsers/fetch may normalize slashes, but we should handle it @@ -2546,7 +2731,9 @@ export function runSharedTests( expect(response.status).toBe(204); expect(response.headers.get("access-control-allow-origin")).toBe("*"); - expect(response.headers.get("access-control-allow-methods")).toContain("POST"); + expect(response.headers.get("access-control-allow-methods")).toContain( + "POST" + ); }); }); }); diff --git a/packages/devhook/src/test-utils.ts b/packages/devhook/src/test-utils.ts index b928190..e2effcd 100644 --- a/packages/devhook/src/test-utils.ts +++ b/packages/devhook/src/test-utils.ts @@ -5,7 +5,11 @@ * so the same tests can run against both. */ -import { DevhookClient, type DevhookClientOptions } from "./client"; +import { + DevhookClient, + type DevhookClientOptions, + type WebSocketRequest, +} from "./client"; import { generateDevhookId } from "./server/crypto"; /** @@ -35,7 +39,7 @@ export interface TestClientOptions { server: TestServer; secret: string; localTargetPort?: number; - transformUrl?: (url: URL) => URL; + transformWebSocketRequest?: (request: WebSocketRequest) => WebSocketRequest; onRequest?: DevhookClientOptions["onRequest"]; onConnect?: DevhookClientOptions["onConnect"]; onDisconnect?: DevhookClientOptions["onDisconnect"]; @@ -46,25 +50,36 @@ export interface TestClientOptions { * Create a DevhookClient configured for testing. */ export function createTestClient(opts: TestClientOptions): DevhookClient { - const { server, secret, localTargetPort, transformUrl, onRequest, ...rest } = opts; + const { + server, + secret, + localTargetPort, + transformWebSocketRequest, + onRequest, + ...rest + } = opts; return new DevhookClient({ serverUrl: server.url, secret, - transformUrl: transformUrl ?? (localTargetPort - ? (url) => { + transformWebSocketRequest: + transformWebSocketRequest ?? + (localTargetPort + ? ({ url, headers }) => { + url.host = `localhost:${localTargetPort}`; + return { url, headers }; + } + : undefined), + onRequest: + onRequest ?? + (async (req) => { + if (localTargetPort) { + const url = new URL(req.url); url.host = `localhost:${localTargetPort}`; - return url; + return fetch(new Request(url.toString(), req)); } - : undefined), - onRequest: onRequest ?? (async (req) => { - if (localTargetPort) { - const url = new URL(req.url); - url.host = `localhost:${localTargetPort}`; - return fetch(new Request(url.toString(), req)); - } - return new Response("No handler configured", { status: 500 }); - }), + return new Response("No handler configured", { status: 500 }); + }), ...rest, }); } @@ -72,21 +87,32 @@ export function createTestClient(opts: TestClientOptions): DevhookClient { /** * Helper to generate a devhook ID for testing. */ -export async function getDevhookId(clientSecret: string, serverSecret: string): Promise { +export async function getDevhookId( + clientSecret: string, + serverSecret: string +): Promise { return generateDevhookId(clientSecret, serverSecret); } /** * Helper to build the devhook URL for a given ID. */ -export function getDevhookUrl(server: TestServer, devhookId: string, path = ""): string { +export function getDevhookUrl( + server: TestServer, + devhookId: string, + path = "" +): string { return `${server.url}/devhook/${devhookId}${path}`; } /** * Helper to build the WebSocket URL for a devhook. */ -export function getDevhookWsUrl(server: TestServer, devhookId: string, path = ""): string { +export function getDevhookWsUrl( + server: TestServer, + devhookId: string, + path = "" +): string { const url = new URL(getDevhookUrl(server, devhookId, path)); url.protocol = url.protocol === "https:" ? "wss:" : "ws:"; return url.toString();