@telorun/runner-core 0.5.2

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.
Files changed (109) hide show
  1. package/LICENSE +17 -0
  2. package/README.md +38 -0
  3. package/dist/backend.d.ts +71 -0
  4. package/dist/backend.d.ts.map +1 -0
  5. package/dist/backend.js +2 -0
  6. package/dist/backend.js.map +1 -0
  7. package/dist/base-image-catalog.d.ts +110 -0
  8. package/dist/base-image-catalog.d.ts.map +1 -0
  9. package/dist/base-image-catalog.js +245 -0
  10. package/dist/base-image-catalog.js.map +1 -0
  11. package/dist/capabilities-schema.d.ts +33 -0
  12. package/dist/capabilities-schema.d.ts.map +1 -0
  13. package/dist/capabilities-schema.js +44 -0
  14. package/dist/capabilities-schema.js.map +1 -0
  15. package/dist/config.d.ts +37 -0
  16. package/dist/config.d.ts.map +1 -0
  17. package/dist/config.js +93 -0
  18. package/dist/config.js.map +1 -0
  19. package/dist/contract.d.ts +170 -0
  20. package/dist/contract.d.ts.map +1 -0
  21. package/dist/contract.js +24 -0
  22. package/dist/contract.js.map +1 -0
  23. package/dist/debug/relay.d.ts +25 -0
  24. package/dist/debug/relay.d.ts.map +1 -0
  25. package/dist/debug/relay.js +89 -0
  26. package/dist/debug/relay.js.map +1 -0
  27. package/dist/dependency-key.d.ts +38 -0
  28. package/dist/dependency-key.d.ts.map +1 -0
  29. package/dist/dependency-key.js +68 -0
  30. package/dist/dependency-key.js.map +1 -0
  31. package/dist/index.d.ts +20 -0
  32. package/dist/index.d.ts.map +1 -0
  33. package/dist/index.js +19 -0
  34. package/dist/index.js.map +1 -0
  35. package/dist/routes/capabilities.d.ts +12 -0
  36. package/dist/routes/capabilities.d.ts.map +1 -0
  37. package/dist/routes/capabilities.js +15 -0
  38. package/dist/routes/capabilities.js.map +1 -0
  39. package/dist/routes/health.d.ts +5 -0
  40. package/dist/routes/health.d.ts.map +1 -0
  41. package/dist/routes/health.js +8 -0
  42. package/dist/routes/health.js.map +1 -0
  43. package/dist/routes/io.d.ts +8 -0
  44. package/dist/routes/io.d.ts.map +1 -0
  45. package/dist/routes/io.js +239 -0
  46. package/dist/routes/io.js.map +1 -0
  47. package/dist/routes/probe.d.ts +7 -0
  48. package/dist/routes/probe.d.ts.map +1 -0
  49. package/dist/routes/probe.js +22 -0
  50. package/dist/routes/probe.js.map +1 -0
  51. package/dist/routes/sessions.d.ts +22 -0
  52. package/dist/routes/sessions.d.ts.map +1 -0
  53. package/dist/routes/sessions.js +223 -0
  54. package/dist/routes/sessions.js.map +1 -0
  55. package/dist/server.d.ts +34 -0
  56. package/dist/server.d.ts.map +1 -0
  57. package/dist/server.js +67 -0
  58. package/dist/server.js.map +1 -0
  59. package/dist/session/bundle-path.d.ts +12 -0
  60. package/dist/session/bundle-path.d.ts.map +1 -0
  61. package/dist/session/bundle-path.js +27 -0
  62. package/dist/session/bundle-path.js.map +1 -0
  63. package/dist/session/byte-ring-buffer.d.ts +29 -0
  64. package/dist/session/byte-ring-buffer.d.ts.map +1 -0
  65. package/dist/session/byte-ring-buffer.js +54 -0
  66. package/dist/session/byte-ring-buffer.js.map +1 -0
  67. package/dist/session/registry.d.ts +62 -0
  68. package/dist/session/registry.d.ts.map +1 -0
  69. package/dist/session/registry.js +156 -0
  70. package/dist/session/registry.js.map +1 -0
  71. package/dist/session/ring-buffer.d.ts +37 -0
  72. package/dist/session/ring-buffer.d.ts.map +1 -0
  73. package/dist/session/ring-buffer.js +64 -0
  74. package/dist/session/ring-buffer.js.map +1 -0
  75. package/dist/session/session-id.d.ts +3 -0
  76. package/dist/session/session-id.d.ts.map +1 -0
  77. package/dist/session/session-id.js +19 -0
  78. package/dist/session/session-id.js.map +1 -0
  79. package/dist/sse/channel.d.ts +11 -0
  80. package/dist/sse/channel.d.ts.map +1 -0
  81. package/dist/sse/channel.js +129 -0
  82. package/dist/sse/channel.js.map +1 -0
  83. package/package.json +48 -0
  84. package/src/backend.ts +88 -0
  85. package/src/base-image-catalog.test.ts +209 -0
  86. package/src/base-image-catalog.ts +320 -0
  87. package/src/capabilities-schema.test.ts +54 -0
  88. package/src/capabilities-schema.ts +71 -0
  89. package/src/config.ts +122 -0
  90. package/src/contract.ts +170 -0
  91. package/src/debug/relay.ts +104 -0
  92. package/src/dependency-key.test.ts +64 -0
  93. package/src/dependency-key.ts +105 -0
  94. package/src/index.ts +33 -0
  95. package/src/routes/capabilities.ts +20 -0
  96. package/src/routes/health.ts +9 -0
  97. package/src/routes/io.ts +265 -0
  98. package/src/routes/probe.ts +35 -0
  99. package/src/routes/sessions.ts +270 -0
  100. package/src/server.ts +108 -0
  101. package/src/session/bundle-path.ts +27 -0
  102. package/src/session/byte-ring-buffer.ts +62 -0
  103. package/src/session/registry.test.ts +34 -0
  104. package/src/session/registry.ts +185 -0
  105. package/src/session/ring-buffer.test.ts +54 -0
  106. package/src/session/ring-buffer.ts +75 -0
  107. package/src/session/session-id.test.ts +17 -0
  108. package/src/session/session-id.ts +20 -0
  109. package/src/sse/channel.ts +154 -0
@@ -0,0 +1,27 @@
1
+ import { posix } from "node:path";
2
+
3
+ export class BundlePathError extends Error {}
4
+
5
+ /**
6
+ * Bundle file paths arrive POSIX-style and untrusted. Guard against traversal
7
+ * explicitly rather than trusting `path.resolve` — an `entryRelativePath` or
8
+ * `files[].relativePath` of `../foo` would escape the session's own directory.
9
+ * Backend-neutral: every backend normalizes paths the same way before placing
10
+ * files, regardless of how it ultimately delivers the bundle.
11
+ */
12
+ export function normalizeBundlePath(p: string): string {
13
+ const normalized = posix.normalize(p).replace(/^\/+/, "");
14
+ if (normalized === "" || normalized === "." || normalized.startsWith("../") || normalized === "..") {
15
+ throw new BundlePathError(`invalid bundle relativePath '${p}'`);
16
+ }
17
+ for (const part of normalized.split("/")) {
18
+ if (part === "..") throw new BundlePathError(`invalid bundle relativePath '${p}'`);
19
+ }
20
+ return normalized;
21
+ }
22
+
23
+ export function validateSessionId(id: string): void {
24
+ if (!/^[a-zA-Z0-9_-]+$/.test(id)) {
25
+ throw new BundlePathError(`invalid sessionId '${id}' (must match /^[a-zA-Z0-9_-]+$/)`);
26
+ }
27
+ }
@@ -0,0 +1,62 @@
1
+ export interface BufferedBytes {
2
+ seq: number;
3
+ bytes: Buffer;
4
+ }
5
+
6
+ /**
7
+ * Byte-capped FIFO ring buffer for raw PTY output replay. Mirrors
8
+ * EventRingBuffer's eviction-with-gap semantics but stores Buffer slices keyed
9
+ * by a monotonic seq number — never reused, never reset.
10
+ *
11
+ * A client's `?lastSeq=<n>` asks for chunks with seq > n; if the oldest
12
+ * remaining seq is still > n+1, the gap must be signaled separately.
13
+ */
14
+ export class ByteRingBuffer {
15
+ private readonly entries: BufferedBytes[] = [];
16
+ private totalBytes = 0;
17
+ private nextSeq = 1;
18
+
19
+ constructor(private readonly maxBytes: number) {
20
+ if (!Number.isInteger(maxBytes) || maxBytes <= 0) {
21
+ throw new Error(`ByteRingBuffer maxBytes must be a positive integer, got ${maxBytes}`);
22
+ }
23
+ }
24
+
25
+ push(bytes: Buffer): BufferedBytes {
26
+ const seq = this.nextSeq++;
27
+ const entry: BufferedBytes = { seq, bytes };
28
+ this.entries.push(entry);
29
+ this.totalBytes += bytes.byteLength;
30
+ this.evict();
31
+ return entry;
32
+ }
33
+
34
+ private evict(): void {
35
+ // Always retain at least the most-recent entry, even if it alone exceeds
36
+ // maxBytes — same invariant as EventRingBuffer.
37
+ while (this.totalBytes > this.maxBytes && this.entries.length > 1) {
38
+ const dropped = this.entries.shift();
39
+ if (!dropped) break;
40
+ this.totalBytes -= dropped.bytes.byteLength;
41
+ }
42
+ }
43
+
44
+ replay(afterSeq: number): { entries: BufferedBytes[]; hasGap: boolean } {
45
+ const entries = this.entries.filter((e) => e.seq > afterSeq);
46
+ const oldestSeq = this.entries[0]?.seq ?? this.nextSeq;
47
+ const hasGap = afterSeq + 1 < oldestSeq;
48
+ return { entries, hasGap };
49
+ }
50
+
51
+ get size(): number {
52
+ return this.entries.length;
53
+ }
54
+
55
+ get bytes(): number {
56
+ return this.totalBytes;
57
+ }
58
+
59
+ get latestSeq(): number {
60
+ return this.nextSeq - 1;
61
+ }
62
+ }
@@ -0,0 +1,34 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { SessionLimitError, SessionRegistry } from "./registry.js";
4
+
5
+ const deps = { maxSessions: 2, exitTtlMs: 60_000, replayBufferBytes: 10_000 };
6
+
7
+ function markExited(reg: SessionRegistry, sessionId: string): void {
8
+ reg.emit(sessionId, { type: "status", status: { kind: "exited", code: 0 } });
9
+ }
10
+
11
+ describe("SessionRegistry capacity", () => {
12
+ it("evicts the oldest terminal session to admit a new run at capacity", () => {
13
+ const reg = new SessionRegistry(deps);
14
+ reg.register({ sessionId: "a" });
15
+ reg.register({ sessionId: "b" });
16
+ markExited(reg, "a");
17
+
18
+ // At capacity (2). The retained exited session yields to the new run.
19
+ reg.register({ sessionId: "c" });
20
+
21
+ expect(reg.has("a")).toBe(false);
22
+ expect(reg.has("b")).toBe(true);
23
+ expect(reg.has("c")).toBe(true);
24
+ });
25
+
26
+ it("rejects a new run when every session is still live", () => {
27
+ const reg = new SessionRegistry(deps);
28
+ reg.register({ sessionId: "a" });
29
+ reg.register({ sessionId: "b" });
30
+
31
+ expect(() => reg.register({ sessionId: "c" })).toThrow(SessionLimitError);
32
+ expect(reg.size()).toBe(2);
33
+ });
34
+ });
@@ -0,0 +1,185 @@
1
+ import { EventEmitter } from "node:events";
2
+
3
+ import type { BackendSession } from "../backend.js";
4
+ import { isTerminal, type RunEvent, type RunStatus } from "../contract.js";
5
+ import { ByteRingBuffer, type BufferedBytes } from "./byte-ring-buffer.js";
6
+ import { EventRingBuffer, type BufferedEvent } from "./ring-buffer.js";
7
+
8
+ export interface SessionEntry {
9
+ readonly sessionId: string;
10
+ readonly createdAt: Date;
11
+ readonly buffer: EventRingBuffer;
12
+ readonly byteBuffer: ByteRingBuffer;
13
+ readonly emitter: EventEmitter;
14
+ readonly byteEmitter: EventEmitter;
15
+
16
+ /** The live backend workload. Null until `start` resolves; the route writes
17
+ * stdin / resize / stop through it. A backend's `writeStdin` is a no-op once
18
+ * the workload has terminated, so callers need not null it on exit. */
19
+ session: BackendSession | null;
20
+ status: RunStatus;
21
+ exitedAt: Date | null;
22
+ userStopped: boolean;
23
+ evictionTimer: NodeJS.Timeout | null;
24
+ }
25
+
26
+ export interface RegistryDeps {
27
+ maxSessions: number;
28
+ exitTtlMs: number;
29
+ replayBufferBytes: number;
30
+ }
31
+
32
+ const EVENT_EMITTED = "event";
33
+ const BYTES_EMITTED = "chunk";
34
+
35
+ /** Cap on a single buffered byte chunk. Without this, one huge workload
36
+ * burst (`cat largefile`) could be admitted as one entry; the ring
37
+ * buffer's "retain at least one" invariant would then keep that one
38
+ * oversized entry resident regardless of `replayBufferBytes`. Splitting
39
+ * on push means the cap actually bounds memory and the eviction loop
40
+ * has fine-grained units to drop. */
41
+ const MAX_PUSH_CHUNK = 64 * 1024;
42
+
43
+ export class SessionLimitError extends Error {}
44
+ export class SessionEvictedError extends Error {}
45
+
46
+ export class SessionRegistry {
47
+ private readonly sessions = new Map<string, SessionEntry>();
48
+
49
+ constructor(private readonly deps: RegistryDeps) {}
50
+
51
+ size(): number {
52
+ return this.sessions.size;
53
+ }
54
+
55
+ has(sessionId: string): boolean {
56
+ return this.sessions.has(sessionId);
57
+ }
58
+
59
+ get(sessionId: string): SessionEntry | undefined {
60
+ return this.sessions.get(sessionId);
61
+ }
62
+
63
+ list(): SessionEntry[] {
64
+ return [...this.sessions.values()];
65
+ }
66
+
67
+ /**
68
+ * Creates a fresh registry entry. Callers are responsible for guarding against
69
+ * duplicate insertion. Throws SessionLimitError if we're at capacity.
70
+ */
71
+ register(args: { sessionId: string }): SessionEntry {
72
+ if (this.sessions.size >= this.deps.maxSessions && !this.evictOldestTerminal()) {
73
+ throw new SessionLimitError(
74
+ `runner is at its configured max of ${this.deps.maxSessions} concurrent sessions`,
75
+ );
76
+ }
77
+ const entry: SessionEntry = {
78
+ sessionId: args.sessionId,
79
+ createdAt: new Date(),
80
+ buffer: new EventRingBuffer(this.deps.replayBufferBytes),
81
+ byteBuffer: new ByteRingBuffer(this.deps.replayBufferBytes),
82
+ emitter: new EventEmitter(),
83
+ byteEmitter: new EventEmitter(),
84
+ session: null,
85
+ status: { kind: "starting" },
86
+ exitedAt: null,
87
+ userStopped: false,
88
+ evictionTimer: null,
89
+ };
90
+ // Many transient SSE / WS subscribers per session is normal — bump the
91
+ // default 10-listener warning to a high cap so the alarm still fires
92
+ // for a real listener leak. 256 is well above the realistic concurrent-
93
+ // tab count and well below "obviously a bug".
94
+ entry.emitter.setMaxListeners(256);
95
+ entry.byteEmitter.setMaxListeners(256);
96
+ this.sessions.set(args.sessionId, entry);
97
+ return entry;
98
+ }
99
+
100
+ pushBytes(sessionId: string, bytes: Buffer): BufferedBytes | undefined {
101
+ const entry = this.sessions.get(sessionId);
102
+ if (!entry) return undefined;
103
+ if (bytes.byteLength <= MAX_PUSH_CHUNK) {
104
+ const buffered = entry.byteBuffer.push(bytes);
105
+ entry.byteEmitter.emit(BYTES_EMITTED, buffered);
106
+ return buffered;
107
+ }
108
+ // Split the chunk into MAX_PUSH_CHUNK-sized slices, each getting its
109
+ // own seq. Returns the last buffered piece for the caller's bookkeeping.
110
+ let last: BufferedBytes | undefined;
111
+ for (let off = 0; off < bytes.byteLength; off += MAX_PUSH_CHUNK) {
112
+ const slice = bytes.subarray(off, Math.min(off + MAX_PUSH_CHUNK, bytes.byteLength));
113
+ // subarray shares memory with the parent buffer; copy so the ring's
114
+ // entry doesn't pin the original allocation past eviction.
115
+ last = entry.byteBuffer.push(Buffer.from(slice));
116
+ entry.byteEmitter.emit(BYTES_EMITTED, last);
117
+ }
118
+ return last;
119
+ }
120
+
121
+ subscribeBytes(sessionId: string, listener: (b: BufferedBytes) => void): () => void {
122
+ const entry = this.sessions.get(sessionId);
123
+ if (!entry) throw new SessionEvictedError(`session '${sessionId}' not in registry`);
124
+ entry.byteEmitter.on(BYTES_EMITTED, listener);
125
+ return () => entry.byteEmitter.off(BYTES_EMITTED, listener);
126
+ }
127
+
128
+ emit(sessionId: string, event: RunEvent): BufferedEvent | undefined {
129
+ const entry = this.sessions.get(sessionId);
130
+ if (!entry) return undefined;
131
+ const buffered = entry.buffer.push(event);
132
+ if (event.type === "status") {
133
+ entry.status = event.status;
134
+ if (isTerminal(event.status)) {
135
+ entry.exitedAt = new Date();
136
+ this.scheduleEviction(entry);
137
+ }
138
+ }
139
+ entry.emitter.emit(EVENT_EMITTED, buffered);
140
+ return buffered;
141
+ }
142
+
143
+ subscribe(sessionId: string, listener: (e: BufferedEvent) => void): () => void {
144
+ const entry = this.sessions.get(sessionId);
145
+ if (!entry) throw new SessionEvictedError(`session '${sessionId}' not in registry`);
146
+ entry.emitter.on(EVENT_EMITTED, listener);
147
+ return () => entry.emitter.off(EVENT_EMITTED, listener);
148
+ }
149
+
150
+ /** Free a slot at capacity by removing the oldest already-terminated session
151
+ * (by exit time). Retained exited sessions are history kept for re-attach, so
152
+ * they yield to a new run rather than blocking it; live sessions are never
153
+ * evicted. Returns false when every session is still live. */
154
+ private evictOldestTerminal(): boolean {
155
+ let oldest: SessionEntry | undefined;
156
+ for (const entry of this.sessions.values()) {
157
+ if (entry.exitedAt === null) continue;
158
+ if (!oldest || entry.exitedAt < oldest.exitedAt!) oldest = entry;
159
+ }
160
+ if (!oldest) return false;
161
+ return this.remove(oldest.sessionId);
162
+ }
163
+
164
+ private scheduleEviction(entry: SessionEntry): void {
165
+ if (entry.evictionTimer) return;
166
+ entry.evictionTimer = setTimeout(() => {
167
+ this.sessions.delete(entry.sessionId);
168
+ }, this.deps.exitTtlMs);
169
+ // Allow process exit even if evictions are pending — they are pure state,
170
+ // not work.
171
+ entry.evictionTimer.unref?.();
172
+ }
173
+
174
+ /**
175
+ * Remove an entry immediately (used by shutdown sweeps and startup cleanup).
176
+ * Returns true if it was present.
177
+ */
178
+ remove(sessionId: string): boolean {
179
+ const entry = this.sessions.get(sessionId);
180
+ if (!entry) return false;
181
+ if (entry.evictionTimer) clearTimeout(entry.evictionTimer);
182
+ this.sessions.delete(sessionId);
183
+ return true;
184
+ }
185
+ }
@@ -0,0 +1,54 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { ByteRingBuffer } from "./byte-ring-buffer.js";
4
+ import { EventRingBuffer } from "./ring-buffer.js";
5
+ import { normalizeBundlePath, validateSessionId, BundlePathError } from "./bundle-path.js";
6
+
7
+ describe("EventRingBuffer", () => {
8
+ it("assigns monotonic ids starting at 1 and replays after an id", () => {
9
+ const buf = new EventRingBuffer(1_000_000);
10
+ buf.push({ type: "stdout", chunk: "a" });
11
+ buf.push({ type: "stdout", chunk: "b" });
12
+ const { entries, hasGap } = buf.replay(1);
13
+ expect(entries.map((e) => e.id)).toEqual([2]);
14
+ expect(hasGap).toBe(false);
15
+ });
16
+
17
+ it("evicts oldest entries past the byte cap but always retains the last", () => {
18
+ const buf = new EventRingBuffer(50);
19
+ for (let i = 0; i < 20; i++) buf.push({ type: "stdout", chunk: "x".repeat(20) });
20
+ expect(buf.size).toBeGreaterThanOrEqual(1);
21
+ expect(buf.bytes).toBeLessThanOrEqual(50 + 40);
22
+ const { hasGap } = buf.replay(0);
23
+ expect(hasGap).toBe(true);
24
+ });
25
+ });
26
+
27
+ describe("ByteRingBuffer", () => {
28
+ it("replays chunks after a seq and flags gaps when evicted", () => {
29
+ const buf = new ByteRingBuffer(10);
30
+ buf.push(Buffer.from("aaaaa"));
31
+ buf.push(Buffer.from("bbbbb"));
32
+ buf.push(Buffer.from("ccccc"));
33
+ const { entries, hasGap } = buf.replay(0);
34
+ expect(entries.length).toBeGreaterThanOrEqual(1);
35
+ expect(hasGap).toBe(true);
36
+ });
37
+ });
38
+
39
+ describe("normalizeBundlePath", () => {
40
+ it("strips leading slashes and keeps nested paths", () => {
41
+ expect(normalizeBundlePath("/a/b.yaml")).toBe("a/b.yaml");
42
+ expect(normalizeBundlePath("a/b.yaml")).toBe("a/b.yaml");
43
+ });
44
+
45
+ it("rejects traversal", () => {
46
+ expect(() => normalizeBundlePath("../x")).toThrow(BundlePathError);
47
+ expect(() => normalizeBundlePath("a/../../x")).toThrow(BundlePathError);
48
+ });
49
+
50
+ it("validates sessionIds", () => {
51
+ expect(() => validateSessionId("ok-123_AB")).not.toThrow();
52
+ expect(() => validateSessionId("../bad")).toThrow(BundlePathError);
53
+ });
54
+ });
@@ -0,0 +1,75 @@
1
+ import type { RunEvent } from "../contract.js";
2
+
3
+ export interface BufferedEvent {
4
+ id: number;
5
+ event: RunEvent;
6
+ bytes: number;
7
+ }
8
+
9
+ /**
10
+ * Byte-capped FIFO ring buffer for SSE replay. Each entry tracks its
11
+ * serialized byte size; when a new entry would push total bytes past the cap,
12
+ * oldest entries are evicted until it fits. Entries are never split.
13
+ *
14
+ * Ids are assigned monotonically starting at 1 — never reused, never reset.
15
+ * A client's `Last-Event-ID: <n>` asks for events with id > n; if the oldest
16
+ * remaining id is still > n+1, the gap must be signaled separately.
17
+ */
18
+ export class EventRingBuffer {
19
+ private readonly entries: BufferedEvent[] = [];
20
+ private totalBytes = 0;
21
+ private nextId = 1;
22
+
23
+ constructor(private readonly maxBytes: number) {
24
+ if (!Number.isInteger(maxBytes) || maxBytes <= 0) {
25
+ throw new Error(`EventRingBuffer maxBytes must be a positive integer, got ${maxBytes}`);
26
+ }
27
+ }
28
+
29
+ push(event: RunEvent): BufferedEvent {
30
+ const id = this.nextId++;
31
+ const bytes = Buffer.byteLength(JSON.stringify(event), "utf8");
32
+ const entry: BufferedEvent = { id, event, bytes };
33
+ this.entries.push(entry);
34
+ this.totalBytes += bytes;
35
+ this.evict();
36
+ return entry;
37
+ }
38
+
39
+ private evict(): void {
40
+ // Invariant: always retain at least the most-recently-pushed entry, even
41
+ // if it alone exceeds maxBytes. A single oversized event (a huge log
42
+ // chunk) is still more useful to a reconnecting client than an empty
43
+ // buffer, and the alternative (drop-and-gap) would surface as a spurious
44
+ // "earlier output truncated" banner immediately after a big write.
45
+ while (this.totalBytes > this.maxBytes && this.entries.length > 1) {
46
+ const dropped = this.entries.shift();
47
+ if (!dropped) break;
48
+ this.totalBytes -= dropped.bytes;
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Returns entries with id > afterId, in insertion order.
54
+ * If `hasGap` is true, the caller should emit a `gap` marker before replay
55
+ * because events between afterId+1 and the first returned id have been evicted.
56
+ */
57
+ replay(afterId: number): { entries: BufferedEvent[]; hasGap: boolean } {
58
+ const entries = this.entries.filter((e) => e.id > afterId);
59
+ const oldestId = this.entries[0]?.id ?? this.nextId;
60
+ const hasGap = afterId + 1 < oldestId;
61
+ return { entries, hasGap };
62
+ }
63
+
64
+ get size(): number {
65
+ return this.entries.length;
66
+ }
67
+
68
+ get bytes(): number {
69
+ return this.totalBytes;
70
+ }
71
+
72
+ get latestId(): number {
73
+ return this.nextId - 1;
74
+ }
75
+ }
@@ -0,0 +1,17 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { generateSessionId } from "./session-id.js";
4
+
5
+ describe("generateSessionId", () => {
6
+ it("is a 12-char lowercase base32 string, valid as a DNS label and k8s name", () => {
7
+ for (let i = 0; i < 1000; i++) {
8
+ expect(generateSessionId()).toMatch(/^[a-z2-7]{12}$/);
9
+ }
10
+ });
11
+
12
+ it("does not repeat across many draws", () => {
13
+ const ids = new Set<string>();
14
+ for (let i = 0; i < 10000; i++) ids.add(generateSessionId());
15
+ expect(ids.size).toBe(10000);
16
+ });
17
+ });
@@ -0,0 +1,20 @@
1
+ import { randomBytes } from "node:crypto";
2
+
3
+ // Lowercase RFC 4648 base32 alphabet. Every character is valid in a DNS label
4
+ // and an RFC 1123 Kubernetes resource name, so the id drops straight into both
5
+ // `<id>.<domain>` session hostnames and `telo-run-<id>` container/pod names.
6
+ const ALPHABET = "abcdefghijklmnopqrstuvwxyz234567";
7
+
8
+ // 12 chars × 5 bits ≈ 60 bits of entropy. Collisions are negligible across the
9
+ // concurrently-active session set (there is no dedup check, so the id must be
10
+ // unique on its own) while staying far shorter than the previous UUID.
11
+ const SESSION_ID_LENGTH = 12;
12
+
13
+ /** Generates a short, DNS- and Kubernetes-safe session id. */
14
+ export function generateSessionId(): string {
15
+ const bytes = randomBytes(SESSION_ID_LENGTH);
16
+ let id = "";
17
+ // 256 is a multiple of 32, so `byte % 32` indexes the alphabet without bias.
18
+ for (let i = 0; i < SESSION_ID_LENGTH; i++) id += ALPHABET[bytes[i] % 32];
19
+ return id;
20
+ }
@@ -0,0 +1,154 @@
1
+ import type { FastifyReply, FastifyRequest } from "fastify";
2
+
3
+ import { isTerminal } from "../contract.js";
4
+ import { SessionEvictedError, type SessionRegistry } from "../session/registry.js";
5
+ import type { BufferedEvent } from "../session/ring-buffer.js";
6
+
7
+ const HEARTBEAT_MS = 20_000;
8
+
9
+ export interface SseStreamArgs {
10
+ registry: SessionRegistry;
11
+ req: FastifyRequest;
12
+ reply: FastifyReply;
13
+ sessionId: string;
14
+ corsOrigins: string[] | "*";
15
+ }
16
+
17
+ export async function streamSessionEvents(args: SseStreamArgs): Promise<void> {
18
+ const { registry, req, reply, sessionId, corsOrigins } = args;
19
+ const entry = registry.get(sessionId);
20
+ if (!entry) {
21
+ reply.code(404).send({ error: "not_found", message: `session '${sessionId}' not in registry` });
22
+ return;
23
+ }
24
+
25
+ const lastEventId = resolveLastEventId(req);
26
+ const raw = reply.raw;
27
+
28
+ // @fastify/cors adds Access-Control-Allow-Origin via an onSend hook, which
29
+ // never fires for this route because we bypass reply and write directly to
30
+ // reply.raw. Inject the CORS headers manually from our config.
31
+ const headers: Record<string, string> = {
32
+ "content-type": "text/event-stream",
33
+ "cache-control": "no-cache, no-transform",
34
+ connection: "keep-alive",
35
+ "x-accel-buffering": "no",
36
+ };
37
+ const allowOrigin = resolveAllowOrigin(corsOrigins, req.headers.origin);
38
+ if (allowOrigin) {
39
+ headers["access-control-allow-origin"] = allowOrigin;
40
+ headers["vary"] = "Origin";
41
+ }
42
+
43
+ raw.writeHead(200, headers);
44
+
45
+ // Replay any buffered history > lastEventId. `hasGap` triggers a synthetic
46
+ // gap marker so the client knows earlier output was evicted before we could
47
+ // deliver it.
48
+ const { entries, hasGap } = entry.buffer.replay(lastEventId);
49
+ if (hasGap) {
50
+ writeFrame(raw, "gap", { reason: "buffer_evicted" });
51
+ }
52
+ for (const buffered of entries) {
53
+ writeBufferedEvent(raw, buffered);
54
+ }
55
+
56
+ // If the session already reached a terminal status before the client hit
57
+ // /events, replay has delivered the terminal status frame and we're done.
58
+ if (isTerminal(entry.status)) {
59
+ raw.end();
60
+ return;
61
+ }
62
+
63
+ let unsubscribe: (() => void) | null = null;
64
+ let heartbeat: NodeJS.Timeout | null = null;
65
+ let closed = false;
66
+
67
+ const cleanup = (): void => {
68
+ if (closed) return;
69
+ closed = true;
70
+ if (heartbeat) clearInterval(heartbeat);
71
+ if (unsubscribe) unsubscribe();
72
+ if (!raw.writableEnded) raw.end();
73
+ };
74
+
75
+ heartbeat = setInterval(() => {
76
+ if (!raw.writableEnded) raw.write(": heartbeat\n\n");
77
+ }, HEARTBEAT_MS);
78
+ heartbeat.unref?.();
79
+
80
+ try {
81
+ unsubscribe = registry.subscribe(sessionId, (buffered) => {
82
+ writeBufferedEvent(raw, buffered);
83
+ if (buffered.event.type === "status" && isTerminal(buffered.event.status)) {
84
+ cleanup();
85
+ }
86
+ });
87
+ } catch (err) {
88
+ if (err instanceof SessionEvictedError) {
89
+ cleanup();
90
+ return;
91
+ }
92
+ throw err;
93
+ }
94
+
95
+ req.raw.on("close", cleanup);
96
+ req.raw.on("end", cleanup);
97
+
98
+ // Keep the response alive — Fastify's handler must not return until the
99
+ // stream closes, otherwise it sends a default end.
100
+ await new Promise<void>((resolve) => {
101
+ raw.on("close", resolve);
102
+ raw.on("finish", resolve);
103
+ });
104
+ }
105
+
106
+ function resolveLastEventId(req: FastifyRequest): number {
107
+ // Spec: header wins over query. EventSource sets the header on native
108
+ // auto-reconnect; the query param is for fresh instances (tab reload) where
109
+ // sse-client.ts passes the persisted id explicitly.
110
+ const header = req.headers["last-event-id"];
111
+ const fromHeader = parseId(Array.isArray(header) ? header[0] : header);
112
+ if (fromHeader !== null) return fromHeader;
113
+
114
+ const query = req.query as { lastEventId?: string } | undefined;
115
+ const fromQuery = parseId(query?.lastEventId);
116
+ return fromQuery ?? 0;
117
+ }
118
+
119
+ function resolveAllowOrigin(
120
+ corsOrigins: string[] | "*",
121
+ requestOrigin: string | string[] | undefined,
122
+ ): string | null {
123
+ if (corsOrigins === "*") return "*";
124
+ const origin = Array.isArray(requestOrigin) ? requestOrigin[0] : requestOrigin;
125
+ if (!origin) return null;
126
+ return corsOrigins.includes(origin) ? origin : null;
127
+ }
128
+
129
+ function parseId(raw: string | undefined): number | null {
130
+ if (!raw) return null;
131
+ const n = Number.parseInt(raw, 10);
132
+ // `Last-Event-ID: 0` is valid per the SSE spec — it means "resume from the
133
+ // beginning." Our ids start at 1, so replay(0) returns all buffered entries
134
+ // and the hasGap check correctly compares against first-resident-id.
135
+ return Number.isFinite(n) && n >= 0 ? n : null;
136
+ }
137
+
138
+ function writeBufferedEvent(raw: NodeJS.WritableStream, buffered: BufferedEvent): void {
139
+ writeFrame(raw, buffered.event.type, buffered.event, buffered.id);
140
+ }
141
+
142
+ function writeFrame(
143
+ raw: NodeJS.WritableStream,
144
+ event: string,
145
+ data: unknown,
146
+ id?: number,
147
+ ): void {
148
+ if (!("writable" in raw) || (raw as { writableEnded?: boolean }).writableEnded) return;
149
+ let frame = "";
150
+ if (id !== undefined) frame += `id: ${id}\n`;
151
+ frame += `event: ${event}\n`;
152
+ frame += `data: ${JSON.stringify(data)}\n\n`;
153
+ raw.write(frame);
154
+ }