@liebstoeckel/present-relay 0.3.5
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 +373 -0
- package/README.md +84 -0
- package/package.json +58 -0
- package/src/addressing.ts +37 -0
- package/src/auth.ts +28 -0
- package/src/cli.ts +111 -0
- package/src/grant.ts +63 -0
- package/src/index.ts +3 -0
- package/src/metrics.ts +152 -0
- package/src/relay-server.ts +546 -0
- package/src/tracing.ts +86 -0
package/src/cli.ts
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { defineCommand, runMain } from "citty";
|
|
3
|
+
import { S3Client } from "bun";
|
|
4
|
+
import { createRelay, type RelayStorage } from "./relay-server";
|
|
5
|
+
import { relayPublicBaseFromPod } from "./addressing";
|
|
6
|
+
import { initTracing } from "./tracing";
|
|
7
|
+
|
|
8
|
+
/** Object storage for session snapshots (ADR 0061), wired from S3_* env when present.
|
|
9
|
+
* Absent → the relay runs without persistence (transient/CLI use). */
|
|
10
|
+
function s3Storage(): RelayStorage | undefined {
|
|
11
|
+
const endpoint = process.env.S3_ENDPOINT;
|
|
12
|
+
const accessKeyId = process.env.S3_ACCESS_KEY_ID;
|
|
13
|
+
const secretAccessKey = process.env.S3_SECRET_ACCESS_KEY;
|
|
14
|
+
if (!endpoint || !accessKeyId || !secretAccessKey) return undefined;
|
|
15
|
+
const client = new S3Client({
|
|
16
|
+
endpoint,
|
|
17
|
+
accessKeyId,
|
|
18
|
+
secretAccessKey,
|
|
19
|
+
bucket: process.env.S3_BUCKET ?? "decks",
|
|
20
|
+
region: process.env.S3_REGION ?? "us-east-1",
|
|
21
|
+
});
|
|
22
|
+
return {
|
|
23
|
+
async get(key) {
|
|
24
|
+
const f = client.file(key);
|
|
25
|
+
return (await f.exists()) ? new Uint8Array(await f.arrayBuffer()) : null;
|
|
26
|
+
},
|
|
27
|
+
async put(key, bytes) {
|
|
28
|
+
await client.write(key, bytes);
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const hex = (bytes = 24): string => {
|
|
34
|
+
const a = new Uint8Array(bytes);
|
|
35
|
+
crypto.getRandomValues(a);
|
|
36
|
+
return Array.from(a, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export const relayCommand = defineCommand({
|
|
40
|
+
meta: {
|
|
41
|
+
name: "relay",
|
|
42
|
+
description: "run a public relay (host live sessions for remote audiences)",
|
|
43
|
+
},
|
|
44
|
+
args: {
|
|
45
|
+
port: {
|
|
46
|
+
type: "string",
|
|
47
|
+
description: "listen port (or PORT env); auto if omitted",
|
|
48
|
+
valueHint: "N",
|
|
49
|
+
},
|
|
50
|
+
tokens: {
|
|
51
|
+
type: "string",
|
|
52
|
+
description: "comma-separated account tokens (or PRESENT_RELAY_TOKENS); one is generated if omitted",
|
|
53
|
+
},
|
|
54
|
+
"public-url": {
|
|
55
|
+
type: "string",
|
|
56
|
+
description: "public https origin so links/WebSocket use wss:// (or PRESENT_RELAY_PUBLIC_URL)",
|
|
57
|
+
valueHint: "https://…",
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
run({ args }) {
|
|
61
|
+
initTracing("present-relay"); // gated by OTEL_EXPORTER_OTLP_ENDPOINT (ADR 0073 step 3b)
|
|
62
|
+
const port = Number(args.port ?? process.env.PORT ?? 0) || 0;
|
|
63
|
+
// Public base: an explicit override wins (CLI / single-pod env); otherwise a
|
|
64
|
+
// StatefulSet pod derives its OWN per-pod base from its ordinal + host template
|
|
65
|
+
// (ADR 0071 §3 / ticket 0016, POD_NAME via the downward API).
|
|
66
|
+
const publicBaseUrl =
|
|
67
|
+
args["public-url"] ??
|
|
68
|
+
process.env.PRESENT_RELAY_PUBLIC_URL ??
|
|
69
|
+
relayPublicBaseFromPod(process.env.POD_NAME, process.env.PRESENT_RELAY_HOST_TEMPLATE);
|
|
70
|
+
let tokens = (args.tokens ?? process.env.PRESENT_RELAY_TOKENS ?? "")
|
|
71
|
+
.split(",")
|
|
72
|
+
.map((s) => s.trim())
|
|
73
|
+
.filter(Boolean);
|
|
74
|
+
|
|
75
|
+
let generated = false;
|
|
76
|
+
if (!tokens.length) {
|
|
77
|
+
tokens = [hex()];
|
|
78
|
+
generated = true;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const storage = s3Storage();
|
|
82
|
+
const relay = createRelay({ accountTokens: tokens, port, publicBaseUrl, storage });
|
|
83
|
+
const base = publicBaseUrl?.replace(/\/$/, "") ?? `http://localhost:${relay.port}`;
|
|
84
|
+
|
|
85
|
+
console.log(`\n▶ liebstoeckel relay listening on :${relay.port}`);
|
|
86
|
+
console.log(` base url ${base}`);
|
|
87
|
+
console.log(` health ${base}/healthz`);
|
|
88
|
+
if (generated) {
|
|
89
|
+
console.log(`\n ⚠ no account tokens set, generated one for this run:`);
|
|
90
|
+
console.log(` ${tokens[0]}`);
|
|
91
|
+
console.log(` persist with PRESENT_RELAY_TOKENS=tok1,tok2 (or --tokens).`);
|
|
92
|
+
}
|
|
93
|
+
console.log(`\n run a deck through it:`);
|
|
94
|
+
console.log(` bunx liebstoeckel live <deck> --relay ${base} --relay-token <token>\n`);
|
|
95
|
+
if (!publicBaseUrl) {
|
|
96
|
+
console.log(` note: serve behind TLS (wss://) for public use; set --public-url to the https origin.\n`);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Await the snapshot flush before exiting so a rolling deploy / drain doesn't lose
|
|
100
|
+
// the last interval's live state (ADR 0071 §5 / ticket 0018). k8s gives us
|
|
101
|
+
// terminationGracePeriodSeconds (set on the StatefulSet) to finish.
|
|
102
|
+
const shutdown = async () => {
|
|
103
|
+
await relay.stop();
|
|
104
|
+
process.exit(0);
|
|
105
|
+
};
|
|
106
|
+
process.on("SIGINT", () => void shutdown());
|
|
107
|
+
process.on("SIGTERM", () => void shutdown());
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
if (import.meta.main) void runMain(relayCommand);
|
package/src/grant.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
// Signed control↔relay grants (ADR 0061). For hosted live presenting the control
|
|
4
|
+
// plane owns the session and authorizes relay actions by minting a short-lived,
|
|
5
|
+
// HMAC-signed grant over a shared secret; the relay verifies it **statelessly**, it
|
|
6
|
+
// never calls back to the control plane per connection. Same trust class as the
|
|
7
|
+
// static relay account token (auth.ts), but scoped to one session + role + expiry.
|
|
8
|
+
|
|
9
|
+
export interface Grant {
|
|
10
|
+
/** the live session this grant is scoped to (the relay's session id / audience slug). */
|
|
11
|
+
session: string;
|
|
12
|
+
/** the role/action this grant confers, e.g. "presenter" | "runner" | "audience"
|
|
13
|
+
* for a peer connection, or "create" to mint a session. Interpreted by the relay. */
|
|
14
|
+
role: string;
|
|
15
|
+
/** optional capability scopes, reserved for finer-grained authorization. */
|
|
16
|
+
scopes?: string[];
|
|
17
|
+
/** expiry, unix epoch milliseconds. */
|
|
18
|
+
exp: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const b64url = (b: Buffer): string => b.toString("base64url");
|
|
22
|
+
const sign = (data: string, secret: string): Buffer => createHmac("sha256", secret).update(data).digest();
|
|
23
|
+
|
|
24
|
+
/** Mint a signed grant: `<base64url(payload)>.<base64url(hmacSHA256)>`. */
|
|
25
|
+
export function mintGrant(grant: Grant, secret: string): string {
|
|
26
|
+
const payload = b64url(Buffer.from(JSON.stringify(grant), "utf8"));
|
|
27
|
+
return `${payload}.${b64url(sign(payload, secret))}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Verify and decode a grant. Returns the payload, or null if it is malformed,
|
|
32
|
+
* tampered, signed with the wrong secret, or expired (`exp <= now`, unix ms). The
|
|
33
|
+
* signature compare is constant-time. Fails closed on any error.
|
|
34
|
+
*/
|
|
35
|
+
export function verifyGrant(token: string | null | undefined, secret: string, now: number): Grant | null {
|
|
36
|
+
if (!token) return null;
|
|
37
|
+
const dot = token.indexOf(".");
|
|
38
|
+
if (dot <= 0 || dot === token.length - 1) return null;
|
|
39
|
+
const payload = token.slice(0, dot);
|
|
40
|
+
const mac = token.slice(dot + 1);
|
|
41
|
+
|
|
42
|
+
let expected: string;
|
|
43
|
+
try {
|
|
44
|
+
expected = b64url(sign(payload, secret));
|
|
45
|
+
} catch {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
const a = Buffer.from(mac, "utf8");
|
|
49
|
+
const b = Buffer.from(expected, "utf8");
|
|
50
|
+
// Length mismatch short-circuits (the mac's length isn't the secret); equal-length
|
|
51
|
+
// inputs are compared without early-out.
|
|
52
|
+
if (a.length !== b.length || !timingSafeEqual(a, b)) return null;
|
|
53
|
+
|
|
54
|
+
let grant: Grant;
|
|
55
|
+
try {
|
|
56
|
+
grant = JSON.parse(Buffer.from(payload, "base64url").toString("utf8")) as Grant;
|
|
57
|
+
} catch {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
if (!grant || typeof grant.session !== "string" || typeof grant.role !== "string") return null;
|
|
61
|
+
if (typeof grant.exp !== "number" || grant.exp <= now) return null;
|
|
62
|
+
return grant;
|
|
63
|
+
}
|
package/src/index.ts
ADDED
package/src/metrics.ts
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// Minimal Prometheus / OpenMetrics text-exposition registry + the relay's metric set
|
|
2
|
+
// (ADR 0073 / ticket 0023). Pure, dependency-free, unit-testable. Intentionally NOT a shared
|
|
3
|
+
// package: present-relay is OSS-published, so a shared `@liebstoeckel/metrics` would force the
|
|
4
|
+
// five-place OSS lock-step for ~80 lines, the registry is duplicated in control-core instead.
|
|
5
|
+
|
|
6
|
+
type Labels = Record<string, string>;
|
|
7
|
+
|
|
8
|
+
const sortedKeys = (l: Labels): string[] => Object.keys(l).sort();
|
|
9
|
+
const labelId = (l: Labels): string => sortedKeys(l).map((k) => `${k}=${l[k]}`).join(",");
|
|
10
|
+
const esc = (v: string): string => v.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/"/g, '\\"');
|
|
11
|
+
const fmtLabels = (l: Labels): string => {
|
|
12
|
+
const ks = sortedKeys(l);
|
|
13
|
+
return ks.length ? "{" + ks.map((k) => `${k}="${esc(l[k]!)}"`).join(",") + "}" : "";
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
abstract class Metric {
|
|
17
|
+
constructor(
|
|
18
|
+
readonly name: string,
|
|
19
|
+
readonly help: string,
|
|
20
|
+
readonly type: "counter" | "gauge" | "histogram",
|
|
21
|
+
) {}
|
|
22
|
+
abstract rows(): string[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export class Counter extends Metric {
|
|
26
|
+
private v = new Map<string, { labels: Labels; n: number }>();
|
|
27
|
+
constructor(name: string, help: string) {
|
|
28
|
+
super(name, help, "counter");
|
|
29
|
+
}
|
|
30
|
+
inc(labels: Labels = {}, by = 1): void {
|
|
31
|
+
const k = labelId(labels);
|
|
32
|
+
const e = this.v.get(k);
|
|
33
|
+
if (e) e.n += by;
|
|
34
|
+
else this.v.set(k, { labels, n: by });
|
|
35
|
+
}
|
|
36
|
+
rows(): string[] {
|
|
37
|
+
return [...this.v.values()].map((e) => `${this.name}${fmtLabels(e.labels)} ${e.n}`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export class Gauge extends Metric {
|
|
42
|
+
private v = new Map<string, { labels: Labels; n: number }>();
|
|
43
|
+
constructor(name: string, help: string) {
|
|
44
|
+
super(name, help, "gauge");
|
|
45
|
+
}
|
|
46
|
+
set(n: number, labels: Labels = {}): void {
|
|
47
|
+
this.v.set(labelId(labels), { labels, n });
|
|
48
|
+
}
|
|
49
|
+
inc(labels: Labels = {}, by = 1): void {
|
|
50
|
+
const k = labelId(labels);
|
|
51
|
+
const e = this.v.get(k);
|
|
52
|
+
if (e) e.n += by;
|
|
53
|
+
else this.v.set(k, { labels, n: by });
|
|
54
|
+
}
|
|
55
|
+
dec(labels: Labels = {}, by = 1): void {
|
|
56
|
+
this.inc(labels, -by);
|
|
57
|
+
}
|
|
58
|
+
rows(): string[] {
|
|
59
|
+
return [...this.v.values()].map((e) => `${this.name}${fmtLabels(e.labels)} ${e.n}`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const DEFAULT_BUCKETS = [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10];
|
|
64
|
+
|
|
65
|
+
export class Histogram extends Metric {
|
|
66
|
+
private v = new Map<string, { labels: Labels; counts: number[]; sum: number; total: number }>();
|
|
67
|
+
constructor(
|
|
68
|
+
name: string,
|
|
69
|
+
help: string,
|
|
70
|
+
private buckets: number[] = DEFAULT_BUCKETS,
|
|
71
|
+
) {
|
|
72
|
+
super(name, help, "histogram");
|
|
73
|
+
}
|
|
74
|
+
observe(value: number, labels: Labels = {}): void {
|
|
75
|
+
const k = labelId(labels);
|
|
76
|
+
let e = this.v.get(k);
|
|
77
|
+
if (!e) {
|
|
78
|
+
e = { labels, counts: new Array(this.buckets.length).fill(0), sum: 0, total: 0 };
|
|
79
|
+
this.v.set(k, e);
|
|
80
|
+
}
|
|
81
|
+
e.sum += value;
|
|
82
|
+
e.total++;
|
|
83
|
+
for (let i = 0; i < this.buckets.length; i++) if (value <= this.buckets[i]!) e.counts[i]!++;
|
|
84
|
+
}
|
|
85
|
+
rows(): string[] {
|
|
86
|
+
const out: string[] = [];
|
|
87
|
+
for (const e of this.v.values()) {
|
|
88
|
+
// `counts[i]` already holds the cumulative count of observations ≤ buckets[i]
|
|
89
|
+
// (observe() increments every bucket the value falls under), so emit it directly.
|
|
90
|
+
for (let i = 0; i < this.buckets.length; i++) {
|
|
91
|
+
out.push(`${this.name}_bucket${fmtLabels({ ...e.labels, le: String(this.buckets[i]) })} ${e.counts[i]}`);
|
|
92
|
+
}
|
|
93
|
+
out.push(`${this.name}_bucket${fmtLabels({ ...e.labels, le: "+Inf" })} ${e.total}`);
|
|
94
|
+
out.push(`${this.name}_sum${fmtLabels(e.labels)} ${e.sum}`);
|
|
95
|
+
out.push(`${this.name}_count${fmtLabels(e.labels)} ${e.total}`);
|
|
96
|
+
}
|
|
97
|
+
return out;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export class Registry {
|
|
102
|
+
private metrics: Metric[] = [];
|
|
103
|
+
private collectors: Array<() => void> = [];
|
|
104
|
+
register<T extends Metric>(m: T): T {
|
|
105
|
+
this.metrics.push(m);
|
|
106
|
+
return m;
|
|
107
|
+
}
|
|
108
|
+
/** Refresh callbacks run at scrape time, before render, for gauges read from live state. */
|
|
109
|
+
onCollect(fn: () => void): void {
|
|
110
|
+
this.collectors.push(fn);
|
|
111
|
+
}
|
|
112
|
+
render(): string {
|
|
113
|
+
for (const fn of this.collectors) fn();
|
|
114
|
+
const lines: string[] = [];
|
|
115
|
+
for (const m of this.metrics) {
|
|
116
|
+
const rows = m.rows();
|
|
117
|
+
if (rows.length === 0) continue; // omit metrics with no samples yet
|
|
118
|
+
lines.push(`# HELP ${m.name} ${m.help}`, `# TYPE ${m.name} ${m.type}`, ...rows);
|
|
119
|
+
}
|
|
120
|
+
return lines.join("\n") + "\n";
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** The relay's metric set (ADR 0073). One registry per relay instance (test-friendly). */
|
|
125
|
+
export function createRelayMetrics(version = "unknown") {
|
|
126
|
+
const r = new Registry();
|
|
127
|
+
const m = {
|
|
128
|
+
registry: r,
|
|
129
|
+
sessions: r.register(new Gauge("liebstoeckel_relay_sessions", "Active live sessions on this pod")),
|
|
130
|
+
audiencePeers: r.register(new Gauge("liebstoeckel_relay_audience_peers", "Connected audience peers on this pod")),
|
|
131
|
+
deckBytes: r.register(new Gauge("liebstoeckel_relay_deck_bytes", "In-memory deck HTML bytes held on this pod")),
|
|
132
|
+
cordoned: r.register(new Gauge("liebstoeckel_relay_cordoned", "1 if this pod is cordoned (draining)")),
|
|
133
|
+
startedAt: r.register(new Gauge("liebstoeckel_relay_started_at_seconds", "Process start time (unix seconds)")),
|
|
134
|
+
sessionCreates: r.register(new Counter("liebstoeckel_relay_session_creates_total", "Sessions provisioned")),
|
|
135
|
+
sessionRejects: r.register(new Counter("liebstoeckel_relay_session_rejects_total", "Session creates rejected, by reason")),
|
|
136
|
+
snapshotWrites: r.register(new Counter("liebstoeckel_relay_snapshot_writes_total", "Snapshot persist attempts")),
|
|
137
|
+
snapshotFailures: r.register(new Counter("liebstoeckel_relay_snapshot_failures_total", "Snapshot persist failures")),
|
|
138
|
+
snapshotSeed: r.register(new Counter("liebstoeckel_relay_snapshot_seed_total", "Snapshot re-seed on create, by result")),
|
|
139
|
+
wsOpens: r.register(new Counter("liebstoeckel_relay_ws_opens_total", "WebSocket opens, by role")),
|
|
140
|
+
wsCloses: r.register(new Counter("liebstoeckel_relay_ws_closes_total", "WebSocket closes, by role")),
|
|
141
|
+
wsConnections: r.register(new Gauge("liebstoeckel_relay_ws_connections", "Open WebSocket connections, by role")),
|
|
142
|
+
audienceCapRejects: r.register(new Counter("liebstoeckel_relay_audience_cap_rejections_total", "WS rejected: audience cap reached")),
|
|
143
|
+
grantDenials: r.register(new Counter("liebstoeckel_relay_grant_denials_total", "Deck/sync requests denied (bad/expired grant)")),
|
|
144
|
+
wsFrames: r.register(new Counter("liebstoeckel_relay_ws_frames_total", "Yjs WS frames, by direction")),
|
|
145
|
+
wsBytes: r.register(new Counter("liebstoeckel_relay_ws_bytes_total", "Yjs WS bytes, by direction")),
|
|
146
|
+
buildInfo: r.register(new Gauge("liebstoeckel_relay_build_info", "Relay build info (constant 1)")),
|
|
147
|
+
};
|
|
148
|
+
m.buildInfo.set(1, { version });
|
|
149
|
+
return m;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export type RelayMetrics = ReturnType<typeof createRelayMetrics>;
|