@volter/twin-tunnel 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1197 @@
1
+ import { createHmac, randomUUID, timingSafeEqual } from 'node:crypto';
2
+ import { isIP } from 'node:net';
3
+ import { hostname } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { getActiveWorldStore, withFileLock, withWorldStore, worldNow, worldPaths, statefulTwinManifest, TWIN_PREFIX_HEADER, twinPublicBase } from '@volter/world-core';
6
+ import { handleTunnelTwinRequest, markTunnelDisconnected, persistTunnelRelayRegistration, prepareTunnelRelayRegistration, reduceTunnelWsFrame, revertTunnelRelayRegistration, } from "./tunnel-twin.js";
7
+ /**
8
+ * Is a bridge's control socket still able to receive?
9
+ *
10
+ * `ServerWebSocket.send` returns 0 on a closed socket rather than throwing, so every
11
+ * `try { owner.send(...) } catch` around it was dead code (§9). Liveness is a STATE to read,
12
+ * not an exception to wait for.
13
+ */
14
+ function ownerAlive(visitor) {
15
+ return visitor.owner.readyState === 1 && !visitor.owner.data.closed;
16
+ }
17
+ /** How much a visitor may send before its local socket is ready, so a pre-`ws-ready` frame is
18
+ * queued rather than dropped — and a visitor that floods before the bridge exists is refused. */
19
+ const VISITOR_PREREADY_LIMIT_BYTES = 1024 * 1024;
20
+ const DEFAULT_JWT_SECRET = 'twin-local-jwt-secret';
21
+ const RELAY_CLAIM_STALE_MS = 60_000;
22
+ const RELAY_CLAIM_HEARTBEAT_MS = 5_000;
23
+ function relayClaimPath(root, tunnelId) {
24
+ return join(worldPaths('tunnel', root).dir, 'relay-claims', `${tunnelId}.json`);
25
+ }
26
+ function readRelayClaim(path) {
27
+ const raw = getActiveWorldStore().read(path);
28
+ if (raw === null)
29
+ return undefined;
30
+ let claim;
31
+ try {
32
+ claim = JSON.parse(raw);
33
+ }
34
+ catch {
35
+ throw new Error(`Relay ownership claim is corrupt: ${path}`);
36
+ }
37
+ if (!claim || typeof claim !== 'object' || typeof claim.token !== 'string'
38
+ || typeof claim.serverId !== 'string' || typeof claim.socketId !== 'string'
39
+ || !Number.isInteger(claim.pid) || claim.pid <= 0 || typeof claim.hostname !== 'string'
40
+ || typeof claim.claimedAt !== 'string'
41
+ || (claim.heartbeatAt !== undefined && typeof claim.heartbeatAt !== 'string')
42
+ || !['reserving', 'active'].includes(claim.state)) {
43
+ throw new Error(`Relay ownership claim is malformed: ${path}`);
44
+ }
45
+ return claim;
46
+ }
47
+ /**
48
+ * A claim is alive while its own HEARTBEAT is fresh — one rule, every host.
49
+ *
50
+ * The relay writes that heartbeat itself: at reservation (connect), on its control traffic, and
51
+ * on the idle keepalive; `markTunnelDisconnected` records the other end of the same fact. So
52
+ * liveness is STATE this twin already holds, readable from any process sharing the root, and the
53
+ * serve path never asks the operating system who is running. A `ps`/`kill(pid, 0)` probe answers
54
+ * only about THIS box — it is silent about a relay owning the id from another host, unavailable
55
+ * on a serverless shell (runtime contract R12b), and in any case only ever a faster negative:
56
+ * a process that has stopped writing its heartbeat is reclaimed here within one stale window,
57
+ * whether it exited, hung, or had its pid handed to somebody else.
58
+ *
59
+ * Bounded in BOTH directions, so a host with a corrupt/far-future clock cannot reserve an id
60
+ * indefinitely, while one stale window still tolerates ordinary skew on a shared filesystem.
61
+ * The stamp is a WORLD instant, so it is compared against the world clock — mixing `Date.now()`
62
+ * in would call every claim in a clock-set world instantly stale.
63
+ */
64
+ function relayClaimAlive(claim) {
65
+ const heartbeatAt = Date.parse(claim.heartbeatAt ?? claim.claimedAt);
66
+ if (!Number.isFinite(heartbeatAt))
67
+ return false;
68
+ const heartbeatAge = Date.parse(worldNow()) - heartbeatAt;
69
+ return heartbeatAge >= -RELAY_CLAIM_STALE_MS && heartbeatAge <= RELAY_CLAIM_STALE_MS;
70
+ }
71
+ /** Reserve the durable ownership slot before the registration action is appended. The small
72
+ * sidecar is a live-process coordination record, not projected vendor state. Its own lock makes
73
+ * the read/decision/write atomic across relay processes sharing one root; the `reserving` state
74
+ * keeps a second process out while the first process is awaiting injected/real persistence. */
75
+ function reserveRelayClaim(input) {
76
+ const path = relayClaimPath(input.root, input.tunnelId);
77
+ return withFileLock(`${path}.lock`, () => {
78
+ const store = getActiveWorldStore();
79
+ let existing = readRelayClaim(path);
80
+ if (existing && !relayClaimAlive(existing)) {
81
+ store.remove(path);
82
+ existing = undefined;
83
+ }
84
+ // A reservation in flight can never be stolen. An active owner can be replaced only by the
85
+ // same server instance, which is the process that can actually make its old socket inert.
86
+ // Cross-process replacement without an IPC/data-plane handoff would create two live owners.
87
+ if (existing && (existing.state === 'reserving' || !input.replace || existing.serverId !== input.serverId)) {
88
+ return { ok: false };
89
+ }
90
+ const now = worldNow();
91
+ const claim = {
92
+ tunnelId: input.tunnelId,
93
+ token: input.token,
94
+ serverId: input.serverId,
95
+ socketId: input.socketId,
96
+ pid: process.pid,
97
+ hostname: hostname(),
98
+ state: 'reserving',
99
+ claimedAt: now,
100
+ heartbeatAt: now,
101
+ };
102
+ store.writeAtomic(path, `${JSON.stringify(claim)}\n`);
103
+ return { ok: true, ...(existing ? { previous: existing } : {}) };
104
+ });
105
+ }
106
+ function refreshRelayClaim(root, tunnelId, token) {
107
+ const path = relayClaimPath(root, tunnelId);
108
+ withFileLock(`${path}.lock`, () => {
109
+ const claim = readRelayClaim(path);
110
+ if (claim?.token === token) {
111
+ getActiveWorldStore().writeAtomic(path, `${JSON.stringify({ ...claim, heartbeatAt: worldNow() })}\n`);
112
+ }
113
+ });
114
+ }
115
+ function activateRelayClaim(root, tunnelId, token) {
116
+ const path = relayClaimPath(root, tunnelId);
117
+ withFileLock(`${path}.lock`, () => {
118
+ const claim = readRelayClaim(path);
119
+ if (!claim || claim.token !== token || claim.state !== 'reserving') {
120
+ throw new Error(`Relay ownership reservation was lost before commit: ${tunnelId}`);
121
+ }
122
+ getActiveWorldStore().writeAtomic(path, `${JSON.stringify({ ...claim, state: 'active' })}\n`);
123
+ });
124
+ }
125
+ function restoreRelayClaim(root, tunnelId, token, previous) {
126
+ const path = relayClaimPath(root, tunnelId);
127
+ withFileLock(`${path}.lock`, () => {
128
+ const claim = readRelayClaim(path);
129
+ if (!claim || claim.token !== token)
130
+ return;
131
+ if (previous)
132
+ getActiveWorldStore().writeAtomic(path, `${JSON.stringify(previous)}\n`);
133
+ else
134
+ getActiveWorldStore().remove(path);
135
+ });
136
+ }
137
+ function releaseRelayClaim(root, tunnelId, token) {
138
+ const path = relayClaimPath(root, tunnelId);
139
+ withFileLock(`${path}.lock`, () => {
140
+ if (readRelayClaim(path)?.token === token)
141
+ getActiveWorldStore().remove(path);
142
+ });
143
+ }
144
+ function b64url(value) {
145
+ return Buffer.from(value).toString('base64url');
146
+ }
147
+ export function createTunnelTwinJwt(payload, secret = DEFAULT_JWT_SECRET) {
148
+ const header = b64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' }));
149
+ const body = b64url(JSON.stringify(payload));
150
+ const signature = createHmac('sha256', secret).update(`${header}.${body}`).digest('base64url');
151
+ return `${header}.${body}.${signature}`;
152
+ }
153
+ function constantEqual(a, b) {
154
+ const aa = Buffer.from(a);
155
+ const bb = Buffer.from(b);
156
+ return aa.length === bb.length && timingSafeEqual(aa, bb);
157
+ }
158
+ function verifyJwt(token, secret, tunnelId, requireTid) {
159
+ try {
160
+ const [header, body, signature, extra] = token.split('.');
161
+ if (!header || !body || !signature || extra !== undefined)
162
+ return false;
163
+ const parsedHeader = JSON.parse(Buffer.from(header, 'base64url').toString('utf8'));
164
+ if (parsedHeader.alg !== 'HS256')
165
+ return false;
166
+ const expected = createHmac('sha256', secret).update(`${header}.${body}`).digest('base64url');
167
+ if (!constantEqual(signature, expected))
168
+ return false;
169
+ const payload = JSON.parse(Buffer.from(body, 'base64url').toString('utf8'));
170
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload))
171
+ return false;
172
+ const now = Math.floor(Date.now() / 1000);
173
+ if (payload.exp !== undefined && (typeof payload.exp !== 'number' || !Number.isFinite(payload.exp)))
174
+ return false;
175
+ if (payload.nbf !== undefined && (typeof payload.nbf !== 'number' || !Number.isFinite(payload.nbf)))
176
+ return false;
177
+ if (typeof payload.exp === 'number' && payload.exp <= now)
178
+ return false;
179
+ if (typeof payload.nbf === 'number' && payload.nbf > now)
180
+ return false;
181
+ // Exact owned default: a string tid, when present, binds the token to that tunnel. A missing
182
+ // (or non-string) tid is shared SSO unless the operator explicitly enables REQUIRE_TID mode.
183
+ const tid = typeof payload.tid === 'string' ? payload.tid : undefined;
184
+ if (tid !== undefined)
185
+ return tid === tunnelId;
186
+ return !requireTid;
187
+ }
188
+ catch {
189
+ return false;
190
+ }
191
+ }
192
+ function jsonError(status, error) {
193
+ return Response.json({ error }, { status, headers: { 'cache-control': 'no-store' } });
194
+ }
195
+ function headersObject(headers) {
196
+ const out = {};
197
+ headers.forEach((value, key) => { out[key] = value; });
198
+ return out;
199
+ }
200
+ function corsHeaders(request) {
201
+ const origin = request.headers.get('origin');
202
+ if (!origin)
203
+ return {};
204
+ return {
205
+ 'access-control-allow-origin': origin,
206
+ 'access-control-allow-methods': 'GET, POST, PUT, PATCH, DELETE, OPTIONS',
207
+ 'access-control-allow-headers': request.headers.get('access-control-request-headers') || 'Content-Type, Authorization, X-Sandbox-Id',
208
+ 'access-control-allow-credentials': 'true',
209
+ };
210
+ }
211
+ function responseHeaders(raw = {}, request, forwardedPath = '/', setCookie) {
212
+ const out = new Headers();
213
+ for (const [key, value] of Object.entries(raw)) {
214
+ const k = key.toLowerCase();
215
+ if (/^(connection|transfer-encoding|content-length|keep-alive|upgrade|x-frame-options)$/i.test(k))
216
+ continue;
217
+ if (/^access-control-allow-/i.test(k))
218
+ continue;
219
+ if (k === 'content-security-policy' || k === 'content-security-policy-report-only') {
220
+ const values = Array.isArray(value) ? value : [value];
221
+ for (const item of values) {
222
+ const cleaned = item.split(';').filter((part) => !part.trim().toLowerCase().startsWith('frame-ancestors')).join(';').trim();
223
+ if (cleaned)
224
+ out.append(k, cleaned);
225
+ }
226
+ continue;
227
+ }
228
+ if (Array.isArray(value))
229
+ for (const item of value)
230
+ out.append(k, item);
231
+ else
232
+ out.set(k, value);
233
+ }
234
+ if (request)
235
+ for (const [key, value] of Object.entries(corsHeaders(request)))
236
+ out.set(key, value);
237
+ const cacheControl = (out.get('cache-control') ?? '').toLowerCase();
238
+ const explicitlyPublic = /(?:^|,)\s*public(?:\s|,|$)/.test(cacheControl)
239
+ && !/(?:^|,)\s*(?:private|no-store|no-cache)(?:\s|,|=|$)/.test(cacheControl);
240
+ const pathname = new URL(forwardedPath, 'http://twin.local').pathname;
241
+ if (!explicitlyPublic || pathname === '/api' || pathname.startsWith('/api/')) {
242
+ out.set('cache-control', 'private, no-store, max-age=0');
243
+ out.set('cloudflare-cdn-cache-control', 'no-store');
244
+ out.set('cdn-cache-control', 'no-store');
245
+ const vary = new Set((out.get('vary') ?? '').split(',').map((value) => value.trim()).filter(Boolean));
246
+ vary.add('Cookie');
247
+ vary.add('Authorization');
248
+ out.set('vary', [...vary].join(', '));
249
+ }
250
+ if (setCookie)
251
+ out.append('set-cookie', setCookie);
252
+ return out;
253
+ }
254
+ function validResponseStatus(value) {
255
+ return typeof value === 'number' && Number.isInteger(value) && value >= 200 && value <= 599;
256
+ }
257
+ function validResponseHeaders(value) {
258
+ if (!value || typeof value !== 'object' || Array.isArray(value))
259
+ return false;
260
+ try {
261
+ for (const [key, raw] of Object.entries(value)) {
262
+ if (!key || (typeof raw !== 'string' && !(Array.isArray(raw) && raw.every((item) => typeof item === 'string'))))
263
+ return false;
264
+ for (const item of Array.isArray(raw) ? raw : [raw])
265
+ new Headers([[key, item]]);
266
+ }
267
+ return true;
268
+ }
269
+ catch {
270
+ return false;
271
+ }
272
+ }
273
+ function decodeBase64(value) {
274
+ if (typeof value !== 'string' || value.length % 4 === 1 || !/^[A-Za-z0-9+/]*={0,2}$/.test(value))
275
+ return undefined;
276
+ const bytes = Buffer.from(value, 'base64');
277
+ if (bytes.toString('base64').replace(/=+$/, '') !== value.replace(/=+$/, ''))
278
+ return undefined;
279
+ return bytes;
280
+ }
281
+ // This listener multiplexes an app origin with a deliberately small control plane. Keep known
282
+ // provider-management namespaces out of the app fallback so an unsupported Cloudflare/owned
283
+ // management call fails locally, while arbitrary product routes (including /api/*) still reach
284
+ // the configured application.
285
+ export function tunnelControlPathIsReserved(pathname) {
286
+ const maxPathLength = 8_192;
287
+ const maxDecodePasses = 8;
288
+ if (pathname.length > maxPathLength)
289
+ return true;
290
+ let path = pathname;
291
+ try {
292
+ // Decode repeatedly so an encoded separator cannot hide a management namespace, then
293
+ // normalize repeated slash/backslash separators and dot segments. This normalized value is
294
+ // used only for the control-plane decision; ordinary application requests retain their
295
+ // original path when forwarded below. Inputs that do not reach a fixed point inside the
296
+ // bounded work budget fail closed rather than becoming an origin-routing bypass.
297
+ let stable = false;
298
+ for (let i = 0; i < maxDecodePasses; i++) {
299
+ const decoded = decodeURIComponent(path);
300
+ if (decoded.length > maxPathLength)
301
+ return true;
302
+ if (decoded === path) {
303
+ stable = true;
304
+ break;
305
+ }
306
+ path = decoded;
307
+ }
308
+ if (!stable && decodeURIComponent(path) !== path)
309
+ return true;
310
+ }
311
+ catch {
312
+ return true;
313
+ }
314
+ const segments = [];
315
+ for (const segment of path.replaceAll('\\', '/').split('/')) {
316
+ if (!segment || segment === '.')
317
+ continue;
318
+ if (segment === '..')
319
+ segments.pop();
320
+ else
321
+ segments.push(segment);
322
+ }
323
+ path = `/${segments.join('/')}`;
324
+ return path === '/'
325
+ || /^\/(?:tunnel|api\/status|ws|__volter_auth|__volter_inspect|__volter_replay|__twin|docs)(?:\/|$)/.test(path)
326
+ || /^\/(?:accounts|zones|memberships|user|client\/v4|me|admin|signup|report|waitlist)(?:\/|$)/.test(path);
327
+ }
328
+ function canonicalDottedIpv4(host) {
329
+ const parts = host.split('.');
330
+ return parts.length === 4 && parts.every((part) => /^(?:0|[1-9][0-9]{0,2})$/.test(part) && Number(part) <= 255);
331
+ }
332
+ export function parseTunnelLoopbackOrigin(origin) {
333
+ const raw = /^https?:\/\/(\[[0-9a-f:.]+\]|[a-z0-9.-]+)(?::([0-9]{1,5}))?\/?$/i.exec(origin);
334
+ if (!raw)
335
+ throw new Error('quick origin must use strict http(s) origin syntax');
336
+ const rawHost = raw[1];
337
+ const rawPort = raw[2];
338
+ let parsed;
339
+ try {
340
+ parsed = new URL(origin);
341
+ }
342
+ catch {
343
+ throw new Error('quick origin must be a valid http(s) URL');
344
+ }
345
+ if (!['http:', 'https:'].includes(parsed.protocol))
346
+ throw new Error('quick origin must use http: or https:');
347
+ const hostname = parsed.hostname.replace(/^\[|\]$/g, '').toLowerCase();
348
+ // Validate the raw numeric authority against WHATWG's normalized host. Otherwise forms such as
349
+ // 127.1, 0177.0.0.1, or 127.0.0.1. normalize into an apparently canonical loopback address.
350
+ if (!rawHost.startsWith('[') && isIP(hostname) === 4
351
+ && (!canonicalDottedIpv4(rawHost) || rawHost !== hostname)) {
352
+ throw new Error('quick origin IPv4 host must use canonical dotted-decimal syntax');
353
+ }
354
+ if (rawPort && rawPort.length > 1 && rawPort.startsWith('0')) {
355
+ throw new Error('quick origin port must use canonical decimal syntax');
356
+ }
357
+ const loopback = hostname === 'localhost' || hostname === '::1'
358
+ || (isIP(hostname) === 4 && hostname.split('.')[0] === '127');
359
+ if (!loopback)
360
+ throw new Error('quick origin must be loopback');
361
+ if (parsed.username || parsed.password)
362
+ throw new Error('quick origin must not contain credentials');
363
+ if (parsed.pathname !== '/' || parsed.search || parsed.hash)
364
+ throw new Error('quick origin must be an origin without path, query, or fragment');
365
+ return parsed;
366
+ }
367
+ function quickOriginTarget(origin, pathname, search) {
368
+ const target = new URL(origin);
369
+ // Assign components onto the already-approved origin. `new URL(pathname, origin)` is unsafe
370
+ // here because a path beginning `//host/` is a scheme-relative URL that replaces the host.
371
+ target.pathname = pathname || '/';
372
+ target.search = search;
373
+ target.hash = '';
374
+ return target;
375
+ }
376
+ function tokenFromRequest(request, url) {
377
+ const authorization = request.headers.get('authorization');
378
+ if (authorization?.startsWith('Bearer '))
379
+ return { token: authorization.slice(7), source: 'header' };
380
+ const query = url.searchParams.get('__volter_token');
381
+ if (query)
382
+ return { token: query, source: 'query' };
383
+ for (const part of (request.headers.get('cookie') ?? '').split(';')) {
384
+ const [key, ...rest] = part.trim().split('=');
385
+ if (key === '__volter_auth')
386
+ return { token: rest.join('='), source: 'cookie' };
387
+ }
388
+ return {};
389
+ }
390
+ function forwardedPath(path, search) {
391
+ const url = new URL(`${path}${search}`, 'http://twin.local');
392
+ url.searchParams.delete('__volter_token');
393
+ url.searchParams.delete('__tunnel');
394
+ return url.pathname + (url.searchParams.size ? `?${url.searchParams}` : '');
395
+ }
396
+ function forwardedHeaders(request) {
397
+ const out = headersObject(request.headers);
398
+ const cookie = out.cookie?.split(';').filter((part) => part.trim().split('=')[0] !== '__volter_auth').join(';').trim();
399
+ if (cookie)
400
+ out.cookie = cookie;
401
+ else
402
+ delete out.cookie;
403
+ delete out['x-forwarded-for'];
404
+ out['x-forwarded-for'] = '0.0.0.0';
405
+ return out;
406
+ }
407
+ /**
408
+ * THE RELAY, host-neutral: a fetch that may answer an upgrade, and the socket handlers. The loopback
409
+ * server below runs it under `Bun.serve`; a hosted World runs it with `WebSocketPair`
410
+ * (createTunnelTwinFetch), since a Worker answers WebSocket upgrades as it answers HTTP.
411
+ */
412
+ export function createTunnelTwinHandler(options = {}) {
413
+ // The relay's state is the World's it was created in. A socket's frames and the heartbeat run
414
+ // outside any request, so they re-enter that World's store themselves (a hosted World's store is
415
+ // scoped to the request that reached it).
416
+ const store = getActiveWorldStore();
417
+ const scoped = (fn) => withWorldStore(store, fn);
418
+ const controls = new Map();
419
+ /** Live bridged browser sockets, by the `connId` the owned protocol correlates on. */
420
+ const visitors = new Map();
421
+ const claimTokens = new Map();
422
+ const reservationTokens = new Map();
423
+ const registrationTurns = new Map();
424
+ const allocatedQuickIds = new Set();
425
+ const quickOrigins = new Map();
426
+ const pending = new Map();
427
+ const jwtSecret = options.jwtSecret ?? DEFAULT_JWT_SECRET;
428
+ const requireTid = options.requireTid === true;
429
+ const serverId = randomUUID();
430
+ let baseUrl = '';
431
+ // THE HEARTBEAT — the relay writing its own liveness, which is the only thing that keeps its
432
+ // claim alive (relayClaimAlive). Throttled to the keepalive interval so a streamed response
433
+ // cannot take the claim lock once per chunk.
434
+ const lastHeartbeat = new Map();
435
+ const beatRelayClaim = (tunnelId) => {
436
+ if (!tunnelId)
437
+ return;
438
+ const token = claimTokens.get(tunnelId) ?? reservationTokens.get(tunnelId);
439
+ if (!token) {
440
+ lastHeartbeat.delete(tunnelId);
441
+ return;
442
+ }
443
+ const now = Date.parse(worldNow());
444
+ const previous = lastHeartbeat.get(tunnelId);
445
+ if (previous !== undefined && Number.isFinite(now) && Math.abs(now - previous) < RELAY_CLAIM_HEARTBEAT_MS)
446
+ return;
447
+ lastHeartbeat.set(tunnelId, now);
448
+ try {
449
+ refreshRelayClaim(options.root, tunnelId, token);
450
+ }
451
+ catch { /* a missed stamp only shortens this claim's freshness; the next frame or tick retries */ }
452
+ };
453
+ // The idle keepalive: a connected relay with no traffic must not look dead. Control frames beat
454
+ // the claim too (below), so the claim stays fresh wherever a long-lived timer does not run.
455
+ const claimHeartbeat = setInterval(() => scoped(() => {
456
+ for (const [tunnelId] of [...claimTokens, ...reservationTokens])
457
+ beatRelayClaim(tunnelId);
458
+ }), RELAY_CLAIM_HEARTBEAT_MS);
459
+ claimHeartbeat.unref?.();
460
+ const withRegistrationTurn = async (tunnelId, task) => {
461
+ const previous = registrationTurns.get(tunnelId) ?? Promise.resolve();
462
+ let release;
463
+ const current = new Promise((resolve) => { release = resolve; });
464
+ registrationTurns.set(tunnelId, current);
465
+ await previous;
466
+ try {
467
+ return await task();
468
+ }
469
+ finally {
470
+ release();
471
+ if (registrationTurns.get(tunnelId) === current)
472
+ registrationTurns.delete(tunnelId);
473
+ }
474
+ };
475
+ const disconnectRegistration = (tunnelId, registrationId, occurredAt = worldNow()) => withRegistrationTurn(tunnelId, () => markTunnelDisconnected(tunnelId, options.root, occurredAt, registrationId));
476
+ const pendingId = (item) => [...pending].find(([, value]) => value === item)?.[0];
477
+ /**
478
+ * End a request that cannot complete: the visitor gets a stable JSON error and the client is
479
+ * told to stop producing.
480
+ *
481
+ * A status is ALWAYS available here, which is the whole point of buffering to `response-end`
482
+ * (see the `response-start` branch below): the head is never committed, so there is no state
483
+ * in which this has to fall back to truncating a response the visitor would read as complete.
484
+ */
485
+ const failPending = (item, status, error) => {
486
+ clearTimeout(item.timer);
487
+ const reqId = pendingId(item);
488
+ if (reqId) {
489
+ pending.delete(reqId);
490
+ // `request-abort` is the vendor's frame for "stop producing this one" and the client
491
+ // destroys its local request on it. It is sent from the ONE place a request ends badly —
492
+ // timeout, visitor hang-up, malformed frame, owner disconnect — so every such ending stops
493
+ // the client rather than leaving it producing into a request the relay has forgotten.
494
+ // Looking the id up BEFORE deleting is also what makes this idempotent: a second call for
495
+ // the same item finds no id and sends nothing.
496
+ try {
497
+ item.owner.send(JSON.stringify({ type: 'request-abort', reqId }));
498
+ }
499
+ catch { /* disconnected */ }
500
+ }
501
+ if (!item.resolved) {
502
+ item.resolved = true;
503
+ item.resolve(jsonError(status, error));
504
+ }
505
+ };
506
+ const failSocketPending = (owner, error) => {
507
+ for (const item of [...pending.values()])
508
+ if (item.owner === owner)
509
+ failPending(item, 502, error);
510
+ };
511
+ const armTimeout = (reqId, item) => {
512
+ clearTimeout(item.timer);
513
+ item.timer = setTimeout(() => {
514
+ if (pending.get(reqId) !== item)
515
+ return;
516
+ failPending(item, 504, 'Tunnel request timed out');
517
+ }, options.requestTimeoutMs ?? 15_000);
518
+ };
519
+ const relay = {
520
+ async fetch(request, bunServer) {
521
+ try {
522
+ const url = new URL(request.url);
523
+ // GET /twin — the discovery manifest (education inside the twin).
524
+ if (request.method === 'GET' && url.pathname.replace(/\/+$/, '') === '/twin') {
525
+ return Response.json(statefulTwinManifest({ vendor: 'tunnel', twinOf: 'the Volter tunnel surfaces (Cloudflare quick tunnels + volter-tunnel)', stores: 'allocated tunnels and their public hostnames' }));
526
+ }
527
+ if (url.pathname === '/ws') {
528
+ const upgraded = bunServer.upgrade(request, {
529
+ data: {
530
+ kind: 'control', socketId: randomUUID(), authoritativeId: url.searchParams.get('id') || undefined, session: {}, closed: false,
531
+ // mounted under a World's path, the relay is reached there, not at its own origin
532
+ publicBase: request.headers.get(TWIN_PREFIX_HEADER) ? twinPublicBase(request) : baseUrl,
533
+ },
534
+ });
535
+ return upgraded ? undefined : jsonError(426, 'expected websocket');
536
+ }
537
+ if (url.pathname === '/__volter_auth') {
538
+ const tunnelId = url.searchParams.get('__tunnel') ?? '';
539
+ const token = url.searchParams.get('__volter_token') ?? '';
540
+ if (!tunnelId || !token)
541
+ return jsonError(400, 'Missing token or tunnel parameter');
542
+ if (!controls.has(tunnelId))
543
+ return jsonError(502, 'Tunnel not connected');
544
+ if (!jwtSecret || !verifyJwt(token, jwtSecret, tunnelId, requireTid))
545
+ return jsonError(401, 'Invalid token');
546
+ return Response.json({ ok: true }, { headers: {
547
+ 'cache-control': 'no-store',
548
+ 'set-cookie': `__volter_auth=${token}; HttpOnly; SameSite=Lax; Path=/__twin/tunnels/${encodeURIComponent(tunnelId)}/; Max-Age=3600`,
549
+ } });
550
+ }
551
+ const relay = /^\/__twin\/tunnels\/([^/]+)(\/.*)?$/.exec(url.pathname);
552
+ if (relay) {
553
+ const tunnelId = decodeURIComponent(relay[1]);
554
+ const socket = controls.get(tunnelId);
555
+ if (!socket || !socket.data.session.registered || socket.data.session.superseded)
556
+ return jsonError(502, 'Tunnel not connected');
557
+ if (request.method === 'OPTIONS')
558
+ return new Response(null, { status: 204, headers: corsHeaders(request) });
559
+ const session = socket.data.session;
560
+ if (session.basicAuth) {
561
+ const authorization = request.headers.get('authorization') ?? '';
562
+ const expected = `Basic ${Buffer.from(`${session.basicAuth.user}:${session.basicAuth.pass}`).toString('base64')}`;
563
+ if (!constantEqual(authorization, expected)) {
564
+ return Response.json({ error: 'Authentication required' }, {
565
+ status: 401, headers: { ...corsHeaders(request), 'www-authenticate': 'Basic realm="volter-tunnel"' },
566
+ });
567
+ }
568
+ }
569
+ let setCookie;
570
+ if (session.authRequired) {
571
+ const auth = tokenFromRequest(request, url);
572
+ if (!auth.token || !jwtSecret || !verifyJwt(auth.token, jwtSecret, tunnelId, requireTid)) {
573
+ return Response.json({ error: 'Authentication required' }, { status: 401, headers: corsHeaders(request) });
574
+ }
575
+ if (auth.source === 'query') {
576
+ setCookie = `__volter_auth=${auth.token}; HttpOnly; SameSite=Lax; Path=/__twin/tunnels/${encodeURIComponent(tunnelId)}/; Max-Age=3600`;
577
+ }
578
+ }
579
+ // A browser WebSocket through the tunnel. It passes the SAME Basic/JWT gates above
580
+ // before any bridge exists — an upgrade that skipped them would be an unauthenticated
581
+ // hole beside an authenticated door. The `ws-upgrade` frame is sent from `open`, once
582
+ // there is a real socket to bridge; the client answers `ws-ready` or `ws-error`.
583
+ if ((request.headers.get('upgrade') ?? '').toLowerCase() === 'websocket') {
584
+ const connId = randomUUID();
585
+ // THE SUBPROTOCOL IS NOT ECHOED HERE, AND MUST NOT BE: `server.upgrade` already
586
+ // answers with the browser's FIRST offer (measured — a bare upgrade of a
587
+ // `Sec-WebSocket-Protocol: vite-hmr, graphql-ws` handshake returns
588
+ // `Sec-WebSocket-Protocol: vite-hmr`). This lane added a redundant echo of its own
589
+ // on the strength of a §9 finding that turned out to be wrong about the runtime,
590
+ // and removed it again once measured; the OBSERVABLE contract is pinned instead, on
591
+ // the raw 101 bytes, by `tunnel.volter.websocket.upgrade` — so the day the runtime
592
+ // stops doing it, the verify says so rather than a duplicate silently covering.
593
+ //
594
+ // FIRST-OFFER-WINS IS NOT NEGOTIATION, and the protocol cannot make it one. The 101
595
+ // goes out before `ws-ready` exists, so the relay commits to a subprotocol before
596
+ // the client has dialled the local server — which may pick a DIFFERENT one from the
597
+ // full list the `ws-upgrade` frame forwards. None of the vendor's fifteen
598
+ // discriminators can carry that choice back (`ws-ready` carries only `connId`), so a
599
+ // multi-offer mismatch is a structural gap, not an unfinished one:
600
+ // `tunnel.volter.websocket.subprotocol_negotiation`.
601
+ const upgradeHeaders = {
602
+ // A query-token upgrade gets the same cookie the HTTP path hands back, so the
603
+ // page's SUBSEQUENT requests are authenticated without re-carrying the token.
604
+ // This one IS the relay's, and is pinned on the raw 101 by the auth-gate verify.
605
+ ...(setCookie ? { 'set-cookie': setCookie } : {}),
606
+ };
607
+ const upgraded = bunServer.upgrade(request, {
608
+ ...(Object.keys(upgradeHeaders).length > 0 ? { headers: upgradeHeaders } : {}),
609
+ data: {
610
+ kind: 'visitor', socketId: randomUUID(), session: {}, closed: false,
611
+ visitor: {
612
+ connId, tunnelId, owner: socket,
613
+ path: forwardedPath(relay[2] || '/', url.search),
614
+ headers: forwardedHeaders(request),
615
+ ready: false, queue: [], queuedBytes: 0, closeRelayed: false,
616
+ },
617
+ },
618
+ });
619
+ return upgraded ? undefined : jsonError(426, 'expected websocket');
620
+ }
621
+ const reqId = randomUUID();
622
+ const path = forwardedPath(relay[2] || '/', url.search);
623
+ const body = request.method === 'GET' || request.method === 'HEAD' ? null : Buffer.from(await request.arrayBuffer()).toString('base64');
624
+ const response = new Promise((resolve) => {
625
+ const item = {
626
+ resolve, timer: setTimeout(() => { }, 0), owner: socket, tunnelId, path, request,
627
+ ...(setCookie ? { setCookie } : {}), resolved: false,
628
+ };
629
+ pending.set(reqId, item);
630
+ armTimeout(reqId, item);
631
+ // The visitor hung up. `failPending` sends the `request-abort` (one place, so the
632
+ // frame cannot be sent twice or forgotten on one of the four ending paths).
633
+ request.signal.addEventListener('abort', () => {
634
+ if (pending.get(reqId) !== item)
635
+ return;
636
+ failPending(item, 499, 'Visitor disconnected');
637
+ }, { once: true });
638
+ });
639
+ try {
640
+ socket.send(JSON.stringify({ type: 'request', reqId, method: request.method, path, headers: forwardedHeaders(request), body }));
641
+ }
642
+ catch {
643
+ const item = pending.get(reqId);
644
+ if (item)
645
+ failPending(item, 502, 'Tunnel send failed');
646
+ }
647
+ return response;
648
+ }
649
+ const quick = /^\/__twin\/quick\/([^/]+)(\/.*)?$/.exec(url.pathname);
650
+ if (quick) {
651
+ const id = decodeURIComponent(quick[1]);
652
+ const origin = quickOrigins.get(id);
653
+ if (!origin)
654
+ return jsonError(502, 'Quick tunnel not connected');
655
+ const target = quickOriginTarget(origin, quick[2] || '/', url.search);
656
+ const init = { method: request.method, headers: request.headers, redirect: 'manual' };
657
+ if (request.method !== 'GET' && request.method !== 'HEAD')
658
+ init.body = await request.arrayBuffer();
659
+ try {
660
+ return await fetch(target, init);
661
+ }
662
+ catch {
663
+ return jsonError(502, 'Quick tunnel origin unavailable');
664
+ }
665
+ }
666
+ if (quickOrigins.size === 1 && !tunnelControlPathIsReserved(url.pathname)) {
667
+ const origin = quickOrigins.values().next().value;
668
+ const target = quickOriginTarget(origin, url.pathname, url.search);
669
+ const init = { method: request.method, headers: request.headers, redirect: 'manual' };
670
+ if (request.method !== 'GET' && request.method !== 'HEAD')
671
+ init.body = await request.arrayBuffer();
672
+ try {
673
+ return await fetch(target, init);
674
+ }
675
+ catch {
676
+ return jsonError(502, 'Quick tunnel origin unavailable');
677
+ }
678
+ }
679
+ const body = request.method === 'GET' || request.method === 'HEAD' ? '' : await request.text();
680
+ const out = await handleTunnelTwinRequest({
681
+ method: request.method, path: url.pathname + url.search, body, headers: headersObject(request.headers),
682
+ root: options.root, readOnly: options.readOnly, occurredAt: worldNow(), liveTunnels: controls.size,
683
+ });
684
+ if (request.method === 'POST' && url.pathname === '/tunnel' && out.status === 200) {
685
+ const id = out.body.result?.id;
686
+ if (typeof id === 'string')
687
+ allocatedQuickIds.add(id);
688
+ }
689
+ return new Response(out.body === null ? null : JSON.stringify(out.body), { status: out.status, headers: out.headers });
690
+ }
691
+ catch {
692
+ return jsonError(500, 'Tunnel twin request failed');
693
+ }
694
+ },
695
+ websocket: {
696
+ open(ws) {
697
+ if (ws.data.kind !== 'visitor')
698
+ return;
699
+ const visitor = ws.data.visitor;
700
+ // THE OWNER MUST STILL BE THERE. Bun runs `open` AFTER the fetch handler returns, so a
701
+ // control socket that died in that window has already run its own sweep — and this
702
+ // visitor was not in `visitors` yet for the sweep to find. §9 found the orphan that
703
+ // leaves: registered against a dead owner, in the map, reachable by no cleanup.
704
+ // `ServerWebSocket.send` RETURNS 0 on a closed socket rather than throwing, so the
705
+ // try/catch this used to rely on was dead code and could never have caught it.
706
+ if (!ownerAlive(visitor)) {
707
+ ws.close(1011, 'Tunnel disconnected');
708
+ return;
709
+ }
710
+ visitors.set(visitor.connId, ws);
711
+ // `ws-upgrade` is the relay→client frame the vendor defines for exactly this: connId,
712
+ // the tunnelled path, and the browser's headers (the client re-stamps host/origin and
713
+ // strips the hop-by-hop handshake headers itself before dialling the local server).
714
+ visitor.owner.send(JSON.stringify({ type: 'ws-upgrade', connId: visitor.connId, path: visitor.path, headers: visitor.headers }));
715
+ },
716
+ async message(ws, raw) {
717
+ // A VISITOR frame is payload, not protocol: it is relayed verbatim and never parsed as a
718
+ // control message. Branching first is what keeps a browser from reaching the registration
719
+ // reducer by sending `{"type":"register"}` over its bridged socket.
720
+ if (ws.data.kind === 'visitor') {
721
+ const visitor = ws.data.visitor;
722
+ const binary = typeof raw !== 'string';
723
+ const bytes = typeof raw === 'string' ? Buffer.from(raw, 'utf8') : Buffer.from(raw);
724
+ if (!visitor.ready) {
725
+ // Frames a browser sends between its own upgrade and the client's `ws-ready` are real
726
+ // application data (a client library often speaks first). Queue them, bounded — the
727
+ // alternative is dropping the first message of every connection.
728
+ visitor.queuedBytes += bytes.byteLength;
729
+ if (visitor.queuedBytes > VISITOR_PREREADY_LIMIT_BYTES) {
730
+ visitors.delete(visitor.connId);
731
+ ws.close(1011, 'Tunnel bridge not ready');
732
+ return;
733
+ }
734
+ visitor.queue.push({ data: new Uint8Array(bytes), binary });
735
+ return;
736
+ }
737
+ // A frame with nowhere to go must CLOSE the browser, not vanish into a dead owner —
738
+ // silently black-holing it is the shape the pre-ready queue exists to prevent, one
739
+ // step later. Checked, not caught: `send` does not throw.
740
+ if (!ownerAlive(visitor)) {
741
+ visitors.delete(visitor.connId);
742
+ ws.close(1011, 'Tunnel disconnected');
743
+ return;
744
+ }
745
+ visitor.owner.send(JSON.stringify({ type: 'ws-message', connId: visitor.connId, data: bytes.toString('base64'), binary }));
746
+ return;
747
+ }
748
+ let message;
749
+ try {
750
+ message = JSON.parse(typeof raw === 'string' ? raw : Buffer.from(raw).toString('utf8'));
751
+ }
752
+ catch {
753
+ ws.send(JSON.stringify({ type: 'error', fatal: true, message: 'invalid JSON' }));
754
+ return;
755
+ }
756
+ // A frame from this relay IS the evidence it is alive — record it as state before doing
757
+ // anything with the frame. (A `register` frame has no claim yet; it stamps its own at
758
+ // reservation.)
759
+ beatRelayClaim(ws.data.session.tunnelId);
760
+ try {
761
+ if (message.type === 'register') {
762
+ const prepared = prepareTunnelRelayRegistration({
763
+ message, session: ws.data.session, authoritativeId: ws.data.authoritativeId,
764
+ publicBaseUrl: ws.data.publicBase ?? baseUrl, readOnly: options.readOnly,
765
+ });
766
+ // Authentication and the complete frame schema are checked before we inspect, close,
767
+ // or transfer any live owner. A rejected replace:true frame is therefore inert.
768
+ if (!('persistence' in prepared)) {
769
+ if (prepared.outbound)
770
+ ws.send(JSON.stringify(prepared.outbound));
771
+ return;
772
+ }
773
+ const candidate = prepared;
774
+ const requested = candidate.registration.tunnelId;
775
+ await withRegistrationTurn(requested, async () => {
776
+ // Re-check only after entering the per-id turn. A contender that arrived while the
777
+ // winner awaited persistence must be refused BEFORE any action append.
778
+ const existing = controls.get(requested);
779
+ if (existing && existing !== ws && message.replace !== true) {
780
+ ws.send(JSON.stringify({ type: 'error', message: `Tunnel ID '${requested}' is already in use by another client. Pass { replace: true } to take over.` }));
781
+ ws.close(4002, 'Tunnel ID already in use');
782
+ return;
783
+ }
784
+ if (existing === ws && ws.data.session.registered && !ws.data.session.superseded) {
785
+ if (candidate.outbound)
786
+ ws.send(JSON.stringify(candidate.outbound));
787
+ return;
788
+ }
789
+ const claimToken = randomUUID();
790
+ const reservation = reserveRelayClaim({
791
+ root: options.root,
792
+ tunnelId: requested,
793
+ token: claimToken,
794
+ serverId,
795
+ socketId: ws.data.socketId,
796
+ replace: message.replace === true,
797
+ });
798
+ if (!reservation.ok) {
799
+ ws.send(JSON.stringify({ type: 'error', message: `Tunnel ID '${requested}' is already in use by another client. Pass { replace: true } to take over.` }));
800
+ ws.close(4002, 'Tunnel ID already in use');
801
+ return;
802
+ }
803
+ reservationTokens.set(requested, claimToken);
804
+ try {
805
+ // The transient claim is already exclusive across both sockets and processes.
806
+ // Only its winner may append relay.register; a storage failure restores the prior
807
+ // claim and leaves every live mapping/session untouched.
808
+ await (options.persistRegistration ?? persistTunnelRelayRegistration)(candidate, {
809
+ root: options.root,
810
+ occurredAt: worldNow(),
811
+ registrationId: claimToken,
812
+ });
813
+ if (ws.data.closed) {
814
+ reservationTokens.delete(requested);
815
+ // The socket vanished after its action append but before it could own traffic.
816
+ // Suppress that exact generation before restoring the previous claim; otherwise
817
+ // its registration_id would hide the prior owner's later conditional disconnect.
818
+ revertTunnelRelayRegistration(requested, claimToken, {
819
+ root: options.root,
820
+ occurredAt: worldNow(),
821
+ });
822
+ restoreRelayClaim(options.root, requested, claimToken, reservation.previous);
823
+ if (!controls.has(requested)) {
824
+ if (reservation.previous) {
825
+ await markTunnelDisconnected(requested, options.root, worldNow(), reservation.previous.token);
826
+ }
827
+ }
828
+ return;
829
+ }
830
+ activateRelayClaim(options.root, requested, claimToken);
831
+ reservationTokens.delete(requested);
832
+ }
833
+ catch (error) {
834
+ reservationTokens.delete(requested);
835
+ let revertError;
836
+ try {
837
+ revertTunnelRelayRegistration(requested, claimToken, {
838
+ root: options.root,
839
+ occurredAt: worldNow(),
840
+ });
841
+ }
842
+ catch (candidate) {
843
+ revertError = candidate;
844
+ }
845
+ restoreRelayClaim(options.root, requested, claimToken, reservation.previous);
846
+ if (revertError) {
847
+ throw new Error(`Registration persistence failed and its durable action could not be reverted: ${revertError instanceof Error ? revertError.message : String(revertError)}`, { cause: error });
848
+ }
849
+ throw error;
850
+ }
851
+ const previous = controls.get(requested);
852
+ const oldAliases = [...controls.entries()]
853
+ .filter(([id, owner]) => owner === ws && id !== requested)
854
+ .map(([id]) => id);
855
+ ws.data.session = candidate.session;
856
+ controls.set(requested, ws);
857
+ claimTokens.set(requested, claimToken);
858
+ if (oldAliases.length > 0) {
859
+ failSocketPending(ws, 'Tunnel identity changed');
860
+ for (const id of oldAliases) {
861
+ const oldToken = claimTokens.get(id);
862
+ controls.delete(id);
863
+ claimTokens.delete(id);
864
+ if (oldToken)
865
+ void disconnectRegistration(id, oldToken).catch(() => { });
866
+ if (oldToken)
867
+ releaseRelayClaim(options.root, id, oldToken);
868
+ }
869
+ }
870
+ if (previous && previous !== ws) {
871
+ previous.data.session.registered = false;
872
+ previous.data.session.superseded = true;
873
+ failSocketPending(previous, 'Tunnel replaced');
874
+ previous.close(4001, 'Replaced by new client');
875
+ }
876
+ if (candidate.outbound)
877
+ ws.send(JSON.stringify(candidate.outbound));
878
+ });
879
+ return;
880
+ }
881
+ if (!ws.data.session.registered || ws.data.session.superseded) {
882
+ ws.send(JSON.stringify({ type: 'error', fatal: true, message: 'control socket is not the registered tunnel owner' }));
883
+ return;
884
+ }
885
+ // ── the WebSocket half of the protocol, correlated on connId ──────────────────
886
+ //
887
+ // The DECISION is `reduceTunnelWsFrame`'s, in tunnel-twin.ts — a mutation seam, so the
888
+ // bridge's verifies are killed at the frame and not merely at registration (§9). This
889
+ // adapter supplies the one thing the reducer cannot know (who owns the connId) and
890
+ // then APPLIES the decision to real sockets.
891
+ if (message.type === 'ws-ready' || message.type === 'ws-error' || message.type === 'ws-message' || message.type === 'ws-close') {
892
+ const rawConnId = message.connId;
893
+ const connId = typeof rawConnId === 'string' ? rawConnId : '';
894
+ const visitorSocket = connId ? visitors.get(connId) : undefined;
895
+ const visitor = visitorSocket?.data.visitor;
896
+ const ownership = !visitorSocket || !visitor ? 'unknown' : visitor.owner === ws ? 'self' : 'other';
897
+ const decision = reduceTunnelWsFrame({ message, ownership });
898
+ if (decision.kind === 'ignore')
899
+ return;
900
+ if (decision.kind === 'error') {
901
+ ws.send(JSON.stringify({ type: 'error', message: decision.message }));
902
+ return;
903
+ }
904
+ // Past `ignore`, ownership was 'self', so both are present.
905
+ if (!visitorSocket || !visitor)
906
+ return;
907
+ if (decision.kind === 'ready') {
908
+ visitor.ready = true;
909
+ // Everything the browser said while the local socket was still dialling, in order.
910
+ // `ws` is the socket this very frame arrived on, so it is open by construction —
911
+ // the `catch { break }` that used to guard this loop was unreachable and would
912
+ // have dropped the remaining frames silently if it ever had run.
913
+ for (const frame of visitor.queue.splice(0)) {
914
+ ws.send(JSON.stringify({ type: 'ws-message', connId, data: Buffer.from(frame.data).toString('base64'), binary: frame.binary }));
915
+ }
916
+ visitor.queuedBytes = 0;
917
+ return;
918
+ }
919
+ if (decision.kind === 'deliver') {
920
+ // `binary` decides the frame type on the wire, so a text protocol stays text.
921
+ visitorSocket.send(decision.binary ? decision.data : Buffer.from(decision.data).toString('utf8'));
922
+ return;
923
+ }
924
+ // `close` — ws-error and ws-close both END the bridge, already accounted for on the
925
+ // client's side, so the visitor's own close handler must not echo one back.
926
+ visitor.closeRelayed = true;
927
+ visitors.delete(connId);
928
+ visitorSocket.close(decision.code, decision.reason);
929
+ return;
930
+ }
931
+ if (!('reqId' in message))
932
+ return;
933
+ const reqId = String(message.reqId);
934
+ const item = pending.get(reqId);
935
+ if (!item || item.owner !== ws || controls.get(item.tunnelId) !== ws) {
936
+ ws.send(JSON.stringify({ type: 'error', message: 'response correlation is not owned by this control socket' }));
937
+ return;
938
+ }
939
+ if (message.type === 'response') {
940
+ if (item.stream || item.resolved) {
941
+ failPending(item, 502, 'Complete response arrived after response start');
942
+ ws.send(JSON.stringify({ type: 'error', message: 'complete response arrived after response start' }));
943
+ return;
944
+ }
945
+ const body = decodeBase64(message.body);
946
+ if (!validResponseStatus(message.status) || !validResponseHeaders(message.headers) || body === undefined) {
947
+ failPending(item, 502, 'Malformed tunnel response frame');
948
+ ws.send(JSON.stringify({ type: 'error', message: 'malformed response frame' }));
949
+ return;
950
+ }
951
+ let response;
952
+ const bodyInit = body.byteLength === 0 && [204, 205, 304].includes(message.status) ? null : new Uint8Array(body);
953
+ try {
954
+ response = new Response(bodyInit, { status: message.status, headers: responseHeaders(message.headers, item.request, item.path, item.setCookie) });
955
+ }
956
+ catch {
957
+ failPending(item, 502, 'Malformed tunnel response frame');
958
+ return;
959
+ }
960
+ clearTimeout(item.timer);
961
+ pending.delete(reqId);
962
+ item.resolved = true;
963
+ item.resolve(response);
964
+ }
965
+ else if (message.type === 'response-start') {
966
+ if (item.stream || item.resolved) {
967
+ failPending(item, 502, 'Duplicate response start frame');
968
+ ws.send(JSON.stringify({ type: 'error', message: 'duplicate response-start frame' }));
969
+ return;
970
+ }
971
+ if (!validResponseStatus(message.status) || !validResponseHeaders(message.headers)) {
972
+ failPending(item, 502, 'Malformed tunnel response frame');
973
+ ws.send(JSON.stringify({ type: 'error', message: 'malformed response-start frame' }));
974
+ return;
975
+ }
976
+ // THE HEAD STAYS UNCOMMITTED UNTIL `response-end`, and that is a MEASURED trade, not
977
+ // an unfinished one. Through `Bun.serve`'s Response surface a body that has begun
978
+ // cannot be failed: erroring the `ReadableStream` behind it terminates the chunked
979
+ // body CLEANLY, so the visitor reads a truncated payload as a successful 200 and
980
+ // cannot tell it from a complete one — a fake success. It also drops a
981
+ // `content-length` the moment the body is a stream, removing the one framing a
982
+ // client could have detected the truncation from.
983
+ //
984
+ // §9 was right to refuse the broader form of that sentence: `node:http` CAN signal a
985
+ // mid-body failure, on this same runtime, in this same process — so the honest
986
+ // statement is that THIS HTTP HOST cannot, and that re-hosting the plane on
987
+ // `node:http` to gain incremental delivery would mean giving up `server.upgrade`,
988
+ // which is what the entire WebSocket bridge above is built on. All three facts are
989
+ // measured by `tunnel-stream-boundary.test.ts`; the day the first two flip,
990
+ // `tunnel.volter.http.streaming` and its two siblings become buildable here.
991
+ // Buffering costs latency on a large response; committing the head would cost the
992
+ // ability to fail one honestly, and this pack trades the first for the second.
993
+ item.stream = { status: message.status, headers: message.headers, chunks: [] };
994
+ armTimeout(reqId, item);
995
+ }
996
+ else if (message.type === 'response-chunk') {
997
+ if (!item.stream) {
998
+ failPending(item, 502, 'response chunk arrived before response start');
999
+ ws.send(JSON.stringify({ type: 'error', message: 'response chunk arrived before response start' }));
1000
+ return;
1001
+ }
1002
+ const data = decodeBase64(message.data);
1003
+ if (data === undefined) {
1004
+ failPending(item, 502, 'Malformed tunnel response chunk');
1005
+ ws.send(JSON.stringify({ type: 'error', message: 'malformed response-chunk frame' }));
1006
+ return;
1007
+ }
1008
+ item.stream.chunks.push(data);
1009
+ armTimeout(reqId, item);
1010
+ }
1011
+ else if (message.type === 'response-end') {
1012
+ if (!item.stream) {
1013
+ failPending(item, 502, 'response end arrived before response start');
1014
+ ws.send(JSON.stringify({ type: 'error', message: 'response end arrived before response start' }));
1015
+ return;
1016
+ }
1017
+ const bytes = Buffer.concat(item.stream.chunks.map((chunk) => Buffer.from(chunk)));
1018
+ const bodyInit = bytes.byteLength === 0 && [204, 205, 304].includes(item.stream.status) ? null : new Uint8Array(bytes);
1019
+ let response;
1020
+ try {
1021
+ response = new Response(bodyInit, {
1022
+ status: item.stream.status,
1023
+ headers: responseHeaders(item.stream.headers, item.request, item.path, item.setCookie),
1024
+ });
1025
+ }
1026
+ catch {
1027
+ failPending(item, 502, 'Malformed tunnel response frame');
1028
+ return;
1029
+ }
1030
+ clearTimeout(item.timer);
1031
+ pending.delete(reqId);
1032
+ item.resolved = true;
1033
+ item.resolve(response);
1034
+ }
1035
+ }
1036
+ catch {
1037
+ try {
1038
+ ws.send(JSON.stringify({ type: 'error', fatal: true, message: 'tunnel control frame failed' }));
1039
+ }
1040
+ catch { /* disconnected */ }
1041
+ }
1042
+ },
1043
+ close(ws, code, reason) {
1044
+ if (ws.data.kind === 'visitor') {
1045
+ const visitor = ws.data.visitor;
1046
+ visitors.delete(visitor.connId);
1047
+ // The browser hung up: tell the client so it closes its LOCAL socket too. Skipped when
1048
+ // the close came FROM the client (`closeRelayed`), which would otherwise loop a close
1049
+ // back at the side that sent it.
1050
+ if (!visitor.closeRelayed && ownerAlive(visitor)) {
1051
+ const safe = code >= 1000 && code <= 4999 && code !== 1005 && code !== 1006 ? code : 1000;
1052
+ visitor.owner.send(JSON.stringify({ type: 'ws-close', connId: visitor.connId, code: safe, reason: reason ?? '' }));
1053
+ }
1054
+ return;
1055
+ }
1056
+ ws.data.closed = true;
1057
+ failSocketPending(ws, 'Tunnel disconnected');
1058
+ // Every bridge this control socket owned dies with it — a visitor left open would be
1059
+ // talking to nothing.
1060
+ for (const [connId, visitorSocket] of [...visitors]) {
1061
+ if (visitorSocket.data.visitor?.owner !== ws)
1062
+ continue;
1063
+ visitorSocket.data.visitor.closeRelayed = true;
1064
+ visitors.delete(connId);
1065
+ try {
1066
+ visitorSocket.close(1011, 'Tunnel disconnected');
1067
+ }
1068
+ catch { /* already gone */ }
1069
+ }
1070
+ const ownedIds = [...controls.entries()].filter(([, owner]) => owner === ws).map(([id]) => id);
1071
+ for (const tunnelId of ownedIds) {
1072
+ const claimToken = claimTokens.get(tunnelId);
1073
+ controls.delete(tunnelId);
1074
+ claimTokens.delete(tunnelId);
1075
+ if (claimToken)
1076
+ void disconnectRegistration(tunnelId, claimToken).catch(() => { });
1077
+ if (claimToken)
1078
+ releaseRelayClaim(options.root, tunnelId, claimToken);
1079
+ }
1080
+ },
1081
+ },
1082
+ };
1083
+ return {
1084
+ fetch: relay.fetch,
1085
+ websocket: {
1086
+ open: (ws) => scoped(() => relay.websocket.open(ws)),
1087
+ message: (ws, raw) => scoped(() => relay.websocket.message(ws, raw)),
1088
+ close: (ws, code, reason) => scoped(() => relay.websocket.close(ws, code, reason)),
1089
+ },
1090
+ /** Where the relay is reached when a request does not say (the loopback server's own origin). */
1091
+ setBaseUrl(url) { baseUrl = url; },
1092
+ get url() { return baseUrl; },
1093
+ registerQuickOrigin(id, origin) {
1094
+ if (!allocatedQuickIds.has(id))
1095
+ throw new Error(`unknown quick tunnel allocation: ${id}`);
1096
+ const parsed = parseTunnelLoopbackOrigin(origin);
1097
+ quickOrigins.set(id, parsed.toString());
1098
+ },
1099
+ quickUrl(id) { return `${baseUrl}/__twin/quick/${encodeURIComponent(id)}`; },
1100
+ authUrl(tunnelId, token) {
1101
+ return `${baseUrl}/__volter_auth?__tunnel=${encodeURIComponent(tunnelId)}&__volter_token=${encodeURIComponent(token)}`;
1102
+ },
1103
+ mintToken(tunnelId, payload = {}) {
1104
+ if (!jwtSecret)
1105
+ throw new Error('tunnel JWT auth is disabled');
1106
+ return createTunnelTwinJwt({ sub: 'twin-local', tid: tunnelId, exp: Math.floor(Date.now() / 1000) + 3600, ...payload }, jwtSecret);
1107
+ },
1108
+ stop() {
1109
+ clearInterval(claimHeartbeat);
1110
+ for (const socket of new Set(controls.values()))
1111
+ failSocketPending(socket, 'Tunnel server stopped');
1112
+ for (const [tunnelId, token] of claimTokens) {
1113
+ void disconnectRegistration(tunnelId, token).catch(() => { });
1114
+ releaseRelayClaim(options.root, tunnelId, token);
1115
+ }
1116
+ claimTokens.clear();
1117
+ for (const [tunnelId, token] of reservationTokens)
1118
+ releaseRelayClaim(options.root, tunnelId, token);
1119
+ reservationTokens.clear();
1120
+ controls.clear();
1121
+ },
1122
+ };
1123
+ }
1124
+ /** The relay on loopback, under `Bun.serve`. */
1125
+ export function createTunnelTwinServer(options = {}) {
1126
+ const handler = createTunnelTwinHandler(options);
1127
+ const server = Bun.serve({
1128
+ hostname: '127.0.0.1',
1129
+ port: options.port ?? 0,
1130
+ idleTimeout: 60,
1131
+ fetch: (request, bunServer) => handler.fetch(request, bunServer),
1132
+ websocket: {
1133
+ open: (ws) => handler.websocket.open(ws),
1134
+ message: (ws, raw) => handler.websocket.message(ws, raw),
1135
+ close: (ws, code, reason) => handler.websocket.close(ws, code, reason),
1136
+ },
1137
+ });
1138
+ handler.setBaseUrl(`http://127.0.0.1:${server.port}`);
1139
+ return {
1140
+ port: server.port,
1141
+ url: handler.url,
1142
+ registerQuickOrigin: handler.registerQuickOrigin,
1143
+ quickUrl: handler.quickUrl,
1144
+ authUrl: handler.authUrl,
1145
+ mintToken: handler.mintToken,
1146
+ stop() {
1147
+ handler.stop();
1148
+ server.stop(true);
1149
+ },
1150
+ };
1151
+ }
1152
+ /**
1153
+ * The relay as a hosted World's twin wire: the same handler, its upgrades answered with a
1154
+ * `WebSocketPair` (a Worker takes a WebSocket as it takes HTTP). Frames are handed to the handler in
1155
+ * arrival order, as `Bun.serve` hands them.
1156
+ */
1157
+ export function createTunnelTwinFetch(options = {}) {
1158
+ const handler = createTunnelTwinHandler(options);
1159
+ return async (request) => {
1160
+ let upgraded;
1161
+ const answer = await handler.fetch(request, {
1162
+ upgrade(_request, init) {
1163
+ const Pair = globalThis.WebSocketPair;
1164
+ if (!Pair)
1165
+ return false;
1166
+ const pair = new Pair();
1167
+ const end = pair[1];
1168
+ end.accept();
1169
+ const socket = {
1170
+ data: init.data,
1171
+ send: (message) => end.send(message),
1172
+ close: (code, reason) => end.close(code, reason),
1173
+ get readyState() { return end.readyState; },
1174
+ };
1175
+ // One queue in arrival order, `open` first (Bun opens after the 101 is answered); a link that
1176
+ // throws does not stop the ones after it, and `close` runs once (an `error` is followed by one).
1177
+ let order = new Promise((resolve) => setTimeout(resolve, 0)).then(() => handler.websocket.open(socket));
1178
+ const next = (step) => { order = order.catch(() => undefined).then(step); order.catch(() => undefined); };
1179
+ let closed = false;
1180
+ const closeOnce = (code, reason) => { if (closed)
1181
+ return; closed = true; next(() => handler.websocket.close(socket, code, reason)); };
1182
+ end.addEventListener('message', (event) => {
1183
+ const data = event.data;
1184
+ next(async () => {
1185
+ const raw = typeof data === 'string' ? data : data instanceof ArrayBuffer ? new Uint8Array(data) : new Uint8Array(await data.arrayBuffer());
1186
+ void handler.websocket.message(socket, raw);
1187
+ });
1188
+ });
1189
+ end.addEventListener('close', (event) => closeOnce(event.code, event.reason));
1190
+ end.addEventListener('error', () => closeOnce(1006, ''));
1191
+ upgraded = new Response(null, { status: 101, webSocket: pair[0], ...(init.headers ? { headers: init.headers } : {}) });
1192
+ return true;
1193
+ },
1194
+ });
1195
+ return answer ?? upgraded ?? jsonError(426, 'expected websocket');
1196
+ };
1197
+ }