@h-rig/relay-registry 0.0.6-alpha.92

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/README.md ADDED
@@ -0,0 +1 @@
1
+ # @h-rig/relay-registry
@@ -0,0 +1,2 @@
1
+ export declare function deriveOwnerToken(namespaceKey: string, secret: string): string;
2
+ export declare function verifyOwnerBearer(header: string | null | undefined, namespaceKey: string, secret: string): boolean;
@@ -0,0 +1,28 @@
1
+ // @bun
2
+ // packages/relay-registry/src/auth.ts
3
+ import { createHash, createHmac, timingSafeEqual } from "crypto";
4
+ function deriveOwnerToken(namespaceKey, secret) {
5
+ return createHmac("sha256", secret).update(namespaceKey).digest("base64url");
6
+ }
7
+ function bearerToken(header) {
8
+ if (!header)
9
+ return null;
10
+ const [scheme, token, extra] = header.trim().split(/\s+/);
11
+ if (extra !== undefined || scheme?.toLowerCase() !== "bearer" || !token)
12
+ return null;
13
+ return token;
14
+ }
15
+ function digest(value) {
16
+ return createHash("sha256").update(value).digest();
17
+ }
18
+ function verifyOwnerBearer(header, namespaceKey, secret) {
19
+ const token = bearerToken(header);
20
+ if (!token)
21
+ return false;
22
+ const expected = deriveOwnerToken(namespaceKey, secret);
23
+ return timingSafeEqual(digest(token), digest(expected)) && token.length === expected.length;
24
+ }
25
+ export {
26
+ verifyOwnerBearer,
27
+ deriveOwnerToken
28
+ };
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Standalone content-blind collab relay for where.rig-does.work.
3
+ *
4
+ * This is oh-my-pi's relay contract verbatim (from
5
+ * can1357/oh-my-pi packages/collab-web/scripts/local-relay.ts), so the
6
+ * @oh-my-pi/pi-coding-agent host/guest clients interoperate unchanged:
7
+ * - GET /r/<roomId>?role=host|guest upgrades to a WebSocket.
8
+ * - host creates the room; second host -> close 4009; guest to missing -> 4004.
9
+ * - host binary frames: peerId 0 broadcasts to all guests, peerId N targets one.
10
+ * - guest binary frames: first 4 envelope bytes rewritten to the sender's peerId,
11
+ * forwarded to the host.
12
+ * - TEXT control to host: {"t":"peer-joined","peer":N} / {"t":"peer-left","peer":N}.
13
+ * - host disconnect: {"t":"room-closed"} to guests, close 4001, room GC'd.
14
+ * Payloads stay sealed end-to-end — the relay never sees plaintext.
15
+ */
16
+ declare const ENVELOPE_HEADER_LENGTH = 4;
17
+ declare function unpackEnvelope(data: Uint8Array): {
18
+ peerId: number;
19
+ payload: Uint8Array;
20
+ } | null;
21
+ declare function rewriteEnvelopePeer(data: Uint8Array, peerId: number): void;
22
+ declare const ROOM_PATH_RE: RegExp;
23
+ interface SocketData {
24
+ roomId: string;
25
+ role: "host" | "guest";
26
+ peerId: number;
27
+ }
28
+ type RelaySocket = Bun.ServerWebSocket<SocketData>;
29
+ interface Room {
30
+ host: RelaySocket;
31
+ guests: Map<number, RelaySocket>;
32
+ nextPeerId: number;
33
+ }
34
+ declare const rooms: Map<string, Room>;
35
+ declare const port: number;
36
+ declare const server: Bun.Server<SocketData>;
@@ -0,0 +1,99 @@
1
+ // @bun
2
+ // packages/relay-registry/src/bin/relay.ts
3
+ var ENVELOPE_HEADER_LENGTH = 4;
4
+ function unpackEnvelope(data) {
5
+ if (data.byteLength < ENVELOPE_HEADER_LENGTH)
6
+ return null;
7
+ const peerId = new DataView(data.buffer, data.byteOffset, ENVELOPE_HEADER_LENGTH).getUint32(0, false);
8
+ return { peerId, payload: data.subarray(ENVELOPE_HEADER_LENGTH) };
9
+ }
10
+ function rewriteEnvelopePeer(data, peerId) {
11
+ new DataView(data.buffer, data.byteOffset, ENVELOPE_HEADER_LENGTH).setUint32(0, peerId, false);
12
+ }
13
+ var ROOM_PATH_RE = /^\/r\/([A-Za-z0-9_-]{10,64})$/;
14
+ var rooms = new Map;
15
+ var port = Number(process.env.RELAY_PORT ?? "3774");
16
+ var server = Bun.serve({
17
+ port,
18
+ fetch(req, srv) {
19
+ const url = new URL(req.url);
20
+ if (url.pathname === "/health")
21
+ return new Response("ok");
22
+ const match = ROOM_PATH_RE.exec(url.pathname);
23
+ const role = url.searchParams.get("role");
24
+ if (!match || role !== "host" && role !== "guest") {
25
+ return new Response("not found", { status: 404 });
26
+ }
27
+ const data = { roomId: match[1], role, peerId: 0 };
28
+ if (srv.upgrade(req, { data }))
29
+ return;
30
+ return new Response("websocket upgrade required", { status: 426 });
31
+ },
32
+ websocket: {
33
+ open(ws) {
34
+ const { roomId, role } = ws.data;
35
+ if (role === "host") {
36
+ if (rooms.has(roomId)) {
37
+ ws.close(4009, "a host is already connected for this room");
38
+ return;
39
+ }
40
+ rooms.set(roomId, { host: ws, guests: new Map, nextPeerId: 1 });
41
+ return;
42
+ }
43
+ const room = rooms.get(roomId);
44
+ if (!room) {
45
+ ws.close(4004, "no such room");
46
+ return;
47
+ }
48
+ const peerId = room.nextPeerId++;
49
+ ws.data.peerId = peerId;
50
+ room.guests.set(peerId, ws);
51
+ room.host.send(JSON.stringify({ t: "peer-joined", peer: peerId }));
52
+ },
53
+ message(ws, message) {
54
+ if (typeof message === "string")
55
+ return;
56
+ const room = rooms.get(ws.data.roomId);
57
+ if (!room)
58
+ return;
59
+ if (ws.data.role === "host") {
60
+ const envelope = unpackEnvelope(message);
61
+ if (!envelope)
62
+ return;
63
+ if (envelope.peerId === 0) {
64
+ for (const guest of room.guests.values())
65
+ guest.send(message);
66
+ } else {
67
+ room.guests.get(envelope.peerId)?.send(message);
68
+ }
69
+ return;
70
+ }
71
+ if (message.byteLength < 4)
72
+ return;
73
+ rewriteEnvelopePeer(message, ws.data.peerId);
74
+ room.host.send(message);
75
+ },
76
+ close(ws) {
77
+ const { roomId, role, peerId } = ws.data;
78
+ const room = rooms.get(roomId);
79
+ if (!room)
80
+ return;
81
+ if (role === "host") {
82
+ if (room.host !== ws)
83
+ return;
84
+ rooms.delete(roomId);
85
+ const closure = JSON.stringify({ t: "room-closed" });
86
+ for (const guest of room.guests.values()) {
87
+ guest.send(closure);
88
+ guest.close(4001, "room closed");
89
+ }
90
+ room.guests.clear();
91
+ return;
92
+ }
93
+ if (room.guests.delete(peerId)) {
94
+ room.host.send(JSON.stringify({ t: "peer-left", peer: peerId }));
95
+ }
96
+ }
97
+ }
98
+ });
99
+ console.log(`rig-omp-relay listening on :${server.port}`);
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env bun
2
+ export {};
@@ -0,0 +1,288 @@
1
+ #!/usr/bin/env bun
2
+ // @bun
3
+
4
+ // packages/relay-registry/src/server.ts
5
+ import { Schema as Schema2 } from "@rig/contracts";
6
+
7
+ // packages/relay-registry/src/auth.ts
8
+ import { createHash, createHmac, timingSafeEqual } from "crypto";
9
+ function deriveOwnerToken(namespaceKey, secret) {
10
+ return createHmac("sha256", secret).update(namespaceKey).digest("base64url");
11
+ }
12
+ function bearerToken(header) {
13
+ if (!header)
14
+ return null;
15
+ const [scheme, token, extra] = header.trim().split(/\s+/);
16
+ if (extra !== undefined || scheme?.toLowerCase() !== "bearer" || !token)
17
+ return null;
18
+ return token;
19
+ }
20
+ function digest(value) {
21
+ return createHash("sha256").update(value).digest();
22
+ }
23
+ function verifyOwnerBearer(header, namespaceKey, secret) {
24
+ const token = bearerToken(header);
25
+ if (!token)
26
+ return false;
27
+ const expected = deriveOwnerToken(namespaceKey, secret);
28
+ return timingSafeEqual(digest(token), digest(expected)) && token.length === expected.length;
29
+ }
30
+
31
+ // packages/relay-registry/src/schema.ts
32
+ import { Schema } from "@rig/contracts";
33
+ var RegistryOwner = Schema.Struct({
34
+ githubUserId: Schema.String,
35
+ login: Schema.String,
36
+ namespaceKey: Schema.String
37
+ });
38
+ var RegistryStatus = Schema.Literals([
39
+ "starting",
40
+ "running",
41
+ "waiting-approval",
42
+ "waiting-input",
43
+ "completed",
44
+ "failed",
45
+ "stopped"
46
+ ]);
47
+ var RegistryEntry = Schema.Struct({
48
+ roomId: Schema.String,
49
+ owner: RegistryOwner,
50
+ repo: Schema.String,
51
+ title: Schema.String,
52
+ status: RegistryStatus,
53
+ joinLink: Schema.String,
54
+ webLink: Schema.String,
55
+ relayUrl: Schema.String,
56
+ startedAt: Schema.String,
57
+ heartbeatAt: Schema.String,
58
+ pid: Schema.optional(Schema.Number)
59
+ });
60
+ var RegisterRequest = Schema.Struct({
61
+ roomId: Schema.String,
62
+ owner: RegistryOwner,
63
+ repo: Schema.String,
64
+ title: Schema.String,
65
+ status: RegistryStatus,
66
+ joinLink: Schema.String,
67
+ webLink: Schema.String,
68
+ relayUrl: Schema.String,
69
+ startedAt: Schema.String,
70
+ pid: Schema.optional(Schema.Number)
71
+ });
72
+ var HeartbeatRequest = Schema.Struct({
73
+ roomId: Schema.String,
74
+ status: RegistryStatus
75
+ });
76
+ var RemoveRequest = Schema.Struct({
77
+ roomId: Schema.String
78
+ });
79
+ var ListedRegistryEntry = Schema.Struct({
80
+ roomId: Schema.String,
81
+ owner: RegistryOwner,
82
+ repo: Schema.String,
83
+ title: Schema.String,
84
+ status: RegistryStatus,
85
+ joinLink: Schema.String,
86
+ webLink: Schema.String,
87
+ relayUrl: Schema.String,
88
+ startedAt: Schema.String,
89
+ heartbeatAt: Schema.String,
90
+ pid: Schema.optional(Schema.Number),
91
+ stale: Schema.Boolean
92
+ });
93
+ var ListResponse = Schema.Struct({
94
+ entries: Schema.Array(ListedRegistryEntry)
95
+ });
96
+ var RegisterResponse = Schema.Struct({
97
+ entry: RegistryEntry
98
+ });
99
+ var HeartbeatResponse = Schema.Struct({
100
+ entry: RegistryEntry
101
+ });
102
+ var RemoveResponse = Schema.Struct({
103
+ removed: Schema.Boolean
104
+ });
105
+
106
+ // packages/relay-registry/src/staleness.ts
107
+ var LIVE_STALE_AFTER_MS = 45000;
108
+ function computeStale(entry, now = Date.now()) {
109
+ const heartbeat = Date.parse(entry.heartbeatAt);
110
+ return !(Number.isFinite(heartbeat) && now - heartbeat <= LIVE_STALE_AFTER_MS);
111
+ }
112
+
113
+ // packages/relay-registry/src/server.ts
114
+ var decodeRegisterRequest = Schema2.decodeUnknownSync(RegisterRequest);
115
+ var decodeHeartbeatRequest = Schema2.decodeUnknownSync(HeartbeatRequest);
116
+ var decodeRemoveRequest = Schema2.decodeUnknownSync(RemoveRequest);
117
+ function json(data, init) {
118
+ return Response.json(data, init);
119
+ }
120
+ function routePath(request) {
121
+ return new URL(request.url).pathname.replace(/\/+$/, "") || "/";
122
+ }
123
+ async function decodeJsonBody(request, decode) {
124
+ return decode(await request.json());
125
+ }
126
+ function unauthorized() {
127
+ return json({ ok: false, error: "unauthorized" }, { status: 401 });
128
+ }
129
+ function notFound() {
130
+ return json({ ok: false, error: "not found" }, { status: 404 });
131
+ }
132
+ function badRequest(error) {
133
+ return json({ ok: false, error: error instanceof Error ? error.message : String(error) }, { status: 400 });
134
+ }
135
+ function startRegistryServer(options) {
136
+ const entries = new Map;
137
+ const now = options.now ?? Date.now;
138
+ const subscribers = new Map;
139
+ const encoder = new TextEncoder;
140
+ const publish = (namespaceKey, event) => {
141
+ const subs = subscribers.get(namespaceKey);
142
+ if (!subs || subs.size === 0)
143
+ return;
144
+ const frame = encoder.encode(`data: ${JSON.stringify(event)}
145
+
146
+ `);
147
+ for (const controller of subs) {
148
+ try {
149
+ controller.enqueue(frame);
150
+ } catch {}
151
+ }
152
+ };
153
+ const keepalive = setInterval(() => {
154
+ const ping = encoder.encode(`: ping
155
+
156
+ `);
157
+ for (const subs of subscribers.values()) {
158
+ for (const controller of subs) {
159
+ try {
160
+ controller.enqueue(ping);
161
+ } catch {}
162
+ }
163
+ }
164
+ }, 25000);
165
+ if (typeof keepalive.unref === "function")
166
+ keepalive.unref();
167
+ const server = Bun.serve({
168
+ port: options.port ?? 0,
169
+ async fetch(request) {
170
+ const url = new URL(request.url);
171
+ const path = routePath(request);
172
+ if (request.method === "GET" && path === "/registry/health")
173
+ return json({ ok: true });
174
+ try {
175
+ if (request.method === "POST" && path === "/registry/register") {
176
+ const body = await decodeJsonBody(request, decodeRegisterRequest);
177
+ if (!verifyOwnerBearer(request.headers.get("authorization"), body.owner.namespaceKey, options.secret))
178
+ return unauthorized();
179
+ const entry = { ...body, heartbeatAt: new Date(now()).toISOString() };
180
+ entries.set(entry.roomId, entry);
181
+ publish(entry.owner.namespaceKey, { type: "registered", roomId: entry.roomId });
182
+ return json({ entry });
183
+ }
184
+ if (request.method === "POST" && path === "/registry/heartbeat") {
185
+ const body = await decodeJsonBody(request, decodeHeartbeatRequest);
186
+ const entry = entries.get(body.roomId);
187
+ if (!entry)
188
+ return notFound();
189
+ if (!verifyOwnerBearer(request.headers.get("authorization"), entry.owner.namespaceKey, options.secret))
190
+ return unauthorized();
191
+ const updated = {
192
+ ...entry,
193
+ status: body.status,
194
+ heartbeatAt: new Date(now()).toISOString()
195
+ };
196
+ entries.set(updated.roomId, updated);
197
+ publish(updated.owner.namespaceKey, { type: "heartbeat", roomId: updated.roomId });
198
+ return json({ entry: updated });
199
+ }
200
+ if (request.method === "GET" && path === "/registry/list") {
201
+ const namespaceKey = url.searchParams.get("namespaceKey");
202
+ if (!namespaceKey || !verifyOwnerBearer(request.headers.get("authorization"), namespaceKey, options.secret))
203
+ return unauthorized();
204
+ const repo = url.searchParams.get("repo");
205
+ const listedAt = now();
206
+ const scoped = Array.from(entries.values()).filter((entry) => entry.owner.namespaceKey === namespaceKey).filter((entry) => repo === null || entry.repo === repo).map((entry) => ({ ...entry, stale: computeStale(entry, listedAt) }));
207
+ return json({ entries: scoped });
208
+ }
209
+ if (request.method === "POST" && path === "/registry/remove") {
210
+ const body = await decodeJsonBody(request, decodeRemoveRequest);
211
+ const entry = entries.get(body.roomId);
212
+ if (!entry)
213
+ return notFound();
214
+ if (!verifyOwnerBearer(request.headers.get("authorization"), entry.owner.namespaceKey, options.secret))
215
+ return unauthorized();
216
+ entries.delete(body.roomId);
217
+ publish(entry.owner.namespaceKey, { type: "removed", roomId: entry.roomId });
218
+ return json({ removed: true });
219
+ }
220
+ if (request.method === "GET" && path === "/registry/subscribe") {
221
+ const namespaceKey = url.searchParams.get("namespaceKey");
222
+ if (!namespaceKey || !verifyOwnerBearer(request.headers.get("authorization"), namespaceKey, options.secret))
223
+ return unauthorized();
224
+ let registered;
225
+ const body = new ReadableStream({
226
+ start(controller) {
227
+ registered = controller;
228
+ let set = subscribers.get(namespaceKey);
229
+ if (!set) {
230
+ set = new Set;
231
+ subscribers.set(namespaceKey, set);
232
+ }
233
+ set.add(controller);
234
+ controller.enqueue(encoder.encode(`: connected
235
+
236
+ `));
237
+ },
238
+ cancel() {
239
+ const set = subscribers.get(namespaceKey);
240
+ if (set && registered) {
241
+ set.delete(registered);
242
+ if (set.size === 0)
243
+ subscribers.delete(namespaceKey);
244
+ }
245
+ }
246
+ });
247
+ return new Response(body, {
248
+ headers: {
249
+ "content-type": "text/event-stream",
250
+ "cache-control": "no-cache",
251
+ connection: "keep-alive"
252
+ }
253
+ });
254
+ }
255
+ return notFound();
256
+ } catch (error) {
257
+ return badRequest(error);
258
+ }
259
+ }
260
+ });
261
+ const boundPort = Number(server.url.port);
262
+ return {
263
+ port: boundPort,
264
+ stop() {
265
+ clearInterval(keepalive);
266
+ for (const subs of subscribers.values()) {
267
+ for (const controller of subs) {
268
+ try {
269
+ controller.close();
270
+ } catch {}
271
+ }
272
+ }
273
+ subscribers.clear();
274
+ server.stop(true);
275
+ }
276
+ };
277
+ }
278
+
279
+ // packages/relay-registry/src/bin/serve.ts
280
+ var portText = process.env.REGISTRY_PORT ?? "8788";
281
+ var port = Number.parseInt(portText, 10);
282
+ if (!Number.isInteger(port) || port <= 0)
283
+ throw new Error(`Invalid REGISTRY_PORT: ${portText}`);
284
+ var secret = process.env.REGISTRY_SHARED_SECRET;
285
+ if (!secret)
286
+ throw new Error("REGISTRY_SHARED_SECRET is required");
287
+ var server = startRegistryServer({ port, secret });
288
+ console.log(`rig-registry listening on :${server.port}`);
@@ -0,0 +1,21 @@
1
+ import type { HeartbeatRequest, ListedRegistryEntry, RegisterRequest, RegistryEntry, RemoveRequest } from "./schema";
2
+ export type CollabRegistryFilter = {
3
+ readonly cwd?: string;
4
+ readonly selectedRepo?: string;
5
+ readonly githubUserId?: string;
6
+ readonly namespaceKey?: string;
7
+ };
8
+ export type RegistryClientOptions = {
9
+ readonly baseUrl: string;
10
+ readonly namespaceKey: string;
11
+ readonly secret: string;
12
+ readonly fetch?: typeof fetch;
13
+ };
14
+ export type RegistryClient = {
15
+ registerRoom(room: RegisterRequest): Promise<RegistryEntry>;
16
+ heartbeatRoom(roomId: string, status: HeartbeatRequest["status"]): Promise<RegistryEntry>;
17
+ listRoomsByOwner(filter: CollabRegistryFilter): Promise<readonly ListedRegistryEntry[]>;
18
+ removeRoom(roomId: string): Promise<boolean>;
19
+ };
20
+ export declare function createRegistryClient(options: RegistryClientOptions): RegistryClient;
21
+ export type { HeartbeatRequest, ListedRegistryEntry, RegisterRequest, RegistryEntry, RemoveRequest };
@@ -0,0 +1,63 @@
1
+ // @bun
2
+ // packages/relay-registry/src/auth.ts
3
+ import { createHash, createHmac, timingSafeEqual } from "crypto";
4
+ function deriveOwnerToken(namespaceKey, secret) {
5
+ return createHmac("sha256", secret).update(namespaceKey).digest("base64url");
6
+ }
7
+
8
+ // packages/relay-registry/src/client.ts
9
+ function registryUrl(baseUrl, pathname) {
10
+ const base = baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`;
11
+ return new URL(pathname.replace(/^\/+/, ""), base);
12
+ }
13
+ async function parseResponseJson(response) {
14
+ const text = await response.text();
15
+ const data = text ? JSON.parse(text) : null;
16
+ if (!response.ok) {
17
+ const message = data && typeof data === "object" && "error" in data ? String(data.error) : response.statusText;
18
+ throw new Error(`registry request failed (${response.status}): ${message}`);
19
+ }
20
+ return data;
21
+ }
22
+ function createRegistryClient(options) {
23
+ const requestFetch = options.fetch ?? fetch;
24
+ const token = deriveOwnerToken(options.namespaceKey, options.secret);
25
+ const headers = {
26
+ authorization: `Bearer ${token}`,
27
+ "content-type": "application/json"
28
+ };
29
+ async function post(pathname, body) {
30
+ const response = await requestFetch(registryUrl(options.baseUrl, pathname), {
31
+ method: "POST",
32
+ headers,
33
+ body: JSON.stringify(body)
34
+ });
35
+ return parseResponseJson(response);
36
+ }
37
+ return {
38
+ async registerRoom(room) {
39
+ const response = await post("register", room);
40
+ return response.entry;
41
+ },
42
+ async heartbeatRoom(roomId, status) {
43
+ const response = await post("heartbeat", { roomId, status });
44
+ return response.entry;
45
+ },
46
+ async listRoomsByOwner(filter) {
47
+ const namespaceKey = filter.namespaceKey ?? options.namespaceKey;
48
+ const url = registryUrl(options.baseUrl, "list");
49
+ url.searchParams.set("namespaceKey", namespaceKey);
50
+ if (filter.selectedRepo)
51
+ url.searchParams.set("repo", filter.selectedRepo);
52
+ const response = await requestFetch(url, { headers: { authorization: headers.authorization } });
53
+ return (await parseResponseJson(response)).entries;
54
+ },
55
+ async removeRoom(roomId) {
56
+ const response = await post("remove", { roomId });
57
+ return response.removed;
58
+ }
59
+ };
60
+ }
61
+ export {
62
+ createRegistryClient
63
+ };
@@ -0,0 +1,137 @@
1
+ import { Schema } from "@rig/contracts";
2
+ export declare const RegistryOwner: Schema.Struct<{
3
+ readonly githubUserId: Schema.String;
4
+ readonly login: Schema.String;
5
+ readonly namespaceKey: Schema.String;
6
+ }>;
7
+ export type RegistryOwner = typeof RegistryOwner.Type;
8
+ export declare const RegistryStatus: Schema.Literals<readonly ["starting", "running", "waiting-approval", "waiting-input", "completed", "failed", "stopped"]>;
9
+ export type RegistryStatus = typeof RegistryStatus.Type;
10
+ export declare const RegistryEntry: Schema.Struct<{
11
+ readonly roomId: Schema.String;
12
+ readonly owner: Schema.Struct<{
13
+ readonly githubUserId: Schema.String;
14
+ readonly login: Schema.String;
15
+ readonly namespaceKey: Schema.String;
16
+ }>;
17
+ readonly repo: Schema.String;
18
+ readonly title: Schema.String;
19
+ readonly status: Schema.Literals<readonly ["starting", "running", "waiting-approval", "waiting-input", "completed", "failed", "stopped"]>;
20
+ readonly joinLink: Schema.String;
21
+ readonly webLink: Schema.String;
22
+ readonly relayUrl: Schema.String;
23
+ readonly startedAt: Schema.String;
24
+ readonly heartbeatAt: Schema.String;
25
+ readonly pid: Schema.optional<Schema.Number>;
26
+ }>;
27
+ export type RegistryEntry = typeof RegistryEntry.Type;
28
+ export declare const RegisterRequest: Schema.Struct<{
29
+ readonly roomId: Schema.String;
30
+ readonly owner: Schema.Struct<{
31
+ readonly githubUserId: Schema.String;
32
+ readonly login: Schema.String;
33
+ readonly namespaceKey: Schema.String;
34
+ }>;
35
+ readonly repo: Schema.String;
36
+ readonly title: Schema.String;
37
+ readonly status: Schema.Literals<readonly ["starting", "running", "waiting-approval", "waiting-input", "completed", "failed", "stopped"]>;
38
+ readonly joinLink: Schema.String;
39
+ readonly webLink: Schema.String;
40
+ readonly relayUrl: Schema.String;
41
+ readonly startedAt: Schema.String;
42
+ readonly pid: Schema.optional<Schema.Number>;
43
+ }>;
44
+ export type RegisterRequest = typeof RegisterRequest.Type;
45
+ export declare const HeartbeatRequest: Schema.Struct<{
46
+ readonly roomId: Schema.String;
47
+ readonly status: Schema.Literals<readonly ["starting", "running", "waiting-approval", "waiting-input", "completed", "failed", "stopped"]>;
48
+ }>;
49
+ export type HeartbeatRequest = typeof HeartbeatRequest.Type;
50
+ export declare const RemoveRequest: Schema.Struct<{
51
+ readonly roomId: Schema.String;
52
+ }>;
53
+ export type RemoveRequest = typeof RemoveRequest.Type;
54
+ export declare const ListedRegistryEntry: Schema.Struct<{
55
+ readonly roomId: Schema.String;
56
+ readonly owner: Schema.Struct<{
57
+ readonly githubUserId: Schema.String;
58
+ readonly login: Schema.String;
59
+ readonly namespaceKey: Schema.String;
60
+ }>;
61
+ readonly repo: Schema.String;
62
+ readonly title: Schema.String;
63
+ readonly status: Schema.Literals<readonly ["starting", "running", "waiting-approval", "waiting-input", "completed", "failed", "stopped"]>;
64
+ readonly joinLink: Schema.String;
65
+ readonly webLink: Schema.String;
66
+ readonly relayUrl: Schema.String;
67
+ readonly startedAt: Schema.String;
68
+ readonly heartbeatAt: Schema.String;
69
+ readonly pid: Schema.optional<Schema.Number>;
70
+ readonly stale: Schema.Boolean;
71
+ }>;
72
+ export type ListedRegistryEntry = typeof ListedRegistryEntry.Type;
73
+ export declare const ListResponse: Schema.Struct<{
74
+ readonly entries: Schema.$Array<Schema.Struct<{
75
+ readonly roomId: Schema.String;
76
+ readonly owner: Schema.Struct<{
77
+ readonly githubUserId: Schema.String;
78
+ readonly login: Schema.String;
79
+ readonly namespaceKey: Schema.String;
80
+ }>;
81
+ readonly repo: Schema.String;
82
+ readonly title: Schema.String;
83
+ readonly status: Schema.Literals<readonly ["starting", "running", "waiting-approval", "waiting-input", "completed", "failed", "stopped"]>;
84
+ readonly joinLink: Schema.String;
85
+ readonly webLink: Schema.String;
86
+ readonly relayUrl: Schema.String;
87
+ readonly startedAt: Schema.String;
88
+ readonly heartbeatAt: Schema.String;
89
+ readonly pid: Schema.optional<Schema.Number>;
90
+ readonly stale: Schema.Boolean;
91
+ }>>;
92
+ }>;
93
+ export type ListResponse = typeof ListResponse.Type;
94
+ export declare const RegisterResponse: Schema.Struct<{
95
+ readonly entry: Schema.Struct<{
96
+ readonly roomId: Schema.String;
97
+ readonly owner: Schema.Struct<{
98
+ readonly githubUserId: Schema.String;
99
+ readonly login: Schema.String;
100
+ readonly namespaceKey: Schema.String;
101
+ }>;
102
+ readonly repo: Schema.String;
103
+ readonly title: Schema.String;
104
+ readonly status: Schema.Literals<readonly ["starting", "running", "waiting-approval", "waiting-input", "completed", "failed", "stopped"]>;
105
+ readonly joinLink: Schema.String;
106
+ readonly webLink: Schema.String;
107
+ readonly relayUrl: Schema.String;
108
+ readonly startedAt: Schema.String;
109
+ readonly heartbeatAt: Schema.String;
110
+ readonly pid: Schema.optional<Schema.Number>;
111
+ }>;
112
+ }>;
113
+ export type RegisterResponse = typeof RegisterResponse.Type;
114
+ export declare const HeartbeatResponse: Schema.Struct<{
115
+ readonly entry: Schema.Struct<{
116
+ readonly roomId: Schema.String;
117
+ readonly owner: Schema.Struct<{
118
+ readonly githubUserId: Schema.String;
119
+ readonly login: Schema.String;
120
+ readonly namespaceKey: Schema.String;
121
+ }>;
122
+ readonly repo: Schema.String;
123
+ readonly title: Schema.String;
124
+ readonly status: Schema.Literals<readonly ["starting", "running", "waiting-approval", "waiting-input", "completed", "failed", "stopped"]>;
125
+ readonly joinLink: Schema.String;
126
+ readonly webLink: Schema.String;
127
+ readonly relayUrl: Schema.String;
128
+ readonly startedAt: Schema.String;
129
+ readonly heartbeatAt: Schema.String;
130
+ readonly pid: Schema.optional<Schema.Number>;
131
+ }>;
132
+ }>;
133
+ export type HeartbeatResponse = typeof HeartbeatResponse.Type;
134
+ export declare const RemoveResponse: Schema.Struct<{
135
+ readonly removed: Schema.Boolean;
136
+ }>;
137
+ export type RemoveResponse = typeof RemoveResponse.Type;
@@ -0,0 +1,88 @@
1
+ // @bun
2
+ // packages/relay-registry/src/schema.ts
3
+ import { Schema } from "@rig/contracts";
4
+ var RegistryOwner = Schema.Struct({
5
+ githubUserId: Schema.String,
6
+ login: Schema.String,
7
+ namespaceKey: Schema.String
8
+ });
9
+ var RegistryStatus = Schema.Literals([
10
+ "starting",
11
+ "running",
12
+ "waiting-approval",
13
+ "waiting-input",
14
+ "completed",
15
+ "failed",
16
+ "stopped"
17
+ ]);
18
+ var RegistryEntry = Schema.Struct({
19
+ roomId: Schema.String,
20
+ owner: RegistryOwner,
21
+ repo: Schema.String,
22
+ title: Schema.String,
23
+ status: RegistryStatus,
24
+ joinLink: Schema.String,
25
+ webLink: Schema.String,
26
+ relayUrl: Schema.String,
27
+ startedAt: Schema.String,
28
+ heartbeatAt: Schema.String,
29
+ pid: Schema.optional(Schema.Number)
30
+ });
31
+ var RegisterRequest = Schema.Struct({
32
+ roomId: Schema.String,
33
+ owner: RegistryOwner,
34
+ repo: Schema.String,
35
+ title: Schema.String,
36
+ status: RegistryStatus,
37
+ joinLink: Schema.String,
38
+ webLink: Schema.String,
39
+ relayUrl: Schema.String,
40
+ startedAt: Schema.String,
41
+ pid: Schema.optional(Schema.Number)
42
+ });
43
+ var HeartbeatRequest = Schema.Struct({
44
+ roomId: Schema.String,
45
+ status: RegistryStatus
46
+ });
47
+ var RemoveRequest = Schema.Struct({
48
+ roomId: Schema.String
49
+ });
50
+ var ListedRegistryEntry = Schema.Struct({
51
+ roomId: Schema.String,
52
+ owner: RegistryOwner,
53
+ repo: Schema.String,
54
+ title: Schema.String,
55
+ status: RegistryStatus,
56
+ joinLink: Schema.String,
57
+ webLink: Schema.String,
58
+ relayUrl: Schema.String,
59
+ startedAt: Schema.String,
60
+ heartbeatAt: Schema.String,
61
+ pid: Schema.optional(Schema.Number),
62
+ stale: Schema.Boolean
63
+ });
64
+ var ListResponse = Schema.Struct({
65
+ entries: Schema.Array(ListedRegistryEntry)
66
+ });
67
+ var RegisterResponse = Schema.Struct({
68
+ entry: RegistryEntry
69
+ });
70
+ var HeartbeatResponse = Schema.Struct({
71
+ entry: RegistryEntry
72
+ });
73
+ var RemoveResponse = Schema.Struct({
74
+ removed: Schema.Boolean
75
+ });
76
+ export {
77
+ RemoveResponse,
78
+ RemoveRequest,
79
+ RegistryStatus,
80
+ RegistryOwner,
81
+ RegistryEntry,
82
+ RegisterResponse,
83
+ RegisterRequest,
84
+ ListedRegistryEntry,
85
+ ListResponse,
86
+ HeartbeatResponse,
87
+ HeartbeatRequest
88
+ };
@@ -0,0 +1,11 @@
1
+ type StartRegistryServerOptions = {
2
+ readonly port?: number;
3
+ readonly secret: string;
4
+ readonly now?: () => number;
5
+ };
6
+ export type RegistryServer = {
7
+ readonly port: number;
8
+ stop(): void;
9
+ };
10
+ export declare function startRegistryServer(options: StartRegistryServerOptions): RegistryServer;
11
+ export {};
@@ -0,0 +1,278 @@
1
+ // @bun
2
+ // packages/relay-registry/src/server.ts
3
+ import { Schema as Schema2 } from "@rig/contracts";
4
+
5
+ // packages/relay-registry/src/auth.ts
6
+ import { createHash, createHmac, timingSafeEqual } from "crypto";
7
+ function deriveOwnerToken(namespaceKey, secret) {
8
+ return createHmac("sha256", secret).update(namespaceKey).digest("base64url");
9
+ }
10
+ function bearerToken(header) {
11
+ if (!header)
12
+ return null;
13
+ const [scheme, token, extra] = header.trim().split(/\s+/);
14
+ if (extra !== undefined || scheme?.toLowerCase() !== "bearer" || !token)
15
+ return null;
16
+ return token;
17
+ }
18
+ function digest(value) {
19
+ return createHash("sha256").update(value).digest();
20
+ }
21
+ function verifyOwnerBearer(header, namespaceKey, secret) {
22
+ const token = bearerToken(header);
23
+ if (!token)
24
+ return false;
25
+ const expected = deriveOwnerToken(namespaceKey, secret);
26
+ return timingSafeEqual(digest(token), digest(expected)) && token.length === expected.length;
27
+ }
28
+
29
+ // packages/relay-registry/src/schema.ts
30
+ import { Schema } from "@rig/contracts";
31
+ var RegistryOwner = Schema.Struct({
32
+ githubUserId: Schema.String,
33
+ login: Schema.String,
34
+ namespaceKey: Schema.String
35
+ });
36
+ var RegistryStatus = Schema.Literals([
37
+ "starting",
38
+ "running",
39
+ "waiting-approval",
40
+ "waiting-input",
41
+ "completed",
42
+ "failed",
43
+ "stopped"
44
+ ]);
45
+ var RegistryEntry = Schema.Struct({
46
+ roomId: Schema.String,
47
+ owner: RegistryOwner,
48
+ repo: Schema.String,
49
+ title: Schema.String,
50
+ status: RegistryStatus,
51
+ joinLink: Schema.String,
52
+ webLink: Schema.String,
53
+ relayUrl: Schema.String,
54
+ startedAt: Schema.String,
55
+ heartbeatAt: Schema.String,
56
+ pid: Schema.optional(Schema.Number)
57
+ });
58
+ var RegisterRequest = Schema.Struct({
59
+ roomId: Schema.String,
60
+ owner: RegistryOwner,
61
+ repo: Schema.String,
62
+ title: Schema.String,
63
+ status: RegistryStatus,
64
+ joinLink: Schema.String,
65
+ webLink: Schema.String,
66
+ relayUrl: Schema.String,
67
+ startedAt: Schema.String,
68
+ pid: Schema.optional(Schema.Number)
69
+ });
70
+ var HeartbeatRequest = Schema.Struct({
71
+ roomId: Schema.String,
72
+ status: RegistryStatus
73
+ });
74
+ var RemoveRequest = Schema.Struct({
75
+ roomId: Schema.String
76
+ });
77
+ var ListedRegistryEntry = Schema.Struct({
78
+ roomId: Schema.String,
79
+ owner: RegistryOwner,
80
+ repo: Schema.String,
81
+ title: Schema.String,
82
+ status: RegistryStatus,
83
+ joinLink: Schema.String,
84
+ webLink: Schema.String,
85
+ relayUrl: Schema.String,
86
+ startedAt: Schema.String,
87
+ heartbeatAt: Schema.String,
88
+ pid: Schema.optional(Schema.Number),
89
+ stale: Schema.Boolean
90
+ });
91
+ var ListResponse = Schema.Struct({
92
+ entries: Schema.Array(ListedRegistryEntry)
93
+ });
94
+ var RegisterResponse = Schema.Struct({
95
+ entry: RegistryEntry
96
+ });
97
+ var HeartbeatResponse = Schema.Struct({
98
+ entry: RegistryEntry
99
+ });
100
+ var RemoveResponse = Schema.Struct({
101
+ removed: Schema.Boolean
102
+ });
103
+
104
+ // packages/relay-registry/src/staleness.ts
105
+ var LIVE_STALE_AFTER_MS = 45000;
106
+ function computeStale(entry, now = Date.now()) {
107
+ const heartbeat = Date.parse(entry.heartbeatAt);
108
+ return !(Number.isFinite(heartbeat) && now - heartbeat <= LIVE_STALE_AFTER_MS);
109
+ }
110
+
111
+ // packages/relay-registry/src/server.ts
112
+ var decodeRegisterRequest = Schema2.decodeUnknownSync(RegisterRequest);
113
+ var decodeHeartbeatRequest = Schema2.decodeUnknownSync(HeartbeatRequest);
114
+ var decodeRemoveRequest = Schema2.decodeUnknownSync(RemoveRequest);
115
+ function json(data, init) {
116
+ return Response.json(data, init);
117
+ }
118
+ function routePath(request) {
119
+ return new URL(request.url).pathname.replace(/\/+$/, "") || "/";
120
+ }
121
+ async function decodeJsonBody(request, decode) {
122
+ return decode(await request.json());
123
+ }
124
+ function unauthorized() {
125
+ return json({ ok: false, error: "unauthorized" }, { status: 401 });
126
+ }
127
+ function notFound() {
128
+ return json({ ok: false, error: "not found" }, { status: 404 });
129
+ }
130
+ function badRequest(error) {
131
+ return json({ ok: false, error: error instanceof Error ? error.message : String(error) }, { status: 400 });
132
+ }
133
+ function startRegistryServer(options) {
134
+ const entries = new Map;
135
+ const now = options.now ?? Date.now;
136
+ const subscribers = new Map;
137
+ const encoder = new TextEncoder;
138
+ const publish = (namespaceKey, event) => {
139
+ const subs = subscribers.get(namespaceKey);
140
+ if (!subs || subs.size === 0)
141
+ return;
142
+ const frame = encoder.encode(`data: ${JSON.stringify(event)}
143
+
144
+ `);
145
+ for (const controller of subs) {
146
+ try {
147
+ controller.enqueue(frame);
148
+ } catch {}
149
+ }
150
+ };
151
+ const keepalive = setInterval(() => {
152
+ const ping = encoder.encode(`: ping
153
+
154
+ `);
155
+ for (const subs of subscribers.values()) {
156
+ for (const controller of subs) {
157
+ try {
158
+ controller.enqueue(ping);
159
+ } catch {}
160
+ }
161
+ }
162
+ }, 25000);
163
+ if (typeof keepalive.unref === "function")
164
+ keepalive.unref();
165
+ const server = Bun.serve({
166
+ port: options.port ?? 0,
167
+ async fetch(request) {
168
+ const url = new URL(request.url);
169
+ const path = routePath(request);
170
+ if (request.method === "GET" && path === "/registry/health")
171
+ return json({ ok: true });
172
+ try {
173
+ if (request.method === "POST" && path === "/registry/register") {
174
+ const body = await decodeJsonBody(request, decodeRegisterRequest);
175
+ if (!verifyOwnerBearer(request.headers.get("authorization"), body.owner.namespaceKey, options.secret))
176
+ return unauthorized();
177
+ const entry = { ...body, heartbeatAt: new Date(now()).toISOString() };
178
+ entries.set(entry.roomId, entry);
179
+ publish(entry.owner.namespaceKey, { type: "registered", roomId: entry.roomId });
180
+ return json({ entry });
181
+ }
182
+ if (request.method === "POST" && path === "/registry/heartbeat") {
183
+ const body = await decodeJsonBody(request, decodeHeartbeatRequest);
184
+ const entry = entries.get(body.roomId);
185
+ if (!entry)
186
+ return notFound();
187
+ if (!verifyOwnerBearer(request.headers.get("authorization"), entry.owner.namespaceKey, options.secret))
188
+ return unauthorized();
189
+ const updated = {
190
+ ...entry,
191
+ status: body.status,
192
+ heartbeatAt: new Date(now()).toISOString()
193
+ };
194
+ entries.set(updated.roomId, updated);
195
+ publish(updated.owner.namespaceKey, { type: "heartbeat", roomId: updated.roomId });
196
+ return json({ entry: updated });
197
+ }
198
+ if (request.method === "GET" && path === "/registry/list") {
199
+ const namespaceKey = url.searchParams.get("namespaceKey");
200
+ if (!namespaceKey || !verifyOwnerBearer(request.headers.get("authorization"), namespaceKey, options.secret))
201
+ return unauthorized();
202
+ const repo = url.searchParams.get("repo");
203
+ const listedAt = now();
204
+ const scoped = Array.from(entries.values()).filter((entry) => entry.owner.namespaceKey === namespaceKey).filter((entry) => repo === null || entry.repo === repo).map((entry) => ({ ...entry, stale: computeStale(entry, listedAt) }));
205
+ return json({ entries: scoped });
206
+ }
207
+ if (request.method === "POST" && path === "/registry/remove") {
208
+ const body = await decodeJsonBody(request, decodeRemoveRequest);
209
+ const entry = entries.get(body.roomId);
210
+ if (!entry)
211
+ return notFound();
212
+ if (!verifyOwnerBearer(request.headers.get("authorization"), entry.owner.namespaceKey, options.secret))
213
+ return unauthorized();
214
+ entries.delete(body.roomId);
215
+ publish(entry.owner.namespaceKey, { type: "removed", roomId: entry.roomId });
216
+ return json({ removed: true });
217
+ }
218
+ if (request.method === "GET" && path === "/registry/subscribe") {
219
+ const namespaceKey = url.searchParams.get("namespaceKey");
220
+ if (!namespaceKey || !verifyOwnerBearer(request.headers.get("authorization"), namespaceKey, options.secret))
221
+ return unauthorized();
222
+ let registered;
223
+ const body = new ReadableStream({
224
+ start(controller) {
225
+ registered = controller;
226
+ let set = subscribers.get(namespaceKey);
227
+ if (!set) {
228
+ set = new Set;
229
+ subscribers.set(namespaceKey, set);
230
+ }
231
+ set.add(controller);
232
+ controller.enqueue(encoder.encode(`: connected
233
+
234
+ `));
235
+ },
236
+ cancel() {
237
+ const set = subscribers.get(namespaceKey);
238
+ if (set && registered) {
239
+ set.delete(registered);
240
+ if (set.size === 0)
241
+ subscribers.delete(namespaceKey);
242
+ }
243
+ }
244
+ });
245
+ return new Response(body, {
246
+ headers: {
247
+ "content-type": "text/event-stream",
248
+ "cache-control": "no-cache",
249
+ connection: "keep-alive"
250
+ }
251
+ });
252
+ }
253
+ return notFound();
254
+ } catch (error) {
255
+ return badRequest(error);
256
+ }
257
+ }
258
+ });
259
+ const boundPort = Number(server.url.port);
260
+ return {
261
+ port: boundPort,
262
+ stop() {
263
+ clearInterval(keepalive);
264
+ for (const subs of subscribers.values()) {
265
+ for (const controller of subs) {
266
+ try {
267
+ controller.close();
268
+ } catch {}
269
+ }
270
+ }
271
+ subscribers.clear();
272
+ server.stop(true);
273
+ }
274
+ };
275
+ }
276
+ export {
277
+ startRegistryServer
278
+ };
@@ -0,0 +1,3 @@
1
+ import type { RegistryEntry } from "./schema";
2
+ export declare const LIVE_STALE_AFTER_MS = 45000;
3
+ export declare function computeStale(entry: Pick<RegistryEntry, "heartbeatAt">, now?: number): boolean;
@@ -0,0 +1,11 @@
1
+ // @bun
2
+ // packages/relay-registry/src/staleness.ts
3
+ var LIVE_STALE_AFTER_MS = 45000;
4
+ function computeStale(entry, now = Date.now()) {
5
+ const heartbeat = Date.parse(entry.heartbeatAt);
6
+ return !(Number.isFinite(heartbeat) && now - heartbeat <= LIVE_STALE_AFTER_MS);
7
+ }
8
+ export {
9
+ computeStale,
10
+ LIVE_STALE_AFTER_MS
11
+ };
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "@h-rig/relay-registry",
3
+ "version": "0.0.6-alpha.92",
4
+ "type": "module",
5
+ "description": "Rig package",
6
+ "license": "UNLICENSED",
7
+ "files": [
8
+ "dist",
9
+ "README.md"
10
+ ],
11
+ "exports": {
12
+ ".": {
13
+ "types": "./dist/src/client.d.ts",
14
+ "import": "./dist/src/client.js"
15
+ },
16
+ "./auth": {
17
+ "types": "./dist/src/auth.d.ts",
18
+ "import": "./dist/src/auth.js"
19
+ },
20
+ "./client": {
21
+ "types": "./dist/src/client.d.ts",
22
+ "import": "./dist/src/client.js"
23
+ },
24
+ "./schema": {
25
+ "types": "./dist/src/schema.d.ts",
26
+ "import": "./dist/src/schema.js"
27
+ },
28
+ "./server": {
29
+ "types": "./dist/src/server.d.ts",
30
+ "import": "./dist/src/server.js"
31
+ },
32
+ "./staleness": {
33
+ "types": "./dist/src/staleness.d.ts",
34
+ "import": "./dist/src/staleness.js"
35
+ },
36
+ "./package.json": "./package.json"
37
+ },
38
+ "engines": {
39
+ "bun": ">=1.3.11"
40
+ },
41
+ "main": "./dist/src/client.js",
42
+ "dependencies": {
43
+ "@rig/contracts": "npm:@h-rig/contracts@0.0.6-alpha.92"
44
+ }
45
+ }