@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/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
|
+
}
|