@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/serve.ts ADDED
@@ -0,0 +1,75 @@
1
+ // The relay on Bun.serve, its participants' websockets included.
2
+
3
+ import type { ServerWebSocket } from 'bun'
4
+ import { createRelay, type RelayOptions, type RelaySocket } from './relay.js'
5
+
6
+ export interface ServeOptions extends Pick<RelayOptions, 'verify' | 'heartbeatMs'> {
7
+ hostname: string
8
+ /** `0` picks a free port. */
9
+ port: number
10
+ }
11
+
12
+ export interface RelayServer {
13
+ readonly url: string
14
+ stop(): Promise<void>
15
+ }
16
+
17
+ interface Held {
18
+ key: string
19
+ id: string
20
+ socket?: RelaySocket
21
+ }
22
+
23
+ export function serveRelay(opts: ServeOptions): RelayServer {
24
+ let server: Bun.Server<Held> | undefined
25
+ const relay = createRelay({
26
+ ...opts,
27
+ upgrade: (key, id, req, protocol) => {
28
+ const headers = protocol === undefined ? {} : { 'sec-websocket-protocol': protocol }
29
+ if (!server!.upgrade(req, { data: { key, id }, headers })) {
30
+ return Response.json({ error: 'not a websocket' }, { status: 426 })
31
+ }
32
+ // Bun answers the upgrade itself and ignores this.
33
+ return new Response(null, { status: 101 })
34
+ },
35
+ })
36
+ server = Bun.serve<Held>({
37
+ hostname: opts.hostname,
38
+ port: opts.port,
39
+ idleTimeout: 60,
40
+ fetch: (req) => relay.fetch(req),
41
+ websocket: {
42
+ // A socket with no frame in three heartbeats has no peer listening
43
+ // either (each probes a quiet one within two): closed, it loses its
44
+ // participant, as a half-open one on no other host would be.
45
+ idleTimeout: Math.max(1, Math.ceil((3 * (opts.heartbeatMs ?? 2_000)) / 1000)),
46
+ open(ws: ServerWebSocket<Held>) {
47
+ const { key, id } = ws.data
48
+ ws.data.socket = {
49
+ send(text) {
50
+ // 0: dropped, so the relay queues it for the next poll instead.
51
+ if (ws.send(text) === 0) throw new Error('socket closed')
52
+ },
53
+ close: (code, reason) => ws.close(code, reason),
54
+ }
55
+ relay.attach(key, id, ws.data.socket)
56
+ },
57
+ message(ws: ServerWebSocket<Held>, message) {
58
+ const { key, id } = ws.data
59
+ const answer = relay.frame(key, id, String(message), ws.data.socket)
60
+ if (answer !== undefined) ws.send(answer)
61
+ },
62
+ close(ws: ServerWebSocket<Held>) {
63
+ const { key, id, socket } = ws.data
64
+ if (socket !== undefined) relay.detach(key, id, socket)
65
+ },
66
+ },
67
+ })
68
+ const host = opts.hostname.includes(':') ? `[${opts.hostname}]` : opts.hostname
69
+ return {
70
+ url: `http://${host}:${server.port}`,
71
+ async stop() {
72
+ await server.stop(true)
73
+ },
74
+ }
75
+ }
package/src/worker.ts ADDED
@@ -0,0 +1,250 @@
1
+ // The relay on Cloudflare Workers: each run is one Durable Object, so one
2
+ // run's sockets meet in one place. Participants hold websockets the object
3
+ // hibernates between messages: its state is in their attachments, and it
4
+ // writes no storage. It keeps no alarm: a socket's close loses its
5
+ // participant.
6
+ // `wrangler.jsonc` deploys it; ALLOW is a var in it.
7
+
8
+ import { createVerifier, parseAllow, TokenError, type VerifiedToken } from './oidc.js'
9
+ import {
10
+ closeSocket,
11
+ createRelay,
12
+ GONE,
13
+ negotiate,
14
+ refusedWith,
15
+ sessionKey,
16
+ type Kept,
17
+ type Relay,
18
+ } from './relay.js'
19
+
20
+ /** The parts of the Workers runtime this file uses, so it needs no types package. */
21
+ interface DurableObjectId {}
22
+ interface DurableObjectStub {
23
+ fetch(req: Request): Promise<Response>
24
+ }
25
+ export interface DurableObjectNamespace {
26
+ idFromName(name: string): DurableObjectId
27
+ get(id: DurableObjectId): DurableObjectStub
28
+ }
29
+ interface Socket {
30
+ accept(): void
31
+ send(text: string): void
32
+ close(code?: number, reason?: string): void
33
+ serializeAttachment(v: Kept): void
34
+ /** Null on one `attach` closed: it kept nothing. */
35
+ deserializeAttachment(): Kept | null
36
+ }
37
+ export interface DurableObjectState {
38
+ acceptWebSocket(ws: Socket): void
39
+ getWebSockets(): Socket[]
40
+ }
41
+ declare const caches: {
42
+ default: {
43
+ match(url: string): Promise<Response | undefined>
44
+ put(url: string, res: Response): Promise<void>
45
+ }
46
+ }
47
+ declare const WebSocketPair: new () => { 0: Socket; 1: Socket }
48
+
49
+ export interface Env {
50
+ readonly RELAY?: DurableObjectNamespace
51
+ /** Who may take part (`parseAllow`): `issuer:owner/repo:workflow`, space separated. */
52
+ readonly ALLOW?: string
53
+ /** The `aud` every token must name; unset, the URL the relay is reached at. */
54
+ readonly AUDIENCE?: string
55
+ }
56
+
57
+ const SESSION = /^\/v1\/sessions\/([^/]+)\//
58
+
59
+ /** How long the data center's cache holds an issuer's keys for the next isolate. */
60
+ const KEYS_TTL_S = 600
61
+
62
+ /**
63
+ * An issuer's keys, read through the data center's cache: each run is its
64
+ * own object, often in a fresh isolate, and each read the issuer twice per
65
+ * join (2026-10-22). A key it does not hold is read past the cache.
66
+ */
67
+ async function fetchJson(url: string, fresh: boolean): Promise<unknown> {
68
+ const hit = fresh ? undefined : await caches.default.match(url)
69
+ if (hit !== undefined) return hit.json()
70
+ const res = await fetch(url, { signal: AbortSignal.timeout(10_000) })
71
+ if (!res.ok) throw new Error(`${url}: ${res.status}`)
72
+ const body = await res.text()
73
+ await caches.default.put(
74
+ url,
75
+ new Response(body, { headers: { 'cache-control': `max-age=${KEYS_TTL_S}` } }),
76
+ )
77
+ return JSON.parse(body)
78
+ }
79
+
80
+ /** What is wrong with an ALLOW, read once per value. */
81
+ let allowChecked: { allow: string | undefined; why: string | undefined } | undefined
82
+
83
+ /**
84
+ * Why no run can join under `allow`, or `undefined`. A 403, not the 503 an
85
+ * object that will not start gives: a client retries a 5xx, and this
86
+ * refusal does not pass (2026-10-23: fail quickly).
87
+ */
88
+ function allowRefusal(allow: string | undefined): string | undefined {
89
+ if (allowChecked !== undefined && allowChecked.allow === allow) return allowChecked.why
90
+ let why: string | undefined
91
+ try {
92
+ // wrangler.jsonc may write a var as JSON: a list reached parseAllow.
93
+ if (allow !== undefined && typeof allow !== 'string')
94
+ why = "this relay's ALLOW: one string of issuer:owner/repo:workflow entries, space separated"
95
+ else if (parseAllow(allow ?? '').length === 0)
96
+ why = "this relay's ALLOW is empty: it admits no run"
97
+ } catch (err) {
98
+ why = `this relay's ALLOW: ${(err as Error).message}`
99
+ }
100
+ allowChecked = { allow, why }
101
+ return why
102
+ }
103
+
104
+ /**
105
+ * A refusal: a socket request is let in and its socket closed saying why,
106
+ * as the relay does (a failed upgrade tells a client nothing it can read).
107
+ */
108
+ function refuse(req: Request, status: number, error: string): Response {
109
+ if (req.headers.get('upgrade')?.toLowerCase() !== 'websocket') {
110
+ return Response.json({ error }, { status })
111
+ }
112
+ const pair = new WebSocketPair()
113
+ pair[1].accept()
114
+ closeSocket(pair[1], refusedWith(status), error)
115
+ return upgraded(pair[0], negotiate(req).protocol)
116
+ }
117
+
118
+ /** The `101` handing the client its end of `socket`, naming the subprotocol chosen. */
119
+ function upgraded(socket: Socket, protocol: string | undefined): Response {
120
+ return new Response(null, {
121
+ status: 101,
122
+ webSocket: socket,
123
+ ...(protocol === undefined ? {} : { headers: { 'sec-websocket-protocol': protocol } }),
124
+ } as ResponseInit)
125
+ }
126
+
127
+ /** Verifiers by ALLOW and audience, shared by the runs of one isolate. */
128
+ const verifiers = new Map<string, ReturnType<typeof createVerifier>>()
129
+
130
+ /**
131
+ * The verifier of a request's token. The worker's URL is not known before
132
+ * its first deploy; a token for another one is not meant for this relay.
133
+ */
134
+ function verifierOf(env: Env, req: Request): ReturnType<typeof createVerifier> {
135
+ // jose matches aud exactly: a pasted newline refused every token.
136
+ const audience = env.AUDIENCE?.trim() || new URL(req.url).origin
137
+ const key = `${env.ALLOW}\0${audience}`
138
+ let v = verifiers.get(key)
139
+ if (v === undefined) {
140
+ v = createVerifier({ allow: parseAllow(env.ALLOW ?? ''), audience, fetchJson })
141
+ verifiers.set(key, v)
142
+ }
143
+ return v
144
+ }
145
+
146
+ export default {
147
+ async fetch(req: Request, env: Env): Promise<Response> {
148
+ const session = SESSION.exec(new URL(req.url).pathname)?.[1]
149
+ if (session === undefined) {
150
+ const error = `no route ${req.method} ${new URL(req.url).pathname.slice(0, 200)}: the relay serves GET /v1/sessions/<id>/socket (PROTOCOL.md)`
151
+ return Response.json({ error }, { status: 404 })
152
+ }
153
+ const refused = allowRefusal(env.ALLOW)
154
+ if (refused !== undefined) return refuse(req, 403, refused)
155
+ // wrangler.jsonc may write a var as JSON: "env.AUDIENCE.trim is not a
156
+ // function" escaped as a 500, which a client retries.
157
+ if (env.AUDIENCE !== undefined && typeof (env.AUDIENCE as unknown) !== 'string')
158
+ return refuse(req, 403, "this relay's AUDIENCE: one string, the aud CI tokens name")
159
+ // A blank one was the audience itself: every token was "wrong audience".
160
+ if (env.AUDIENCE !== undefined && env.AUDIENCE.trim() === '')
161
+ return refuse(req, 403, "this relay's AUDIENCE: the aud CI tokens name, or unset, not blank")
162
+ if (env.RELAY === undefined) {
163
+ return refuse(
164
+ req,
165
+ 403,
166
+ 'RELAY: bind the RelayRun Durable Object as RELAY (wrangler.jsonc durable_objects)',
167
+ )
168
+ }
169
+ // Checked here, before any object: each one a request wakes is billed,
170
+ // and a host's daily limit then refuses every run, so a request with
171
+ // no good token spent the relay's quota.
172
+ const token = /^Bearer (\S+)$/.exec(req.headers.get('authorization') ?? '')?.[1]
173
+ if (token === undefined) return refuse(req, 401, 'no bearer token')
174
+ let run: VerifiedToken
175
+ try {
176
+ run = await verifierOf(env, req)(token)
177
+ } catch (err) {
178
+ if (!(err instanceof TokenError)) throw err
179
+ return refuse(req, err.status, err.message)
180
+ }
181
+ const stub = env.RELAY.get(env.RELAY.idFromName(await sessionKey(session, run)))
182
+ try {
183
+ return await stub.fetch(req)
184
+ } catch (err) {
185
+ // The host refused the object (a plan's daily limit, an outage): a
186
+ // bare 500 told a failed run nothing (2026-10-22).
187
+ const why = err instanceof Error ? err.message : String(err)
188
+ return refuse(req, 503, `the relay's host refused the run: ${why}`)
189
+ }
190
+ },
191
+ }
192
+
193
+ /** One run's relay. */
194
+ export class RelayRun {
195
+ private readonly relay: Relay
196
+
197
+ constructor(state: DurableObjectState, env: Env) {
198
+ let allow: ReturnType<typeof parseAllow>
199
+ try {
200
+ allow = parseAllow(env.ALLOW ?? '')
201
+ } catch (err) {
202
+ throw new Error(`vx-dte-server: ALLOW: ${(err as Error).message}`)
203
+ }
204
+ if (allow.length === 0) throw new Error('vx-dte-server: set ALLOW')
205
+ this.relay = createRelay({
206
+ verify: (token, req) => verifierOf(env, req)(token),
207
+ // One `attach` closed kept nothing: it is gone.
208
+ sockets: state.getWebSockets().flatMap((ws) => {
209
+ const kept = ws.deserializeAttachment()
210
+ if (kept === null) closeSocket(ws, GONE, 'gone')
211
+ return kept === null ? [] : [[kept, ws] as const]
212
+ }),
213
+ upgrade: (key, id, _req, protocol) => {
214
+ const pair = new WebSocketPair()
215
+ state.acceptWebSocket(pair[1])
216
+ const kept = this.relay.attach(key, id, pair[1])
217
+ if (kept !== false) pair[1].serializeAttachment(kept)
218
+ return upgraded(pair[0], protocol)
219
+ },
220
+ })
221
+ }
222
+
223
+ fetch(req: Request): Promise<Response> {
224
+ return this.relay.fetch(req)
225
+ }
226
+
227
+ webSocketMessage(ws: Socket, message: string | ArrayBuffer): void {
228
+ const kept = ws.deserializeAttachment()
229
+ if (kept === null) return
230
+ const { key, id } = kept
231
+ const text = typeof message === 'string' ? message : new TextDecoder().decode(message)
232
+ const answer = this.relay.frame(key, id, text, ws)
233
+ // A `leave` closed the socket it came down: its close is the answer.
234
+ try {
235
+ if (answer !== undefined) ws.send(answer)
236
+ } catch {}
237
+ // quiet: a socket that cannot send has closed; its close is the answer
238
+ }
239
+
240
+ webSocketClose(ws: Socket): void {
241
+ // One `attach` closed as gone kept nothing: its close threw (main
242
+ // 8d0619f's redial, 2026-10-24).
243
+ const kept = ws.deserializeAttachment()
244
+ if (kept !== null) this.relay.detach(kept.key, kept.id, ws)
245
+ }
246
+
247
+ webSocketError(ws: Socket): void {
248
+ this.webSocketClose(ws)
249
+ }
250
+ }
package/wrangler.jsonc ADDED
@@ -0,0 +1,18 @@
1
+ // The relay on Cloudflare Workers (src/worker.ts): what the README's Deploy
2
+ // to Cloudflare button reads, and `bunx wrangler deploy` from this directory.
3
+ // Each run is one Durable Object, which writes no storage.
4
+ {
5
+ "name": "vx-dte-server",
6
+ "main": "src/worker.ts",
7
+ "compatibility_date": "2026-10-01",
8
+ // The relay's URL until a domain is added.
9
+ "workers_dev": true,
10
+ // Who may take part, a plain var each deploy sets from this file: fill it
11
+ // in your copy. Empty, the relay refuses every run with "this relay's
12
+ // ALLOW is empty: it admits no run".
13
+ "vars": { "ALLOW": "" },
14
+ "durable_objects": {
15
+ "bindings": [{ "name": "RELAY", "class_name": "RelayRun" }],
16
+ },
17
+ "migrations": [{ "tag": "v1", "new_sqlite_classes": ["RelayRun"] }],
18
+ }