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