@vzn/vx-dte-server 0.0.0 → 0.0.650
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/LICENSE +21 -0
- package/PROTOCOL.md +212 -0
- package/README.md +164 -1
- package/index.ts +4 -0
- package/package.json +59 -4
- package/src/bun.ts +107 -0
- package/src/index.ts +21 -0
- package/src/oidc.ts +307 -0
- package/src/proof.ts +82 -0
- package/src/relay.ts +785 -0
- package/src/serve.ts +75 -0
- package/src/worker.ts +250 -0
- package/wrangler.jsonc +18 -0
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
|
+
}
|