@vzn/vx-dte-server 0.0.0-stage → 0.0.649

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.
package/src/relay.ts ADDED
@@ -0,0 +1,785 @@
1
+ // The relay of PROTOCOL.md: pushes each message between one host
2
+ // and its peers down the receiver's socket, or drops it; only its own
3
+ // notices wait. A relay restart ends the sessions it carried, unless its host
4
+ // hands back the sockets it kept (a Durable Object's, across hibernation),
5
+ // each with what `attach` gave it to keep.
6
+ // It knows as little as routing needs: a session is a hash, bodies and a
7
+ // peer's info are its peers' ciphertext, and no body is ever stored.
8
+
9
+ import { TokenError, type VerifiedToken } from './oidc.js'
10
+ import { importKey, PROOF_HEADER, proofText, verifyProof } from './proof.js'
11
+
12
+ export interface RelayOptions {
13
+ /**
14
+ * Checks a request's bearer token; `undefined` admits every request
15
+ * (`--no-auth`, loopback only: tests and the `local()` adapter). It gets
16
+ * the request too, for a relay whose audience is the URL it is reached at.
17
+ */
18
+ verify?: ((token: string, req: Request) => Promise<VerifiedToken>) | undefined
19
+ /**
20
+ * How often its participants hear from each other, said in each join's
21
+ * answer: a peer quiet one is asked, and lost after two (vx-dte).
22
+ */
23
+ heartbeatMs?: number
24
+ /**
25
+ * How long a participant whose socket dropped, without `leave`, may dial
26
+ * again by its `id` before it is gone (default 10 000).
27
+ */
28
+ graceMs?: number
29
+ /**
30
+ * The sockets its host kept across a restart, each with what `attach`
31
+ * gave it to keep: the sessions are rebuilt from them, and a session
32
+ * none of whose sockets names its host is over.
33
+ */
34
+ sockets?: Iterable<readonly [Kept, RelaySocket]> | undefined
35
+ /**
36
+ * Answers a participant's `socket` request: the host accepts the
37
+ * websocket and `attach`es it. A participant speaks only over it.
38
+ * Any status but 101 says it could not: the join it carried is undone.
39
+ */
40
+ upgrade: (key: string, id: string, req: Request, protocol?: string) => Response
41
+ }
42
+
43
+ /** The websocket subprotocol a version of PROTOCOL.md is offered and chosen as. */
44
+ const PROTOCOL = 'switchboard.v'
45
+ /** The versions this relay speaks, oldest first. */
46
+ const VERSIONS = [1]
47
+ const SUPPORTED = `supported: ${PROTOCOL}${VERSIONS[0]}-v${VERSIONS.at(-1)}`
48
+
49
+ /**
50
+ * The subprotocol a socket request is answered with: the highest version
51
+ * both speak, else the first it offered, so its client reads the 4406
52
+ * refusal (one answered with no subprotocol, or another, fails unread).
53
+ */
54
+ export function negotiate(req: Request): { protocol?: string; version?: number } {
55
+ const offered = (req.headers.get('sec-websocket-protocol') ?? '')
56
+ .split(',')
57
+ .map((t) => t.trim())
58
+ .filter((t) => t !== '')
59
+ let version: number | undefined
60
+ for (const t of offered) {
61
+ const v = t.startsWith(PROTOCOL) ? Number(t.slice(PROTOCOL.length)) : NaN
62
+ if (VERSIONS.includes(v) && (version === undefined || v > version)) version = v
63
+ }
64
+ if (version !== undefined) return { protocol: `${PROTOCOL}${version}`, version }
65
+ return offered[0] === undefined ? {} : { protocol: offered[0] }
66
+ }
67
+
68
+ /**
69
+ * What a host keeps with a participant's socket (a Durable Object's
70
+ * attachment, at most 2 KiB) so a restart rebuilds its session: no message.
71
+ */
72
+ export interface Kept {
73
+ key: string
74
+ id: string
75
+ role: 'host' | 'peer'
76
+ /** Its session's `cert`: a session rebuilt from peers alone still proves its host. */
77
+ cert?: string
78
+ /** A peer's: its host's `id`, so a restart keeps a session whose host is dialing again. */
79
+ host?: string
80
+ }
81
+
82
+ /** A participant's websocket, as its host holds it. */
83
+ export interface RelaySocket {
84
+ send(text: string): void
85
+ close(code?: number, reason?: string): void
86
+ }
87
+
88
+ /** The close code of a socket request refused with HTTP `status`. */
89
+ export const refusedWith = (status: number): number => 4000 + status
90
+
91
+ /** The close code of a socket whose participant the relay lost, let go, or never knew. */
92
+ export const GONE = refusedWith(404)
93
+
94
+ /**
95
+ * Close `socket` with `code`, said first in a frame (`{ "close": code,
96
+ * "error"? }`, 0 for none): Bun 1.4.4's client fires its close only once
97
+ * the host ends TCP too, and workerd never did, so a lost peer heard
98
+ * 4404 10 s late, at its own redial limit (PR #4171, 2026-10-23). A
99
+ * refusal's code is 4000 plus its HTTP status, its `reason` the error.
100
+ */
101
+ export function closeSocket(socket: RelaySocket, code?: number, reason?: string): void {
102
+ try {
103
+ socket.send(JSON.stringify({ close: code ?? 0, ...(reason ? { error: reason } : {}) }))
104
+ // quiet: a socket that can carry nothing is closed all the same
105
+ } catch {}
106
+ // A close reason holds 123 bytes; the frame said it whole.
107
+ socket.close(code, reason?.slice(0, 120))
108
+ }
109
+
110
+ export interface Relay {
111
+ fetch(req: Request): Promise<Response>
112
+ /**
113
+ * Pushes the participant's messages down `socket`; returns what its host
114
+ * keeps with it, or false if it is gone or its request was refused: the
115
+ * relay has closed it, saying why.
116
+ */
117
+ attach(key: string, id: string, socket: RelaySocket): Kept | false
118
+ /** The participant's socket closed: gone, unless it dials again within `graceMs`. */
119
+ detach(key: string, id: string, socket: RelaySocket): void
120
+ /**
121
+ * One frame a participant sent down `socket`, which becomes its socket;
122
+ * returns the answer frame, if it gets one.
123
+ */
124
+ frame(key: string, id: string, text: string, socket?: RelaySocket): string | undefined
125
+ }
126
+
127
+ interface Message {
128
+ from: string
129
+ body: unknown
130
+ }
131
+
132
+ interface Participant {
133
+ id: string
134
+ role: 'host' | 'peer'
135
+ /** A peer's, until the host is told it joined. */
136
+ info: unknown
137
+ /** The relay's own notices it could not push yet. */
138
+ queue: Message[]
139
+ socket?: RelaySocket | undefined
140
+ /** When its socket dropped with no `leave`, or it joined: it is gone `graceMs` on. */
141
+ droppedAt?: number
142
+ /** A participant that joined on its socket: what that socket says first. */
143
+ hello?: string
144
+ }
145
+
146
+ interface Session {
147
+ key: string
148
+ closed: boolean
149
+ host?: Participant
150
+ byId: Map<string, Participant>
151
+ /** The public key its host joined with; only its holder acts as it. */
152
+ cert?: string
153
+ certKey?: Promise<CryptoKey>
154
+ }
155
+
156
+ /** A random (v4) UUID: no one can guess a session id to open it first or join it. */
157
+ const SESSION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
158
+ const MAX_BODY = 1 << 20
159
+ let room: Uint8Array | undefined
160
+ /** Over MAX_BODY in UTF-8: a UTF-16 unit is 1 to 3 bytes, so 1 MiB of `€` is 3. */
161
+ function overMax(text: string): boolean {
162
+ if (text.length > MAX_BODY) return true
163
+ if (text.length * 3 <= MAX_BODY) return false
164
+ // No Buffer on Workers: encode into a fixed 1 MiB, which stops short of one that does not fit.
165
+ room ??= new Uint8Array(MAX_BODY)
166
+ return new TextEncoder().encodeInto(text, room).read < text.length
167
+ }
168
+ /** How far a proof's time may be from the relay's: a CI runner's clock drifts. */
169
+ const PROOF_SKEW_MS = 2 * 60_000
170
+
171
+ class HttpError extends Error {
172
+ constructor(
173
+ readonly status: number,
174
+ message: string,
175
+ ) {
176
+ super(message)
177
+ }
178
+ }
179
+
180
+ const errorBody = (err: HttpError | TokenError): object => ({ error: err.message })
181
+
182
+ export function createRelay(opts: RelayOptions): Relay {
183
+ const heartbeatMs = opts.heartbeatMs ?? 2_000
184
+ const graceMs = opts.graceMs ?? 10_000
185
+ const sessions = new Map<string, Session>()
186
+ /**
187
+ * Proof nonces seen, and when: each is taken once. The relay's, not a
188
+ * session's: a host's join proves itself to the session it opens, so a
189
+ * session's own were new to it, and that join sent again once its
190
+ * session ended opened it again.
191
+ */
192
+ const nonces = new Map<string, number>()
193
+
194
+ /**
195
+ * Down the participant's socket, or nowhere: a participant's message the
196
+ * relay cannot push now is dropped, and its sender, unacknowledged, sends
197
+ * it again (NATS-style; the relay holds nothing a sender can resend). The
198
+ * relay's own notices (`notice`: joined, lost, closed) have no sender to
199
+ * resend them, so they wait for the next socket.
200
+ */
201
+ const deliver = (session: Session, p: Participant, m: Message, notice = false): void => {
202
+ if (p.socket !== undefined && p.queue.length === 0) {
203
+ try {
204
+ p.socket.send(JSON.stringify({ messages: [m] }))
205
+ return
206
+ } catch {
207
+ // quiet: a socket that cannot send is unplugged; the participant rejoins
208
+ unplug(p)
209
+ }
210
+ }
211
+ if (notice) p.queue.push(m)
212
+ }
213
+
214
+ /**
215
+ * A socket a push failed on is closed, not only let go: left open, its
216
+ * pings answered, its participant never redialed for what queued (two CI
217
+ * runs hung 20 min, 2026-10-23).
218
+ */
219
+ const unplug = (p: Participant): void => {
220
+ const s = p.socket
221
+ p.socket = undefined
222
+ p.droppedAt = Date.now()
223
+ try {
224
+ if (s !== undefined) closeSocket(s)
225
+ } catch {}
226
+ // quiet: the socket is already gone; closing it again says nothing new
227
+ }
228
+
229
+ /** `socket` reaches the participant: what queued for it goes down it. */
230
+ const plug = (session: Session, p: Participant, socket: RelaySocket): void => {
231
+ p.socket = socket
232
+ delete p.droppedAt
233
+ if (p.hello !== undefined) {
234
+ try {
235
+ socket.send(p.hello)
236
+ delete p.hello
237
+ } catch {
238
+ // quiet: a socket that cannot send is unplugged; the participant rejoins
239
+ unplug(p)
240
+ return
241
+ }
242
+ }
243
+ if (p.queue.length === 0) return
244
+ const messages = drain(p)
245
+ try {
246
+ socket.send(JSON.stringify({ messages }))
247
+ } catch {
248
+ // quiet: a socket that cannot send is unplugged; what queued waits for the rejoin
249
+ unplug(p)
250
+ p.queue = messages
251
+ }
252
+ }
253
+
254
+ const drain = (p: Participant): Message[] => {
255
+ const messages = p.queue
256
+ p.queue = []
257
+ return messages
258
+ }
259
+
260
+ /**
261
+ * `lost`: its host's grace passed, so it may still join again (a relay
262
+ * restart forgot it, or its network dropped): each peer's socket closes
263
+ * with no code, and its redial learns the session is gone and joins
264
+ * again. Only a host's `leave` tells its peers `closed`, the end.
265
+ */
266
+ const close = (session: Session, lost = false): void => {
267
+ session.closed = true
268
+ for (const p of [...session.byId.values()]) {
269
+ if (p.role !== 'peer') continue
270
+ if (lost) {
271
+ session.byId.delete(p.id)
272
+ if (p.socket !== undefined) closeSocket(p.socket)
273
+ continue
274
+ }
275
+ deliver(session, p, { from: 'relay', body: { type: 'closed' } }, true)
276
+ drop(session, p, false)
277
+ }
278
+ // A session is live while its host is, and only a live one is kept:
279
+ // a peer that comes or calls after it learns it at once
280
+ // (2026-10-23: "job is live, or it isnt"). Kept closed, or opened by a
281
+ // peer alone, a session held a late peer's runner ten minutes.
282
+ forget(session)
283
+ }
284
+
285
+ /**
286
+ * `tell`: the host hears its peer was lost (not when it removed it).
287
+ * `lost`: its grace passed with no redial, rather than a `leave`.
288
+ */
289
+ const drop = (session: Session, p: Participant, tell = true, lost = false): void => {
290
+ if (!session.byId.delete(p.id)) return
291
+ const left = drain(p)
292
+ if (!tell) {
293
+ // What queued, its `refused` among it, before the socket closes.
294
+ if (left.length > 0) {
295
+ try {
296
+ p.socket?.send(JSON.stringify({ messages: left }))
297
+ } catch {}
298
+ // quiet: the close code tells the participant it is gone
299
+ }
300
+ }
301
+ // Its socket learns it is gone by the close code; its next socket, from `socket`.
302
+ if (p.socket !== undefined) closeSocket(p.socket, GONE, 'gone')
303
+ if (p.role === 'host') {
304
+ close(session, lost)
305
+ return
306
+ }
307
+ if (tell && session.host !== undefined && !session.closed) {
308
+ deliver(session, session.host, { from: 'relay', body: { type: 'left', id: p.id } }, true)
309
+ }
310
+ }
311
+
312
+ /**
313
+ * Its participants whose sockets dropped `graceMs` ago, and never came
314
+ * back, are gone: checked as the session is next used, so the relay
315
+ * keeps no timer. A peer's silence is lost by its host sooner (vx-dte).
316
+ */
317
+ const lapse = (session: Session): void => {
318
+ const now = Date.now()
319
+ for (const p of [...session.byId.values()]) {
320
+ if (session.closed) return
321
+ if (p.droppedAt !== undefined && now - p.droppedAt >= graceMs) drop(session, p, true, true)
322
+ }
323
+ }
324
+
325
+ let swept = 0
326
+ /**
327
+ * Every session's lapsed participants, at most once a grace: a session
328
+ * all of whose sockets dropped is used by no one again, and a relay
329
+ * that keeps its sessions in memory alone (`serveRelay`) held it.
330
+ */
331
+ const sweep = (): void => {
332
+ const now = Date.now()
333
+ if (now - swept < graceMs) return
334
+ swept = now
335
+ for (const session of [...sessions.values()]) lapse(session)
336
+ }
337
+
338
+ /** The host takes a peer it did not admit off the session. */
339
+ const removePeer = (session: Session, p: Participant, peer: unknown): void => {
340
+ const a = typeof peer === 'string' ? session.byId.get(peer) : undefined
341
+ if (p.role !== 'host') throw new HttpError(403, 'only the host removes')
342
+ if (a === undefined || a.role !== 'peer') throw new HttpError(404, 'unknown peer')
343
+ drop(session, a, false)
344
+ }
345
+
346
+ /** A closed session's participants, and what it knew of its host. */
347
+ const empty = (session: Session): void => {
348
+ for (const p of session.byId.values()) drain(p)
349
+ session.byId.clear()
350
+ delete session.host
351
+ delete session.cert
352
+ delete session.certKey
353
+ }
354
+
355
+ const forget = (session: Session): void => {
356
+ sessions.delete(session.key)
357
+ empty(session)
358
+ }
359
+
360
+ if (opts.sockets !== undefined) restore(opts.sockets, sessions)
361
+ for (const session of sessions.values()) lapse(session)
362
+
363
+ /** The host's request is signed by the key it joined with, at most once. */
364
+ const prove = async (
365
+ session: Session,
366
+ req: Request,
367
+ url: URL,
368
+ body: string,
369
+ cert = session.cert,
370
+ ) => {
371
+ const m = /^(\d+)\.([\w-]{16,64})\.([\w-]+)$/.exec(req.headers.get(PROOF_HEADER) ?? '')
372
+ if (m === null || cert === undefined) {
373
+ throw new HttpError(403, 'not signed by the session’s host key')
374
+ }
375
+ const [, ts, nonce, sig] = m as unknown as [string, string, string, string]
376
+ const now = Date.now()
377
+ // Taken in time order: the first one still fresh ends the prune.
378
+ for (const [n, at] of nonces) {
379
+ if (now - at <= 2 * PROOF_SKEW_MS) break
380
+ nonces.delete(n)
381
+ }
382
+ if (Math.abs(now - Number(ts)) > PROOF_SKEW_MS || nonces.has(nonce)) {
383
+ throw new HttpError(403, 'not signed by the session’s host key')
384
+ }
385
+ // Taken before the await, so a replay sent alongside finds it taken.
386
+ nonces.set(nonce, now)
387
+ const key = cert === session.cert ? (session.certKey ??= importKey(cert)) : importKey(cert)
388
+ const signed = proofText(req.method, url.pathname + url.search, Number(ts), nonce, body)
389
+ if (!(await verifyProof(await key, sig, signed))) {
390
+ throw new HttpError(403, 'not signed by the session’s host key')
391
+ }
392
+ }
393
+
394
+ const participant = (session: Session, id: unknown): Participant => {
395
+ const p = typeof id === 'string' ? session.byId.get(id) : undefined
396
+ if (p === undefined) throw new HttpError(404, 'unknown id')
397
+ return p
398
+ }
399
+
400
+ const join = (
401
+ session: Session,
402
+ body: { role?: unknown; info?: unknown; key?: unknown },
403
+ ): Participant => {
404
+ const role = body.role
405
+ if (role !== 'host' && role !== 'peer') {
406
+ throw new HttpError(400, 'role is host or peer')
407
+ }
408
+ if (role === 'host' && session.host !== undefined) {
409
+ throw new HttpError(409, 'the session has a host')
410
+ }
411
+ const p: Participant = {
412
+ id: crypto.randomUUID().replaceAll('-', ''),
413
+ role,
414
+ // No one reads the host's.
415
+ info: role === 'peer' ? (body.info ?? null) : null,
416
+ queue: [],
417
+ // Let in before its socket opens: a client gone in between never
418
+ // opens it, and its host held the run for good (Bun's refused
419
+ // upgrade too). Its socket's `attach` clears this.
420
+ droppedAt: Date.now(),
421
+ }
422
+ session.byId.set(p.id, p)
423
+ if (p.role === 'host') {
424
+ session.host = p
425
+ session.cert = body.key as string
426
+ for (const a of session.byId.values())
427
+ if (a.role === 'peer') deliver(session, p, joined(a), true)
428
+ } else if (session.host !== undefined) deliver(session, session.host, joined(p), true)
429
+ return p
430
+ }
431
+
432
+ const send = (
433
+ session: Session,
434
+ from: Participant,
435
+ body: { to?: unknown; body?: unknown },
436
+ ): void => {
437
+ const to =
438
+ from.role === 'peer'
439
+ ? body.to === 'host'
440
+ ? session.host
441
+ : undefined
442
+ : typeof body.to === 'string'
443
+ ? session.byId.get(body.to)
444
+ : undefined
445
+ if (to === undefined || to === from) throw new HttpError(404, 'unknown to')
446
+ deliver(session, to, {
447
+ from: from.role === 'host' ? 'host' : from.id,
448
+ body: body.body,
449
+ })
450
+ }
451
+
452
+ const live = (key: string): Session => {
453
+ const session = sessions.get(key)
454
+ if (session === undefined) missing()
455
+ if (session.closed) throw new HttpError(410, 'session closed')
456
+ return session
457
+ }
458
+
459
+ /** Refusals of socket requests, by the id their host attaches the socket as. */
460
+ const refusals = new Map<string, HttpError | TokenError>()
461
+
462
+ /**
463
+ * A participant speaks only over its socket, and joins as it opens one
464
+ * (`socket?join=`): one request a participant on a host that bills each.
465
+ */
466
+ const handle = async (
467
+ req: Request,
468
+ protocol: string | undefined,
469
+ version: number | undefined,
470
+ ) => {
471
+ sweep()
472
+ const url = new URL(req.url)
473
+ const m = /^\/v1\/sessions\/([^/]+)\/socket$/.exec(url.pathname)
474
+ if (m === null)
475
+ throw new HttpError(
476
+ 404,
477
+ `no route ${req.method} ${url.pathname.slice(0, 200)}: the relay serves GET /v1/sessions/<id>/socket (PROTOCOL.md)`,
478
+ )
479
+ const sessionId = m[1]!
480
+ if (!SESSION_ID.test(sessionId)) throw new HttpError(400, 'session id is not a random UUID')
481
+ if (req.method !== 'GET') throw new HttpError(405, 'this route takes GET')
482
+ const token = await authorize(req, opts.verify)
483
+ // A session is its id within its repository, so another tenant that learns
484
+ // an id cannot open it first.
485
+ const key = await sessionKey(sessionId, token)
486
+ if (req.headers.get('upgrade')?.toLowerCase() !== 'websocket') {
487
+ throw new HttpError(426, 'not a websocket')
488
+ }
489
+ if (version === undefined) throw new HttpError(406, SUPPORTED)
490
+ const asked = url.searchParams.get('join')
491
+ if (asked !== null) {
492
+ if (overMax(asked)) throw new HttpError(413, 'join over 1 MiB')
493
+ const p = await enter(key, req, url, parse(asked))
494
+ p.hello = JSON.stringify({ joined: { id: p.id, heartbeatMs } })
495
+ const res = opts.upgrade(key, p.id, req, protocol)
496
+ // No socket will ever attach it, so no drop time would lapse it: a
497
+ // host's session kept refusing every later host 409.
498
+ const session = sessions.get(key)
499
+ if (res.status !== 101 && session?.byId.get(p.id) === p) drop(session, p, true, true)
500
+ return res
501
+ }
502
+ const session = sessions.get(key)
503
+ if (session !== undefined) lapse(session)
504
+ const id = url.searchParams.get('id') ?? ''
505
+ // One the relay lost or let go learns it from the close code (GONE)
506
+ // `attach` closes the socket with.
507
+ if (session === undefined || session.closed || !session.byId.has(id))
508
+ return opts.upgrade(key, id, req, protocol)
509
+ // As the host: signed by the key it joined with.
510
+ if (participant(session, id).role === 'host') await prove(session, req, url, '')
511
+ return opts.upgrade(key, id, req, protocol)
512
+ }
513
+
514
+ /** Joins `body` to the session `key`, by the socket that carries it, signed in its query. */
515
+ const enter = async (
516
+ key: string,
517
+ req: Request,
518
+ url: URL,
519
+ body: Record<string, unknown>,
520
+ ): Promise<Participant> => {
521
+ if (body['role'] === 'host') {
522
+ if (
523
+ typeof body['key'] !== 'string' ||
524
+ !(await importKey(body['key']).then(
525
+ () => true,
526
+ () => false,
527
+ ))
528
+ ) {
529
+ throw new HttpError(400, 'a host joins with key: its P-256 public key')
530
+ }
531
+ }
532
+ if (body['role'] !== 'host' && body['role'] !== 'peer') {
533
+ throw new HttpError(400, 'role is host or peer')
534
+ }
535
+ const found = sessions.get(key)
536
+ if (found !== undefined) lapse(found)
537
+ const session = sessions.get(key)
538
+ if (body['role'] === 'peer') {
539
+ // Its host starts it: a peer finds it live, or leaves.
540
+ if (session === undefined || session.closed) throw new HttpError(410, 'no live session')
541
+ return join(session, body)
542
+ }
543
+ if (session !== undefined) throw new HttpError(409, 'the session has a host')
544
+ const fresh: Session = { key, closed: false, byId: new Map() }
545
+ await prove(fresh, req, url, '', body['key'] as string)
546
+ // Another host may have joined while the key was checked.
547
+ if (sessions.has(key)) throw new HttpError(409, 'the session has a host')
548
+ sessions.set(key, fresh)
549
+ return join(fresh, body)
550
+ }
551
+
552
+ return {
553
+ async fetch(req) {
554
+ const { protocol, version } = negotiate(req)
555
+ try {
556
+ return await handle(req, protocol, version)
557
+ } catch (err) {
558
+ if (!(err instanceof HttpError || err instanceof TokenError)) throw err
559
+ // A failed upgrade tells a client nothing it can read: a socket
560
+ // request is let in, and its socket closed saying why.
561
+ if (req.headers.get('upgrade')?.toLowerCase() === 'websocket') {
562
+ const id = `refused-${crypto.randomUUID()}`
563
+ refusals.set(id, err)
564
+ const res = opts.upgrade('', id, req, protocol)
565
+ // Never attached, it would be held for good: one per request, no token needed.
566
+ if (res.status !== 101) refusals.delete(id)
567
+ return res
568
+ }
569
+ // The one route takes GET: a 405 names it, as HTTP has it.
570
+ const allow = err.status === 405 ? { headers: { allow: 'GET' } } : {}
571
+ return Response.json(errorBody(err), { status: err.status, ...allow })
572
+ }
573
+ },
574
+ attach(key, id, socket) {
575
+ const refused = refusals.get(id)
576
+ if (refused !== undefined) {
577
+ refusals.delete(id)
578
+ closeSocket(socket, refusedWith(refused.status), refused.message)
579
+ return false
580
+ }
581
+ const session = sessions.get(key)
582
+ const p = session?.closed === false ? session.byId.get(id) : undefined
583
+ if (session === undefined || p === undefined) {
584
+ closeSocket(socket, GONE, 'gone')
585
+ return false
586
+ }
587
+ // One socket a participant. A host restoring several (a wake, a
588
+ // redial whose old close it never ran) attached the last; a push
589
+ // failing on a stale one unplugged it, and the live one, its pings
590
+ // answered by the host, never heard again: main 32f73ed's
591
+ // host waited 13 min on results queued here (2026-10-23).
592
+ // Closed, the other's client dials again and gets what queued.
593
+ const earlier = p.socket
594
+ // Plugged first: Bun runs the earlier socket's close handler inside
595
+ // its close(), and that detach lost the participant (a redial on
596
+ // the Bun relay ended its peer).
597
+ plug(session, p, socket)
598
+ if (earlier !== undefined && earlier !== socket) {
599
+ try {
600
+ closeSocket(earlier)
601
+ } catch {}
602
+ // quiet: the earlier socket is replaced; its close says nothing new
603
+ }
604
+ return {
605
+ key,
606
+ id,
607
+ role: p.role,
608
+ ...(session.cert !== undefined ? { cert: session.cert } : {}),
609
+ ...(p.role === 'peer' && session.host !== undefined ? { host: session.host.id } : {}),
610
+ }
611
+ },
612
+ detach(key, id, socket) {
613
+ const session = sessions.get(key)
614
+ const p = session?.byId.get(id)
615
+ if (p === undefined || p.socket !== socket) return
616
+ p.socket = undefined
617
+ // Only `leave` ends a participant at once (2026-10-25): one
618
+ // whose socket dropped dials again within `graceMs`, and its session,
619
+ // a host's too, waits. Main 8d0619f's host socket ended 1006 and its
620
+ // redial 350 ms on found the session closed (2026-10-24). A peer
621
+ // quiet on a socket that never closed is its peers' to lose (vx-dte
622
+ // probes it).
623
+ p.droppedAt = Date.now()
624
+ },
625
+ frame(key, id, text, socket) {
626
+ let n: unknown = null
627
+ try {
628
+ if (overMax(text)) {
629
+ // Answered by its number, read without parsing the rest.
630
+ const k = /^\{"n":(\d+)[,}]/.exec(text)?.[1]
631
+ if (k !== undefined) n = Number(k)
632
+ throw new HttpError(413, 'body over 1 MiB')
633
+ }
634
+ let body: {
635
+ n?: unknown
636
+ leave?: unknown
637
+ remove?: unknown
638
+ sends?: unknown
639
+ }
640
+ try {
641
+ body = JSON.parse(text) as typeof body
642
+ } catch {
643
+ throw new HttpError(400, 'frame is not JSON')
644
+ }
645
+ // `null` or an array read as a frame threw a TypeError out of the relay.
646
+ if (typeof body !== 'object' || body === null || Array.isArray(body))
647
+ throw new HttpError(400, 'frame is not a JSON object')
648
+ n = body.n ?? null
649
+ const found = live(key)
650
+ lapse(found)
651
+ const session = live(key)
652
+ const p = participant(session, id)
653
+ // It spoke down this socket, so this socket reaches it: one its
654
+ // host let go or never restored as its own left it deaf while its
655
+ // pings were answered, and 13baaf6's host heard nothing
656
+ // for 8 min (2026-10-23). Not closing the other: a host may hand
657
+ // the same socket as another object.
658
+ if (socket !== undefined && p.socket !== socket) plug(session, p, socket)
659
+ if (body.leave === true) drop(session, p)
660
+ else if (body.remove !== undefined) removePeer(session, p, body.remove)
661
+ else if (Array.isArray(body.sends)) {
662
+ // Several sends in one frame: one message the host bills, each answered.
663
+ const results = body.sends.map((one: { to?: unknown; body?: unknown }) => {
664
+ try {
665
+ if (typeof one !== 'object' || one === null)
666
+ throw new HttpError(400, 'a send is an object')
667
+ send(session, p, one)
668
+ return null
669
+ } catch (err) {
670
+ if (!(err instanceof HttpError)) throw err
671
+ return { status: err.status, ...errorBody(err) }
672
+ }
673
+ })
674
+ if (results.some((r) => r !== null)) return JSON.stringify({ n, results })
675
+ } else throw new HttpError(400, 'frame is sends, remove or leave')
676
+ // Unnumbered: its sender waits on no answer (an ack, a probe), so
677
+ // only a failure is one.
678
+ return n === null ? undefined : JSON.stringify({ n })
679
+ } catch (err) {
680
+ if (!(err instanceof HttpError)) throw err
681
+ return JSON.stringify({ n, status: err.status, ...errorBody(err) })
682
+ }
683
+ },
684
+ }
685
+ }
686
+
687
+ /** Tells the host `a` joined, and forgets what it said. */
688
+ const joined = (a: Participant): Message => {
689
+ const info = a.info
690
+ a.info = null
691
+ return { from: 'relay', body: { type: 'joined', id: a.id, info } }
692
+ }
693
+
694
+ /**
695
+ * Sessions as their sockets kept them. A session whose host's socket was
696
+ * not kept waits for its host's redial from now (`graceMs`), if a peer's
697
+ * socket names it; one none names is over, as is a socket kept by an older
698
+ * relay: each is closed as gone. Of a participant's several, the last is
699
+ * its socket.
700
+ */
701
+ function restore(
702
+ sockets: Iterable<readonly [Kept, RelaySocket]>,
703
+ sessions: Map<string, Session>,
704
+ ): void {
705
+ const all = [...sockets]
706
+ for (const [k] of all) {
707
+ const host = k?.role === 'host' ? k.id : k?.host
708
+ if (host === undefined) continue
709
+ let session = sessions.get(k.key)
710
+ if (session === undefined) {
711
+ session = {
712
+ key: k.key,
713
+ closed: false,
714
+ byId: new Map(),
715
+ ...(k.cert === undefined ? {} : { cert: k.cert }),
716
+ }
717
+ sessions.set(k.key, session)
718
+ }
719
+ if (session.host === undefined) {
720
+ const p: Participant = {
721
+ id: host,
722
+ role: 'host',
723
+ info: null,
724
+ queue: [],
725
+ droppedAt: Date.now(),
726
+ }
727
+ session.host = p
728
+ session.byId.set(host, p)
729
+ }
730
+ }
731
+ for (const [k, socket] of all) {
732
+ const session = k?.role === undefined ? undefined : sessions.get(k.key)
733
+ if (session === undefined) {
734
+ closeSocket(socket, GONE, 'gone')
735
+ continue
736
+ }
737
+ const before = session.byId.get(k.id)
738
+ if (before?.socket !== undefined) closeSocket(before.socket)
739
+ const p: Participant = before ?? {
740
+ id: k.id,
741
+ role: k.role,
742
+ info: null,
743
+ queue: [],
744
+ }
745
+ p.socket = socket
746
+ delete p.droppedAt
747
+ session.byId.set(k.id, p)
748
+ }
749
+ }
750
+
751
+ function missing(): never {
752
+ throw new HttpError(404, 'unknown session')
753
+ }
754
+
755
+ /**
756
+ * The session's name: a SHA-256 of its id and the issuer and session claims of its
757
+ * tokens, so neither storage nor a Durable Object's name says whose it is.
758
+ */
759
+ export async function sessionKey(
760
+ sessionId: string,
761
+ token: VerifiedToken | undefined,
762
+ ): Promise<string> {
763
+ const name = token === undefined ? sessionId : [token.iss, ...token.run, sessionId].join('\0')
764
+ const hash = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(name))
765
+ return [...new Uint8Array(hash)].map((b) => b.toString(16).padStart(2, '0')).join('')
766
+ }
767
+
768
+ async function authorize(
769
+ req: Request,
770
+ verify: RelayOptions['verify'],
771
+ ): Promise<VerifiedToken | undefined> {
772
+ if (verify === undefined) return undefined
773
+ const m = /^Bearer (\S+)$/.exec(req.headers.get('authorization') ?? '')
774
+ if (m === null) throw new HttpError(401, 'no bearer token')
775
+ return verify(m[1]!, req)
776
+ }
777
+
778
+ function parse(text: string): Record<string, unknown> {
779
+ try {
780
+ const v: unknown = JSON.parse(text)
781
+ if (typeof v === 'object' && v !== null && !Array.isArray(v))
782
+ return v as Record<string, unknown>
783
+ } catch {}
784
+ throw new HttpError(400, 'body is not a JSON object')
785
+ }