@omg-dev/stream 0.4.24
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/dist/core.mjs +95 -0
- package/dist/index.mjs +129 -0
- package/dist/react.mjs +56 -0
- package/package.json +48 -0
- package/src/core.ts +162 -0
- package/src/index.ts +211 -0
- package/src/react.ts +76 -0
package/dist/core.mjs
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
//#region src/core.ts
|
|
2
|
+
const enc = new TextEncoder();
|
|
3
|
+
/**
|
|
4
|
+
* Format a single event as one SSE block. Multiline data is split into
|
|
5
|
+
* multiple `data:` lines per spec; JSON.stringify normally can't produce
|
|
6
|
+
* one but defensive.
|
|
7
|
+
*/
|
|
8
|
+
function encodeEvent(id, data) {
|
|
9
|
+
const lines = data.split("\n").map((l) => `data: ${l}`).join("\n");
|
|
10
|
+
return enc.encode(`id: ${id}\n${lines}\n\n`);
|
|
11
|
+
}
|
|
12
|
+
/** SSE block representing a buffer-overflow gap. */
|
|
13
|
+
function encodeGap(resumeId) {
|
|
14
|
+
return encodeEvent(resumeId, JSON.stringify({ type: "gap" }));
|
|
15
|
+
}
|
|
16
|
+
/** Comment-only frame, used for heartbeats. */
|
|
17
|
+
function encodeHeartbeat() {
|
|
18
|
+
return enc.encode(":hb\n\n");
|
|
19
|
+
}
|
|
20
|
+
var Topic = class {
|
|
21
|
+
buffer = [];
|
|
22
|
+
nextId = 1;
|
|
23
|
+
subs = /* @__PURE__ */ new Set();
|
|
24
|
+
bufferMax;
|
|
25
|
+
bufferTtlMs;
|
|
26
|
+
constructor(opts = {}) {
|
|
27
|
+
this.bufferMax = opts.bufferMax ?? 500;
|
|
28
|
+
this.bufferTtlMs = opts.bufferTtlMs ?? 6e4;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Publish one event. Returns the assigned id so wrappers can include it
|
|
32
|
+
* in their HTTP response (`{ seq }` body, audit log, etc.).
|
|
33
|
+
*/
|
|
34
|
+
publish(data) {
|
|
35
|
+
const id = this.nextId++;
|
|
36
|
+
const json = JSON.stringify(data);
|
|
37
|
+
const ts = Date.now();
|
|
38
|
+
this.buffer.push({
|
|
39
|
+
id,
|
|
40
|
+
data: json,
|
|
41
|
+
ts
|
|
42
|
+
});
|
|
43
|
+
this.evictOld(ts);
|
|
44
|
+
const chunk = encodeEvent(id, json);
|
|
45
|
+
for (const w of [...this.subs]) w.write(chunk).catch(() => this.drop(w));
|
|
46
|
+
return id;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Attach a subscriber.
|
|
50
|
+
* - If `sinceId` is provided and the buffer can fully replay events
|
|
51
|
+
* after it, replay them in order, then go live.
|
|
52
|
+
* - If `sinceId` is provided but the gap is too large (events fell
|
|
53
|
+
* out of the buffer), emit one `{type:"gap"}` event so the client
|
|
54
|
+
* can rehydrate from its durable source, then go live.
|
|
55
|
+
* - If `sinceId` is absent (fresh connect), emit nothing and go live.
|
|
56
|
+
*
|
|
57
|
+
* Returns a `detach()` thunk so wrappers can clean up on disconnect.
|
|
58
|
+
*/
|
|
59
|
+
async attach(writer, sinceId) {
|
|
60
|
+
if (sinceId != null && Number.isFinite(sinceId)) {
|
|
61
|
+
const missed = this.buffer.filter((e) => e.id > sinceId);
|
|
62
|
+
const oldestBufferedId = this.buffer[0]?.id ?? this.nextId;
|
|
63
|
+
if (missed.length > 0 ? missed[0].id === sinceId + 1 : oldestBufferedId > sinceId) for (const e of missed) await writer.write(encodeEvent(e.id, e.data));
|
|
64
|
+
else await writer.write(encodeGap(this.nextId - 1));
|
|
65
|
+
}
|
|
66
|
+
this.subs.add(writer);
|
|
67
|
+
return () => this.drop(writer);
|
|
68
|
+
}
|
|
69
|
+
/** Drop a subscriber explicitly (e.g. on client disconnect). */
|
|
70
|
+
detach(writer) {
|
|
71
|
+
this.drop(writer);
|
|
72
|
+
}
|
|
73
|
+
/** Current subscriber count. */
|
|
74
|
+
size() {
|
|
75
|
+
return this.subs.size;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Id of the most recently published event, or 0 if none have been
|
|
79
|
+
* published yet. Useful for wrappers that want to tell fresh-connect
|
|
80
|
+
* subscribers where they're picking up (e.g. the CF worker's
|
|
81
|
+
* `:cursor <id>` comment for resume-ability).
|
|
82
|
+
*/
|
|
83
|
+
lastId() {
|
|
84
|
+
return this.nextId - 1;
|
|
85
|
+
}
|
|
86
|
+
drop(writer) {
|
|
87
|
+
if (!this.subs.delete(writer)) return;
|
|
88
|
+
if (writer.close) writer.close().catch(() => {});
|
|
89
|
+
}
|
|
90
|
+
evictOld(now) {
|
|
91
|
+
while (this.buffer.length > 0 && (this.buffer.length > this.bufferMax || now - this.buffer[0].ts > this.bufferTtlMs)) this.buffer.shift();
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
//#endregion
|
|
95
|
+
export { Topic, encodeEvent, encodeGap, encodeHeartbeat };
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { fetchEventSource } from "@microsoft/fetch-event-source";
|
|
2
|
+
//#region src/index.ts
|
|
3
|
+
/**
|
|
4
|
+
* @omg-dev/stream — client + server SDK for the Vibes stream service.
|
|
5
|
+
*
|
|
6
|
+
* Two surfaces:
|
|
7
|
+
* - `createStreamClient({ url, token })` — server-side publisher
|
|
8
|
+
* - `subscribe(topic, opts)` — client-side subscriber (raw promise API;
|
|
9
|
+
* React users should prefer `useStream` from `@omg-dev/stream/react`)
|
|
10
|
+
*
|
|
11
|
+
* The wire format is plain SSE with one event per `id:` block. Each event's
|
|
12
|
+
* `data:` is JSON. See apps/infra/stream-worker for the server side.
|
|
13
|
+
*
|
|
14
|
+
* Resumability: the underlying fetch-event-source library sends
|
|
15
|
+
* `Last-Event-ID` on reconnect; the stream service replays missed events
|
|
16
|
+
* from its ring buffer, or emits `{type:"gap"}` if the gap is too large.
|
|
17
|
+
* Callers should treat `gap` as a signal to refetch authoritative state.
|
|
18
|
+
*/
|
|
19
|
+
const DEFAULT_STREAM_URL = "https://stream.omg.dev";
|
|
20
|
+
var FatalError = class extends Error {};
|
|
21
|
+
/**
|
|
22
|
+
* Subscribe to a topic. Returns an unsubscribe function.
|
|
23
|
+
*
|
|
24
|
+
* Reconnection, exponential backoff, Last-Event-ID, and tab-visibility
|
|
25
|
+
* pause/resume are all handled by the underlying library.
|
|
26
|
+
*/
|
|
27
|
+
function subscribe(topic, opts) {
|
|
28
|
+
const url = `${(opts.url ?? "https://stream.omg.dev").replace(/\/+$/, "")}/${encodeURIComponent(topic)}`;
|
|
29
|
+
const ctl = new AbortController();
|
|
30
|
+
if (opts.signal) if (opts.signal.aborted) ctl.abort();
|
|
31
|
+
else opts.signal.addEventListener("abort", () => ctl.abort(), { once: true });
|
|
32
|
+
const getToken = typeof opts.token === "function" ? opts.token : () => opts.token;
|
|
33
|
+
let attempt = 0;
|
|
34
|
+
let authRetries = 0;
|
|
35
|
+
const MAX_AUTH_RETRIES = 4;
|
|
36
|
+
opts.onStatus?.({ state: "connecting" });
|
|
37
|
+
fetchEventSource(url, {
|
|
38
|
+
signal: ctl.signal,
|
|
39
|
+
fetch: async (input, init) => {
|
|
40
|
+
const token = await getToken();
|
|
41
|
+
const headers = new Headers(init?.headers);
|
|
42
|
+
headers.set("Authorization", `Bearer ${token}`);
|
|
43
|
+
return fetch(input, {
|
|
44
|
+
...init,
|
|
45
|
+
headers
|
|
46
|
+
});
|
|
47
|
+
},
|
|
48
|
+
async onopen(res) {
|
|
49
|
+
if (res.ok && res.headers.get("content-type")?.includes("text/event-stream")) {
|
|
50
|
+
attempt = 0;
|
|
51
|
+
authRetries = 0;
|
|
52
|
+
opts.onStatus?.({ state: "open" });
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
if (res.status === 401 && authRetries < MAX_AUTH_RETRIES) {
|
|
56
|
+
authRetries += 1;
|
|
57
|
+
throw new Error(`401 (auth not ready, retry ${authRetries}/${MAX_AUTH_RETRIES})`);
|
|
58
|
+
}
|
|
59
|
+
if (res.status === 401 || res.status === 403 || res.status === 400) {
|
|
60
|
+
const body = await res.text().catch(() => "");
|
|
61
|
+
throw new FatalError(`${res.status}: ${body || res.statusText}`);
|
|
62
|
+
}
|
|
63
|
+
throw new Error(`unexpected status ${res.status}`);
|
|
64
|
+
},
|
|
65
|
+
onmessage(ev) {
|
|
66
|
+
if (!ev.data) return;
|
|
67
|
+
let parsed;
|
|
68
|
+
try {
|
|
69
|
+
parsed = JSON.parse(ev.data);
|
|
70
|
+
} catch {
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
const id = ev.id ? Number(ev.id) : 0;
|
|
74
|
+
if (isGap(parsed)) {
|
|
75
|
+
opts.onGap?.();
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
opts.onEvent({
|
|
79
|
+
id,
|
|
80
|
+
data: parsed
|
|
81
|
+
});
|
|
82
|
+
},
|
|
83
|
+
onclose() {
|
|
84
|
+
throw new Error("connection closed");
|
|
85
|
+
},
|
|
86
|
+
onerror(err) {
|
|
87
|
+
if (err instanceof FatalError) {
|
|
88
|
+
opts.onStatus?.({
|
|
89
|
+
state: "closed",
|
|
90
|
+
reason: err.message
|
|
91
|
+
});
|
|
92
|
+
throw err;
|
|
93
|
+
}
|
|
94
|
+
attempt += 1;
|
|
95
|
+
opts.onStatus?.({
|
|
96
|
+
state: "reconnecting",
|
|
97
|
+
attempt,
|
|
98
|
+
error: err
|
|
99
|
+
});
|
|
100
|
+
return Math.min(3e4, 500 * 2 ** Math.min(attempt - 1, 6));
|
|
101
|
+
}
|
|
102
|
+
}).catch(() => {});
|
|
103
|
+
return () => ctl.abort();
|
|
104
|
+
}
|
|
105
|
+
function isGap(data) {
|
|
106
|
+
return typeof data === "object" && data !== null && data.type === "gap";
|
|
107
|
+
}
|
|
108
|
+
function createStreamClient(opts) {
|
|
109
|
+
const baseUrl = (opts.url ?? "https://stream.omg.dev").replace(/\/+$/, "");
|
|
110
|
+
const getToken = typeof opts.token === "function" ? opts.token : () => opts.token;
|
|
111
|
+
return { async publish(topic, event) {
|
|
112
|
+
const token = await getToken();
|
|
113
|
+
const res = await fetch(`${baseUrl}/${encodeURIComponent(topic)}`, {
|
|
114
|
+
method: "POST",
|
|
115
|
+
headers: {
|
|
116
|
+
"content-type": "application/json",
|
|
117
|
+
authorization: `Bearer ${token}`
|
|
118
|
+
},
|
|
119
|
+
body: JSON.stringify(event)
|
|
120
|
+
});
|
|
121
|
+
if (!res.ok) {
|
|
122
|
+
const body = await res.text().catch(() => "");
|
|
123
|
+
throw new Error(`publish ${topic} failed: ${res.status} ${body}`);
|
|
124
|
+
}
|
|
125
|
+
return { seq: (await res.json()).seq };
|
|
126
|
+
} };
|
|
127
|
+
}
|
|
128
|
+
//#endregion
|
|
129
|
+
export { DEFAULT_STREAM_URL, createStreamClient, subscribe };
|
package/dist/react.mjs
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { subscribe } from "./index.mjs";
|
|
2
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
3
|
+
//#region src/react.ts
|
|
4
|
+
/**
|
|
5
|
+
* React bindings for @omg-dev/stream.
|
|
6
|
+
*
|
|
7
|
+
* `useStream(topic, opts)` opens a subscription on mount, cleans up on
|
|
8
|
+
* unmount, and exposes connection status + an event callback. Token can
|
|
9
|
+
* be a sync string or an async thunk; the underlying library re-fetches
|
|
10
|
+
* on every (re)connect so rotation is free.
|
|
11
|
+
*
|
|
12
|
+
* This hook intentionally does NOT buffer events in React state — token
|
|
13
|
+
* deltas at 30/s would thrash. Callers consume each event in `onEvent`
|
|
14
|
+
* (typically appending to a `useRef` and flushing via RAF — see
|
|
15
|
+
* apps/web/src/components/InflightAssistantBubble.tsx for the pattern).
|
|
16
|
+
*/
|
|
17
|
+
function useStream(topic, opts) {
|
|
18
|
+
const [status, setStatus] = useState({ state: "connecting" });
|
|
19
|
+
const [nonce, setNonce] = useState(0);
|
|
20
|
+
const onEventRef = useRef(opts.onEvent);
|
|
21
|
+
const onGapRef = useRef(opts.onGap);
|
|
22
|
+
onEventRef.current = opts.onEvent;
|
|
23
|
+
onGapRef.current = opts.onGap;
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
if (opts.enabled === false) {
|
|
26
|
+
setStatus({
|
|
27
|
+
state: "closed",
|
|
28
|
+
reason: "disabled"
|
|
29
|
+
});
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
const unsub = subscribe(topic, {
|
|
33
|
+
url: opts.url,
|
|
34
|
+
token: opts.token,
|
|
35
|
+
onEvent: (e) => onEventRef.current(e),
|
|
36
|
+
onGap: () => onGapRef.current?.(),
|
|
37
|
+
onStatus: setStatus
|
|
38
|
+
});
|
|
39
|
+
return () => {
|
|
40
|
+
unsub();
|
|
41
|
+
};
|
|
42
|
+
}, [
|
|
43
|
+
topic,
|
|
44
|
+
opts.enabled,
|
|
45
|
+
nonce
|
|
46
|
+
]);
|
|
47
|
+
return {
|
|
48
|
+
status,
|
|
49
|
+
reconnect: useCallback(() => {
|
|
50
|
+
setStatus({ state: "connecting" });
|
|
51
|
+
setNonce((n) => n + 1);
|
|
52
|
+
}, [])
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
//#endregion
|
|
56
|
+
export { useStream };
|
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@omg-dev/stream",
|
|
3
|
+
"version": "0.4.24",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"exports": {
|
|
6
|
+
".": {
|
|
7
|
+
"types": "./src/index.ts",
|
|
8
|
+
"default": "./dist/index.mjs"
|
|
9
|
+
},
|
|
10
|
+
"./core": {
|
|
11
|
+
"types": "./src/core.ts",
|
|
12
|
+
"default": "./dist/core.mjs"
|
|
13
|
+
},
|
|
14
|
+
"./react": {
|
|
15
|
+
"types": "./src/react.ts",
|
|
16
|
+
"default": "./dist/react.mjs"
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"@microsoft/fetch-event-source": "^2.0.1"
|
|
21
|
+
},
|
|
22
|
+
"peerDependencies": {
|
|
23
|
+
"react": ">=18"
|
|
24
|
+
},
|
|
25
|
+
"peerDependenciesMeta": {
|
|
26
|
+
"react": {
|
|
27
|
+
"optional": true
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"@types/react": "^19.2.14",
|
|
32
|
+
"typescript": "^5.4.5"
|
|
33
|
+
},
|
|
34
|
+
"license": "MIT",
|
|
35
|
+
"repository": {
|
|
36
|
+
"type": "git",
|
|
37
|
+
"url": "git+https://github.com/BennyKok/vibes.git"
|
|
38
|
+
},
|
|
39
|
+
"homepage": "https://docs.omg.dev",
|
|
40
|
+
"files": [
|
|
41
|
+
"dist",
|
|
42
|
+
"src"
|
|
43
|
+
],
|
|
44
|
+
"publishConfig": {
|
|
45
|
+
"access": "public",
|
|
46
|
+
"registry": "https://registry.npmjs.org/"
|
|
47
|
+
}
|
|
48
|
+
}
|
package/src/core.ts
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @omg-dev/stream/core — environment-agnostic SSE topic.
|
|
3
|
+
*
|
|
4
|
+
* `Topic` is the shared primitive behind both:
|
|
5
|
+
* - apps/infra/stream-worker (one Topic per Durable Object, used by all
|
|
6
|
+
* omg.dev streaming + cross-process Vibes app channels)
|
|
7
|
+
* - packages/server (one Topic per Vibes app server, in-process fan-out
|
|
8
|
+
* for CRUD invalidation)
|
|
9
|
+
*
|
|
10
|
+
* It owns a monotonic id counter, a ring buffer for Last-Event-ID replay,
|
|
11
|
+
* and a set of subscriber writers. It does NOT know about HTTP, JWTs,
|
|
12
|
+
* Durable Objects, or framework adapters — wrappers add those.
|
|
13
|
+
*
|
|
14
|
+
* Wire format (per event): `id: <n>\ndata: <json>\n\n`.
|
|
15
|
+
* Heartbeats and SSE response headers are the wrapper's responsibility
|
|
16
|
+
* because they're transport-specific (CF Workers vs Node http vs Hono).
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Minimum interface a wrapper needs to supply for a subscriber. Compatible
|
|
21
|
+
* with `WritableStreamDefaultWriter<Uint8Array>` (web streams) and with
|
|
22
|
+
* hand-rolled adapters around legacy callback-style sinks.
|
|
23
|
+
*/
|
|
24
|
+
export interface TopicWriter {
|
|
25
|
+
write(chunk: Uint8Array): Promise<void>;
|
|
26
|
+
/** Optional. Called when the topic drops the writer. */
|
|
27
|
+
close?(): Promise<void>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface BufferedEvent {
|
|
31
|
+
id: number;
|
|
32
|
+
/** Already-stringified JSON payload (what landed on the wire). */
|
|
33
|
+
data: string;
|
|
34
|
+
/** Wall-clock when published, used for TTL eviction. */
|
|
35
|
+
ts: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface TopicOptions {
|
|
39
|
+
/** Max events retained in the ring buffer. Default 500. */
|
|
40
|
+
bufferMax?: number;
|
|
41
|
+
/** Max age of an event before eviction, in ms. Default 60 000. */
|
|
42
|
+
bufferTtlMs?: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const enc = new TextEncoder();
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Format a single event as one SSE block. Multiline data is split into
|
|
49
|
+
* multiple `data:` lines per spec; JSON.stringify normally can't produce
|
|
50
|
+
* one but defensive.
|
|
51
|
+
*/
|
|
52
|
+
export function encodeEvent(id: number, data: string): Uint8Array {
|
|
53
|
+
const lines = data.split("\n").map((l) => `data: ${l}`).join("\n");
|
|
54
|
+
return enc.encode(`id: ${id}\n${lines}\n\n`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** SSE block representing a buffer-overflow gap. */
|
|
58
|
+
export function encodeGap(resumeId: number): Uint8Array {
|
|
59
|
+
return encodeEvent(resumeId, JSON.stringify({ type: "gap" }));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Comment-only frame, used for heartbeats. */
|
|
63
|
+
export function encodeHeartbeat(): Uint8Array {
|
|
64
|
+
return enc.encode(":hb\n\n");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export class Topic {
|
|
68
|
+
private buffer: BufferedEvent[] = [];
|
|
69
|
+
private nextId = 1;
|
|
70
|
+
private subs = new Set<TopicWriter>();
|
|
71
|
+
private readonly bufferMax: number;
|
|
72
|
+
private readonly bufferTtlMs: number;
|
|
73
|
+
|
|
74
|
+
constructor(opts: TopicOptions = {}) {
|
|
75
|
+
this.bufferMax = opts.bufferMax ?? 500;
|
|
76
|
+
this.bufferTtlMs = opts.bufferTtlMs ?? 60_000;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Publish one event. Returns the assigned id so wrappers can include it
|
|
81
|
+
* in their HTTP response (`{ seq }` body, audit log, etc.).
|
|
82
|
+
*/
|
|
83
|
+
publish(data: unknown): number {
|
|
84
|
+
const id = this.nextId++;
|
|
85
|
+
const json = JSON.stringify(data);
|
|
86
|
+
const ts = Date.now();
|
|
87
|
+
this.buffer.push({ id, data: json, ts });
|
|
88
|
+
this.evictOld(ts);
|
|
89
|
+
const chunk = encodeEvent(id, json);
|
|
90
|
+
for (const w of [...this.subs]) {
|
|
91
|
+
w.write(chunk).catch(() => this.drop(w));
|
|
92
|
+
}
|
|
93
|
+
return id;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Attach a subscriber.
|
|
98
|
+
* - If `sinceId` is provided and the buffer can fully replay events
|
|
99
|
+
* after it, replay them in order, then go live.
|
|
100
|
+
* - If `sinceId` is provided but the gap is too large (events fell
|
|
101
|
+
* out of the buffer), emit one `{type:"gap"}` event so the client
|
|
102
|
+
* can rehydrate from its durable source, then go live.
|
|
103
|
+
* - If `sinceId` is absent (fresh connect), emit nothing and go live.
|
|
104
|
+
*
|
|
105
|
+
* Returns a `detach()` thunk so wrappers can clean up on disconnect.
|
|
106
|
+
*/
|
|
107
|
+
async attach(writer: TopicWriter, sinceId?: number): Promise<() => void> {
|
|
108
|
+
if (sinceId != null && Number.isFinite(sinceId)) {
|
|
109
|
+
const missed = this.buffer.filter((e) => e.id > sinceId);
|
|
110
|
+
const oldestBufferedId = this.buffer[0]?.id ?? this.nextId;
|
|
111
|
+
const canFullyReplay =
|
|
112
|
+
missed.length > 0
|
|
113
|
+
? missed[0].id === sinceId + 1
|
|
114
|
+
: oldestBufferedId > sinceId;
|
|
115
|
+
if (canFullyReplay) {
|
|
116
|
+
for (const e of missed) {
|
|
117
|
+
await writer.write(encodeEvent(e.id, e.data));
|
|
118
|
+
}
|
|
119
|
+
} else {
|
|
120
|
+
await writer.write(encodeGap(this.nextId - 1));
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
this.subs.add(writer);
|
|
124
|
+
return () => this.drop(writer);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Drop a subscriber explicitly (e.g. on client disconnect). */
|
|
128
|
+
detach(writer: TopicWriter): void {
|
|
129
|
+
this.drop(writer);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Current subscriber count. */
|
|
133
|
+
size(): number {
|
|
134
|
+
return this.subs.size;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Id of the most recently published event, or 0 if none have been
|
|
139
|
+
* published yet. Useful for wrappers that want to tell fresh-connect
|
|
140
|
+
* subscribers where they're picking up (e.g. the CF worker's
|
|
141
|
+
* `:cursor <id>` comment for resume-ability).
|
|
142
|
+
*/
|
|
143
|
+
lastId(): number {
|
|
144
|
+
return this.nextId - 1;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
private drop(writer: TopicWriter): void {
|
|
148
|
+
if (!this.subs.delete(writer)) return;
|
|
149
|
+
if (writer.close) {
|
|
150
|
+
writer.close().catch(() => {});
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
private evictOld(now: number): void {
|
|
155
|
+
while (
|
|
156
|
+
this.buffer.length > 0 &&
|
|
157
|
+
(this.buffer.length > this.bufferMax || now - this.buffer[0].ts > this.bufferTtlMs)
|
|
158
|
+
) {
|
|
159
|
+
this.buffer.shift();
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @omg-dev/stream — client + server SDK for the Vibes stream service.
|
|
3
|
+
*
|
|
4
|
+
* Two surfaces:
|
|
5
|
+
* - `createStreamClient({ url, token })` — server-side publisher
|
|
6
|
+
* - `subscribe(topic, opts)` — client-side subscriber (raw promise API;
|
|
7
|
+
* React users should prefer `useStream` from `@omg-dev/stream/react`)
|
|
8
|
+
*
|
|
9
|
+
* The wire format is plain SSE with one event per `id:` block. Each event's
|
|
10
|
+
* `data:` is JSON. See apps/infra/stream-worker for the server side.
|
|
11
|
+
*
|
|
12
|
+
* Resumability: the underlying fetch-event-source library sends
|
|
13
|
+
* `Last-Event-ID` on reconnect; the stream service replays missed events
|
|
14
|
+
* from its ring buffer, or emits `{type:"gap"}` if the gap is too large.
|
|
15
|
+
* Callers should treat `gap` as a signal to refetch authoritative state.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import {
|
|
19
|
+
fetchEventSource,
|
|
20
|
+
type EventSourceMessage,
|
|
21
|
+
} from "@microsoft/fetch-event-source";
|
|
22
|
+
|
|
23
|
+
export const DEFAULT_STREAM_URL = "https://stream.omg.dev";
|
|
24
|
+
|
|
25
|
+
export interface StreamEvent {
|
|
26
|
+
/** Monotonic per-topic sequence number assigned by the server. */
|
|
27
|
+
id: number;
|
|
28
|
+
/** Whatever the publisher sent. Type-narrowed by the caller. */
|
|
29
|
+
data: unknown;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Reasons the subscription closed or transitioned state. */
|
|
33
|
+
export type StreamStatus =
|
|
34
|
+
| { state: "connecting" }
|
|
35
|
+
| { state: "open" }
|
|
36
|
+
| { state: "reconnecting"; attempt: number; error?: unknown }
|
|
37
|
+
| { state: "closed"; reason?: string };
|
|
38
|
+
|
|
39
|
+
export interface SubscribeOptions {
|
|
40
|
+
/** Stream service base URL. Defaults to https://stream.omg.dev. */
|
|
41
|
+
url?: string;
|
|
42
|
+
/** Bearer token, or a thunk that returns one (called on each connect). */
|
|
43
|
+
token: string | (() => string | Promise<string>);
|
|
44
|
+
/** Called for every event published while subscribed. */
|
|
45
|
+
onEvent: (event: StreamEvent) => void;
|
|
46
|
+
/**
|
|
47
|
+
* Called when the server says we missed too many events to replay.
|
|
48
|
+
* Hook for refetching the durable source-of-truth (e.g. Convex query).
|
|
49
|
+
*/
|
|
50
|
+
onGap?: () => void;
|
|
51
|
+
/** Called whenever the connection state changes. */
|
|
52
|
+
onStatus?: (status: StreamStatus) => void;
|
|
53
|
+
/**
|
|
54
|
+
* Optional AbortSignal to terminate the subscription. The returned
|
|
55
|
+
* unsubscribe() does the same thing more idiomatically.
|
|
56
|
+
*/
|
|
57
|
+
signal?: AbortSignal;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
class FatalError extends Error {}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Subscribe to a topic. Returns an unsubscribe function.
|
|
64
|
+
*
|
|
65
|
+
* Reconnection, exponential backoff, Last-Event-ID, and tab-visibility
|
|
66
|
+
* pause/resume are all handled by the underlying library.
|
|
67
|
+
*/
|
|
68
|
+
export function subscribe(topic: string, opts: SubscribeOptions): () => void {
|
|
69
|
+
const url = `${(opts.url ?? DEFAULT_STREAM_URL).replace(/\/+$/, "")}/${encodeURIComponent(topic)}`;
|
|
70
|
+
const ctl = new AbortController();
|
|
71
|
+
if (opts.signal) {
|
|
72
|
+
if (opts.signal.aborted) ctl.abort();
|
|
73
|
+
else opts.signal.addEventListener("abort", () => ctl.abort(), { once: true });
|
|
74
|
+
}
|
|
75
|
+
const getToken = typeof opts.token === "function" ? opts.token : () => opts.token as string;
|
|
76
|
+
|
|
77
|
+
let attempt = 0;
|
|
78
|
+
// 401 is recoverable when the consumer's auth state is still hydrating
|
|
79
|
+
// after a page reload — the token thunk returns null for ~hundreds of ms
|
|
80
|
+
// and the stream worker rejects until the session arrives. Treating
|
|
81
|
+
// those as fatal on the first try caused the bubble to stay closed
|
|
82
|
+
// for the rest of the page session even after the token became valid.
|
|
83
|
+
// Bound retries so a genuinely-unauthorised topic still fails fast.
|
|
84
|
+
let authRetries = 0;
|
|
85
|
+
const MAX_AUTH_RETRIES = 4;
|
|
86
|
+
opts.onStatus?.({ state: "connecting" });
|
|
87
|
+
|
|
88
|
+
void fetchEventSource(url, {
|
|
89
|
+
signal: ctl.signal,
|
|
90
|
+
// Custom fetch lets us stamp the token fresh on every (re)connection
|
|
91
|
+
// so callers can rotate tokens without re-subscribing.
|
|
92
|
+
fetch: async (input, init) => {
|
|
93
|
+
const token = await getToken();
|
|
94
|
+
const headers = new Headers(init?.headers);
|
|
95
|
+
headers.set("Authorization", `Bearer ${token}`);
|
|
96
|
+
return fetch(input, { ...init, headers });
|
|
97
|
+
},
|
|
98
|
+
async onopen(res) {
|
|
99
|
+
if (res.ok && res.headers.get("content-type")?.includes("text/event-stream")) {
|
|
100
|
+
attempt = 0;
|
|
101
|
+
authRetries = 0;
|
|
102
|
+
opts.onStatus?.({ state: "open" });
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
// 401 — let it retry a bounded number of times to ride out an
|
|
106
|
+
// un-hydrated auth state on the consumer. The token thunk is
|
|
107
|
+
// re-invoked on each reconnect, so a fresh token will be picked
|
|
108
|
+
// up automatically.
|
|
109
|
+
if (res.status === 401 && authRetries < MAX_AUTH_RETRIES) {
|
|
110
|
+
authRetries += 1;
|
|
111
|
+
throw new Error(`401 (auth not ready, retry ${authRetries}/${MAX_AUTH_RETRIES})`);
|
|
112
|
+
}
|
|
113
|
+
// After the budget is spent, OR for the always-fatal cases
|
|
114
|
+
// (403 = ready-but-denied, 400 = bad topic), stop retrying.
|
|
115
|
+
if (res.status === 401 || res.status === 403 || res.status === 400) {
|
|
116
|
+
const body = await res.text().catch(() => "");
|
|
117
|
+
throw new FatalError(`${res.status}: ${body || res.statusText}`);
|
|
118
|
+
}
|
|
119
|
+
// Anything else (5xx, network) → let the library retry.
|
|
120
|
+
throw new Error(`unexpected status ${res.status}`);
|
|
121
|
+
},
|
|
122
|
+
onmessage(ev: EventSourceMessage) {
|
|
123
|
+
if (!ev.data) return; // heartbeat comments arrive as empty messages
|
|
124
|
+
let parsed: unknown;
|
|
125
|
+
try {
|
|
126
|
+
parsed = JSON.parse(ev.data);
|
|
127
|
+
} catch {
|
|
128
|
+
return; // Non-JSON line, ignore
|
|
129
|
+
}
|
|
130
|
+
const id = ev.id ? Number(ev.id) : 0;
|
|
131
|
+
if (isGap(parsed)) {
|
|
132
|
+
opts.onGap?.();
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
opts.onEvent({ id, data: parsed });
|
|
136
|
+
},
|
|
137
|
+
onclose() {
|
|
138
|
+
// The library calls this on intentional close. Treat as transient
|
|
139
|
+
// (it'll re-call openWhenHidden semantics on visibility change) by
|
|
140
|
+
// throwing — the loop will reconnect with backoff.
|
|
141
|
+
throw new Error("connection closed");
|
|
142
|
+
},
|
|
143
|
+
onerror(err) {
|
|
144
|
+
if (err instanceof FatalError) {
|
|
145
|
+
opts.onStatus?.({ state: "closed", reason: err.message });
|
|
146
|
+
throw err; // stop retrying
|
|
147
|
+
}
|
|
148
|
+
attempt += 1;
|
|
149
|
+
opts.onStatus?.({ state: "reconnecting", attempt, error: err });
|
|
150
|
+
return Math.min(30_000, 500 * 2 ** Math.min(attempt - 1, 6)); // exp backoff capped at 30s
|
|
151
|
+
},
|
|
152
|
+
// Default behavior: pause on tab hidden, resume on visible.
|
|
153
|
+
// openWhenHidden left at default false.
|
|
154
|
+
})
|
|
155
|
+
.catch(() => {
|
|
156
|
+
// Fatal errors bubble out of fetchEventSource as rejections.
|
|
157
|
+
// onerror has already reported them via onStatus; nothing else to do.
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
// Tokens are fetched lazily and stamped onto each request via a
|
|
161
|
+
// wrapper. fetch-event-source doesn't give us a per-request hook for
|
|
162
|
+
// headers, so we shim through a custom fetch.
|
|
163
|
+
return () => ctl.abort();
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function isGap(data: unknown): boolean {
|
|
167
|
+
return (
|
|
168
|
+
typeof data === "object" &&
|
|
169
|
+
data !== null &&
|
|
170
|
+
(data as { type?: unknown }).type === "gap"
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
175
|
+
// Publisher
|
|
176
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
177
|
+
|
|
178
|
+
export interface CreateStreamClientOptions {
|
|
179
|
+
url?: string;
|
|
180
|
+
/** Bearer token. Function form is called per publish so it can refresh. */
|
|
181
|
+
token: string | (() => string | Promise<string>);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export interface StreamClient {
|
|
185
|
+
publish(topic: string, event: unknown): Promise<{ seq: number }>;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export function createStreamClient(opts: CreateStreamClientOptions): StreamClient {
|
|
189
|
+
const baseUrl = (opts.url ?? DEFAULT_STREAM_URL).replace(/\/+$/, "");
|
|
190
|
+
const getToken = typeof opts.token === "function" ? opts.token : () => opts.token as string;
|
|
191
|
+
|
|
192
|
+
return {
|
|
193
|
+
async publish(topic: string, event: unknown) {
|
|
194
|
+
const token = await getToken();
|
|
195
|
+
const res = await fetch(`${baseUrl}/${encodeURIComponent(topic)}`, {
|
|
196
|
+
method: "POST",
|
|
197
|
+
headers: {
|
|
198
|
+
"content-type": "application/json",
|
|
199
|
+
authorization: `Bearer ${token}`,
|
|
200
|
+
},
|
|
201
|
+
body: JSON.stringify(event),
|
|
202
|
+
});
|
|
203
|
+
if (!res.ok) {
|
|
204
|
+
const body = await res.text().catch(() => "");
|
|
205
|
+
throw new Error(`publish ${topic} failed: ${res.status} ${body}`);
|
|
206
|
+
}
|
|
207
|
+
const json = (await res.json()) as { seq: number };
|
|
208
|
+
return { seq: json.seq };
|
|
209
|
+
},
|
|
210
|
+
};
|
|
211
|
+
}
|
package/src/react.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* React bindings for @omg-dev/stream.
|
|
3
|
+
*
|
|
4
|
+
* `useStream(topic, opts)` opens a subscription on mount, cleans up on
|
|
5
|
+
* unmount, and exposes connection status + an event callback. Token can
|
|
6
|
+
* be a sync string or an async thunk; the underlying library re-fetches
|
|
7
|
+
* on every (re)connect so rotation is free.
|
|
8
|
+
*
|
|
9
|
+
* This hook intentionally does NOT buffer events in React state — token
|
|
10
|
+
* deltas at 30/s would thrash. Callers consume each event in `onEvent`
|
|
11
|
+
* (typically appending to a `useRef` and flushing via RAF — see
|
|
12
|
+
* apps/web/src/components/InflightAssistantBubble.tsx for the pattern).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
16
|
+
import { subscribe, type StreamEvent, type StreamStatus, type SubscribeOptions } from "./index";
|
|
17
|
+
|
|
18
|
+
export interface UseStreamOptions
|
|
19
|
+
extends Pick<SubscribeOptions, "url" | "token" | "onEvent" | "onGap"> {
|
|
20
|
+
/**
|
|
21
|
+
* When false, skips the subscription. Useful for "only subscribe when
|
|
22
|
+
* the run is active" patterns.
|
|
23
|
+
*/
|
|
24
|
+
enabled?: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface UseStreamResult {
|
|
28
|
+
status: StreamStatus;
|
|
29
|
+
/**
|
|
30
|
+
* Force-tear-down the current subscription and start a fresh one. Use
|
|
31
|
+
* to recover from "stuck" states a user surfaces via the UI:
|
|
32
|
+
* - permanent FatalError closed (401 budget exhausted, 403)
|
|
33
|
+
* - silent zombie open with no events
|
|
34
|
+
* - reconnect storm grinding at the 30s cap
|
|
35
|
+
* Cheap to call; no-op if `enabled` is false at call time.
|
|
36
|
+
*/
|
|
37
|
+
reconnect: () => void;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function useStream(topic: string, opts: UseStreamOptions): UseStreamResult {
|
|
41
|
+
const [status, setStatus] = useState<StreamStatus>({ state: "connecting" });
|
|
42
|
+
const [nonce, setNonce] = useState(0);
|
|
43
|
+
// Latch callbacks in refs so we can change them without re-subscribing.
|
|
44
|
+
const onEventRef = useRef(opts.onEvent);
|
|
45
|
+
const onGapRef = useRef(opts.onGap);
|
|
46
|
+
onEventRef.current = opts.onEvent;
|
|
47
|
+
onGapRef.current = opts.onGap;
|
|
48
|
+
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
if (opts.enabled === false) {
|
|
51
|
+
setStatus({ state: "closed", reason: "disabled" });
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
const unsub = subscribe(topic, {
|
|
55
|
+
url: opts.url,
|
|
56
|
+
token: opts.token,
|
|
57
|
+
onEvent: (e: StreamEvent) => onEventRef.current(e),
|
|
58
|
+
onGap: () => onGapRef.current?.(),
|
|
59
|
+
onStatus: setStatus,
|
|
60
|
+
});
|
|
61
|
+
return () => {
|
|
62
|
+
unsub();
|
|
63
|
+
};
|
|
64
|
+
// The token thunk and url are captured at effect-run time; bump
|
|
65
|
+
// `nonce` via reconnect() to pick up a fresh thunk after the
|
|
66
|
+
// consumer's auth state has changed.
|
|
67
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
68
|
+
}, [topic, opts.enabled, nonce]);
|
|
69
|
+
|
|
70
|
+
const reconnect = useCallback(() => {
|
|
71
|
+
setStatus({ state: "connecting" });
|
|
72
|
+
setNonce((n) => n + 1);
|
|
73
|
+
}, []);
|
|
74
|
+
|
|
75
|
+
return { status, reconnect };
|
|
76
|
+
}
|