@broberg/seti-client 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,55 @@
1
+ # @broberg/seti-client
2
+
3
+ Typed client + frame-merge engine + Preact `<SetiChat>` component for
4
+ **buddycloud.cc SET/SETI live streaming chat**. Consume it through a host-app
5
+ proxy from [`@broberg/seti-server`](https://www.npmjs.com/package/@broberg/seti-server)
6
+ (same-origin, host auth, no CORS — the consumer token never reaches the browser).
7
+
8
+ ## Drop-in chat surface (Preact)
9
+
10
+ ```tsx
11
+ import { SetiChat } from "@broberg/seti-client/preact";
12
+
13
+ <SetiChat baseUrl="/api/seti" edge="cb-ubuntu-docker" session="cc" />;
14
+ ```
15
+
16
+ Complete mobile-first surface: status header, accumulated screen, nav-keys bar
17
+ (Esc/↑/↓/←/→/⏎) and a text input with delivery feedback (text preserved on
18
+ failure). Self-contained styles, themeable via CSS vars (`--seti-bg`,
19
+ `--seti-panel`, `--seti-edge`, `--seti-fg`, `--seti-dim`, `--seti-accent`,
20
+ `--seti-warn`, `--seti-bad`, `--seti-mono`, `--seti-radius`). Every interactive
21
+ element has `data-testid="seti-chat-*"`. Peer dependency `preact ^10` (optional —
22
+ the core export is framework-agnostic).
23
+
24
+ ## Headless client
25
+
26
+ ```ts
27
+ import { SetiClient, FrameAccumulator } from "@broberg/seti-client";
28
+
29
+ const client = new SetiClient({ baseUrl: "/api/seti" });
30
+
31
+ const roster = await client.listSessions();
32
+ // roster.edges[n].tmuxSessions = the STREAMABLE session names (use as `session`)
33
+
34
+ const acc = new FrameAccumulator();
35
+ const stream = client.openStream("cb-ubuntu-docker", "cc", {
36
+ onFrame: (content) => console.log(acc.feed(content)), // { history, footer }
37
+ onStateChange: (s) => console.log(s), // connecting | open | reconnecting | closed
38
+ });
39
+
40
+ await client.sendText("cb-ubuntu-docker", "cc", "Run the test suite, report back.");
41
+ await client.sendKey("cb-ubuntu-docker", "cc", "Enter");
42
+ stream.close();
43
+ ```
44
+
45
+ “Start a task” on a headless SET and chatting with an interactive SETI are the
46
+ same call — `sendText` — because both are tmux cc sessions on the edge.
47
+
48
+ `FrameAccumulator` solves alt-screen scrollback: cc renders on the terminal
49
+ alt-screen (tmux keeps no scrollback), so every frame is a full window snapshot;
50
+ the accumulator overlap-merges successive frames into a growing dialogue history
51
+ plus a live footer.
52
+
53
+ Server-side/direct use: `new SetiClient({ baseUrl: "https://buddycloud.cc/api/seti/v1", token })`.
54
+
55
+ MIT © broberg.ai
@@ -0,0 +1,177 @@
1
+ // src/client.ts
2
+ var SetiClient = class {
3
+ base;
4
+ headers;
5
+ doFetch;
6
+ constructor(opts) {
7
+ this.base = opts.baseUrl.replace(/\/$/, "");
8
+ this.headers = opts.token ? { Authorization: `Bearer ${opts.token}` } : {};
9
+ this.doFetch = opts.fetch ?? globalThis.fetch.bind(globalThis);
10
+ }
11
+ async listSessions() {
12
+ const res = await this.doFetch(`${this.base}/sessions`, { headers: this.headers });
13
+ if (!res.ok) return { edges: [], error: `http_${res.status}` };
14
+ return await res.json();
15
+ }
16
+ async sendText(edge, session, text) {
17
+ return this.input({ edge, session, text });
18
+ }
19
+ async sendKey(edge, session, key) {
20
+ return this.input({ edge, session, key });
21
+ }
22
+ async input(body) {
23
+ try {
24
+ const res = await this.doFetch(`${this.base}/input`, {
25
+ method: "POST",
26
+ headers: { ...this.headers, "content-type": "application/json" },
27
+ body: JSON.stringify(body),
28
+ signal: AbortSignal.timeout(8e3)
29
+ });
30
+ const json = await res.json().catch(() => ({}));
31
+ return { ok: !!json.ok, edgeConnected: !!json.edgeConnected, error: json.error };
32
+ } catch (err) {
33
+ return { ok: false, edgeConnected: false, error: err instanceof Error ? err.message : "send_failed" };
34
+ }
35
+ }
36
+ /**
37
+ * Open the live frame stream for one edge session. Reconnects automatically
38
+ * until `close()` is called.
39
+ */
40
+ openStream(edge, session, handlers) {
41
+ let closed = false;
42
+ let controller = null;
43
+ const run = async () => {
44
+ let attempt = 0;
45
+ while (!closed) {
46
+ handlers.onStateChange?.(attempt === 0 ? "connecting" : "reconnecting");
47
+ controller = new AbortController();
48
+ try {
49
+ const res = await this.doFetch(
50
+ `${this.base}/stream?edge=${encodeURIComponent(edge)}&session=${encodeURIComponent(session)}`,
51
+ { headers: { ...this.headers, accept: "text/event-stream" }, signal: controller.signal }
52
+ );
53
+ if (!res.ok || !res.body) throw new Error(`http_${res.status}`);
54
+ handlers.onStateChange?.("open");
55
+ attempt = 0;
56
+ await this.consume(res.body, handlers);
57
+ } catch {
58
+ }
59
+ if (closed) break;
60
+ attempt++;
61
+ await new Promise((r) => setTimeout(r, Math.min(1e3 * attempt, 5e3)));
62
+ }
63
+ handlers.onStateChange?.("closed");
64
+ };
65
+ void run();
66
+ return {
67
+ close: () => {
68
+ closed = true;
69
+ controller?.abort();
70
+ }
71
+ };
72
+ }
73
+ /** Minimal SSE parser: `event:` + `data:` lines, events split on blank lines. */
74
+ async consume(body, handlers) {
75
+ const reader = body.getReader();
76
+ const decoder = new TextDecoder();
77
+ let buf = "";
78
+ for (; ; ) {
79
+ const { done, value } = await reader.read();
80
+ if (done) break;
81
+ buf += decoder.decode(value, { stream: true });
82
+ let sep;
83
+ while ((sep = buf.indexOf("\n\n")) !== -1) {
84
+ const chunk = buf.slice(0, sep);
85
+ buf = buf.slice(sep + 2);
86
+ let event = "message";
87
+ const data = [];
88
+ for (const line of chunk.split("\n")) {
89
+ if (line.startsWith("event:")) event = line.slice(6).trim();
90
+ else if (line.startsWith("data:")) data.push(line.slice(5).trimStart());
91
+ }
92
+ if (data.length === 0) continue;
93
+ let parsed;
94
+ try {
95
+ parsed = JSON.parse(data.join("\n"));
96
+ } catch {
97
+ continue;
98
+ }
99
+ if (event === "frame") {
100
+ const c = parsed.content;
101
+ if (typeof c === "string") handlers.onFrame?.(c);
102
+ } else if (event === "hello") {
103
+ handlers.onHello?.(parsed);
104
+ } else if (event === "ping") {
105
+ handlers.onPing?.(parsed);
106
+ }
107
+ }
108
+ }
109
+ }
110
+ };
111
+
112
+ // src/frame-accumulator.ts
113
+ var RULE = /^[─━-]{10,}\s*$/;
114
+ var SPIN = /^[✻✶✳·•*]\s/;
115
+ function splitFooter(lines) {
116
+ let inp = -1;
117
+ for (let i = lines.length - 1; i >= 0 && i >= lines.length - 8; i--) {
118
+ const t = lines[i].replace(/\s+$/, "");
119
+ if (t.charCodeAt(0) === 10095 || t[0] === ">") {
120
+ inp = i;
121
+ break;
122
+ }
123
+ }
124
+ let start;
125
+ if (inp === -1) start = Math.max(0, lines.length - 3);
126
+ else start = inp > 0 && RULE.test(lines[inp - 1].trim()) ? inp - 1 : inp;
127
+ while (start > 0 && SPIN.test(lines[start - 1])) start--;
128
+ return { body: lines.slice(0, start), footer: lines.slice(start) };
129
+ }
130
+ function mergeOverlap(hist, body) {
131
+ const max = Math.min(hist.length, body.length);
132
+ for (let k = max; k > 0; k--) {
133
+ let ok = true;
134
+ for (let i = 0; i < k; i++) {
135
+ if (hist[hist.length - k + i] !== body[i]) {
136
+ ok = false;
137
+ break;
138
+ }
139
+ }
140
+ if (ok) return hist.concat(body.slice(k));
141
+ }
142
+ return hist.concat(body);
143
+ }
144
+ var FrameAccumulator = class {
145
+ constructor(maxHistory = 5e3) {
146
+ this.maxHistory = maxHistory;
147
+ }
148
+ maxHistory;
149
+ history = [];
150
+ footer = [];
151
+ /** Feed a full frame snapshot; returns the updated view. */
152
+ feed(content) {
153
+ const lines = content.replace(/\s+$/, "").split("\n");
154
+ const { body, footer } = splitFooter(lines);
155
+ this.history = mergeOverlap(this.history, body);
156
+ if (this.history.length > this.maxHistory) {
157
+ this.history = this.history.slice(-this.maxHistory);
158
+ }
159
+ this.footer = footer;
160
+ return this.view;
161
+ }
162
+ get view() {
163
+ return { history: this.history, footer: this.footer };
164
+ }
165
+ /** Full rendered text (history + live footer). */
166
+ get text() {
167
+ return this.history.concat(this.footer).join("\n");
168
+ }
169
+ reset() {
170
+ this.history = [];
171
+ this.footer = [];
172
+ }
173
+ };
174
+
175
+ export { FrameAccumulator, SetiClient, mergeOverlap, splitFooter };
176
+ //# sourceMappingURL=chunk-VWTGZF3D.js.map
177
+ //# sourceMappingURL=chunk-VWTGZF3D.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/client.ts","../src/frame-accumulator.ts"],"names":[],"mappings":"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","file":"chunk-VWTGZF3D.js","sourcesContent":["import type {\n SetiInputResult,\n SetiKey,\n SetiRoster,\n SetiStreamHandle,\n SetiStreamHandlers,\n} from \"./types\";\n\nexport interface SetiClientOptions {\n /**\n * Base URL of the SETI surface. In a browser this is the host app's proxy\n * mount (same-origin, e.g. \"/api/seti\" via @broberg/seti-server). Server-side\n * it can be the cloud directly (\"https://buddycloud.cc/api/seti/v1\") together\n * with `token`.\n */\n baseUrl: string;\n /** Bearer token — only for server-side/direct use. NEVER ship to a browser. */\n token?: string;\n /** Override fetch (tests / custom runtimes). */\n fetch?: typeof fetch;\n}\n\n/**\n * Typed client for the SETI API (roster + SSE stream + input). One code path\n * for browser and server: fetch-based SSE with automatic reconnect (1s → 5s\n * backoff), so bearer headers work everywhere and no EventSource is needed.\n */\nexport class SetiClient {\n private readonly base: string;\n private readonly headers: Record<string, string>;\n private readonly doFetch: typeof fetch;\n\n constructor(opts: SetiClientOptions) {\n this.base = opts.baseUrl.replace(/\\/$/, \"\");\n this.headers = opts.token ? { Authorization: `Bearer ${opts.token}` } : {};\n this.doFetch = opts.fetch ?? globalThis.fetch.bind(globalThis);\n }\n\n async listSessions(): Promise<SetiRoster> {\n const res = await this.doFetch(`${this.base}/sessions`, { headers: this.headers });\n if (!res.ok) return { edges: [], error: `http_${res.status}` };\n return (await res.json()) as SetiRoster;\n }\n\n async sendText(edge: string, session: string, text: string): Promise<SetiInputResult> {\n return this.input({ edge, session, text });\n }\n\n async sendKey(edge: string, session: string, key: SetiKey): Promise<SetiInputResult> {\n return this.input({ edge, session, key });\n }\n\n private async input(body: {\n edge: string;\n session: string;\n text?: string;\n key?: SetiKey;\n }): Promise<SetiInputResult> {\n try {\n const res = await this.doFetch(`${this.base}/input`, {\n method: \"POST\",\n headers: { ...this.headers, \"content-type\": \"application/json\" },\n body: JSON.stringify(body),\n signal: AbortSignal.timeout(8000),\n });\n const json = (await res.json().catch(() => ({}))) as Partial<SetiInputResult>;\n return { ok: !!json.ok, edgeConnected: !!json.edgeConnected, error: json.error };\n } catch (err) {\n return { ok: false, edgeConnected: false, error: err instanceof Error ? err.message : \"send_failed\" };\n }\n }\n\n /**\n * Open the live frame stream for one edge session. Reconnects automatically\n * until `close()` is called.\n */\n openStream(edge: string, session: string, handlers: SetiStreamHandlers): SetiStreamHandle {\n let closed = false;\n let controller: AbortController | null = null;\n\n const run = async (): Promise<void> => {\n let attempt = 0;\n while (!closed) {\n handlers.onStateChange?.(attempt === 0 ? \"connecting\" : \"reconnecting\");\n controller = new AbortController();\n try {\n const res = await this.doFetch(\n `${this.base}/stream?edge=${encodeURIComponent(edge)}&session=${encodeURIComponent(session)}`,\n { headers: { ...this.headers, accept: \"text/event-stream\" }, signal: controller.signal },\n );\n if (!res.ok || !res.body) throw new Error(`http_${res.status}`);\n handlers.onStateChange?.(\"open\");\n attempt = 0;\n await this.consume(res.body, handlers);\n } catch {\n /* fall through to reconnect */\n }\n if (closed) break;\n attempt++;\n await new Promise((r) => setTimeout(r, Math.min(1000 * attempt, 5000)));\n }\n handlers.onStateChange?.(\"closed\");\n };\n void run();\n\n return {\n close: () => {\n closed = true;\n controller?.abort();\n },\n };\n }\n\n /** Minimal SSE parser: `event:` + `data:` lines, events split on blank lines. */\n private async consume(body: ReadableStream<Uint8Array>, handlers: SetiStreamHandlers): Promise<void> {\n const reader = body.getReader();\n const decoder = new TextDecoder();\n let buf = \"\";\n for (;;) {\n const { done, value } = await reader.read();\n if (done) break;\n buf += decoder.decode(value, { stream: true });\n let sep: number;\n while ((sep = buf.indexOf(\"\\n\\n\")) !== -1) {\n const chunk = buf.slice(0, sep);\n buf = buf.slice(sep + 2);\n let event = \"message\";\n const data: string[] = [];\n for (const line of chunk.split(\"\\n\")) {\n if (line.startsWith(\"event:\")) event = line.slice(6).trim();\n else if (line.startsWith(\"data:\")) data.push(line.slice(5).trimStart());\n }\n if (data.length === 0) continue;\n let parsed: unknown;\n try {\n parsed = JSON.parse(data.join(\"\\n\"));\n } catch {\n continue;\n }\n if (event === \"frame\") {\n const c = (parsed as { content?: unknown }).content;\n if (typeof c === \"string\") handlers.onFrame?.(c);\n } else if (event === \"hello\") {\n handlers.onHello?.(parsed as { edge: string; session: string; edgeConnected: boolean });\n } else if (event === \"ping\") {\n handlers.onPing?.(parsed as { edgeConnected: boolean });\n }\n }\n }\n }\n}\n","/**\n * FrameAccumulator — the F071 scrollback engine as a tested pure class.\n *\n * cc runs on the terminal alt-screen, so tmux has no scrollback: every frame is\n * a full snapshot of the visible window. The accumulator splits each frame into\n * a volatile footer (cc's input box + statusline + spinner, rendered live) and\n * a body, then overlap-merges successive bodies so the dialogue that scrolls\n * off the top is retained.\n */\nexport interface FrameView {\n /** Accumulated dialogue lines (grows from the first fed frame). */\n history: string[];\n /** The volatile tail of the latest frame (input box / statusline / spinner). */\n footer: string[];\n}\n\nconst RULE = /^[─━-]{10,}\\s*$/;\nconst SPIN = /^[✻✶✳·•*]\\s/;\n\nexport function splitFooter(lines: string[]): { body: string[]; footer: string[] } {\n let inp = -1;\n for (let i = lines.length - 1; i >= 0 && i >= lines.length - 8; i--) {\n const t = lines[i].replace(/\\s+$/, \"\");\n if (t.charCodeAt(0) === 0x276f || t[0] === \">\") {\n inp = i;\n break;\n }\n }\n let start: number;\n if (inp === -1) start = Math.max(0, lines.length - 3);\n else start = inp > 0 && RULE.test(lines[inp - 1].trim()) ? inp - 1 : inp;\n while (start > 0 && SPIN.test(lines[start - 1])) start--;\n return { body: lines.slice(0, start), footer: lines.slice(start) };\n}\n\nexport function mergeOverlap(hist: string[], body: string[]): string[] {\n const max = Math.min(hist.length, body.length);\n for (let k = max; k > 0; k--) {\n let ok = true;\n for (let i = 0; i < k; i++) {\n if (hist[hist.length - k + i] !== body[i]) {\n ok = false;\n break;\n }\n }\n if (ok) return hist.concat(body.slice(k));\n }\n return hist.concat(body);\n}\n\nexport class FrameAccumulator {\n private history: string[] = [];\n private footer: string[] = [];\n\n constructor(private readonly maxHistory = 5000) {}\n\n /** Feed a full frame snapshot; returns the updated view. */\n feed(content: string): FrameView {\n const lines = content.replace(/\\s+$/, \"\").split(\"\\n\");\n const { body, footer } = splitFooter(lines);\n this.history = mergeOverlap(this.history, body);\n if (this.history.length > this.maxHistory) {\n this.history = this.history.slice(-this.maxHistory);\n }\n this.footer = footer;\n return this.view;\n }\n\n get view(): FrameView {\n return { history: this.history, footer: this.footer };\n }\n\n /** Full rendered text (history + live footer). */\n get text(): string {\n return this.history.concat(this.footer).join(\"\\n\");\n }\n\n reset(): void {\n this.history = [];\n this.footer = [];\n }\n}\n"]}
@@ -0,0 +1,89 @@
1
+ /** A cc session registered on an edge (intercom channel snapshot). */
2
+ interface SetiRemoteSession {
3
+ ccSessionId: string | null;
4
+ sessionName: string | null;
5
+ cwd: string;
6
+ }
7
+ /** One edge host in the fleet roster. */
8
+ interface SetiEdge {
9
+ edgeId: string;
10
+ connected: boolean;
11
+ lastSeenMs: number;
12
+ connectedAtMs: number | null;
13
+ sessions: SetiRemoteSession[];
14
+ /**
15
+ * The tmux session names live on the edge — the STREAMABLE units. Stream and
16
+ * input target these by name (channel sessionNames can differ, e.g. container
17
+ * tmux "cc" vs channel "fly-arn-1-cc"). Empty = nothing streamable (M1 iTerm).
18
+ */
19
+ tmuxSessions: string[];
20
+ }
21
+ interface SetiRoster {
22
+ edges: SetiEdge[];
23
+ error?: string;
24
+ }
25
+ /** tmux key names accepted by input's `key` field (navigates cc's menus). */
26
+ declare const SETI_KEYS: readonly ["Escape", "Up", "Down", "Left", "Right", "Enter", "BSpace", "Tab"];
27
+ type SetiKey = (typeof SETI_KEYS)[number];
28
+ interface SetiInputResult {
29
+ ok: boolean;
30
+ edgeConnected: boolean;
31
+ error?: string;
32
+ }
33
+ type SetiStreamState = "connecting" | "open" | "reconnecting" | "closed";
34
+ interface SetiStreamHandlers {
35
+ /** First event after (re)connect: { edge, session, edgeConnected }. */
36
+ onHello?: (info: {
37
+ edge: string;
38
+ session: string;
39
+ edgeConnected: boolean;
40
+ }) => void;
41
+ /** A full capture-pane snapshot of the session's visible window. */
42
+ onFrame?: (content: string) => void;
43
+ /** Idle keep-alive carrying the latest edge connectivity. */
44
+ onPing?: (info: {
45
+ edgeConnected: boolean;
46
+ }) => void;
47
+ onStateChange?: (state: SetiStreamState) => void;
48
+ }
49
+ interface SetiStreamHandle {
50
+ close: () => void;
51
+ }
52
+
53
+ interface SetiClientOptions {
54
+ /**
55
+ * Base URL of the SETI surface. In a browser this is the host app's proxy
56
+ * mount (same-origin, e.g. "/api/seti" via @broberg/seti-server). Server-side
57
+ * it can be the cloud directly ("https://buddycloud.cc/api/seti/v1") together
58
+ * with `token`.
59
+ */
60
+ baseUrl: string;
61
+ /** Bearer token — only for server-side/direct use. NEVER ship to a browser. */
62
+ token?: string;
63
+ /** Override fetch (tests / custom runtimes). */
64
+ fetch?: typeof fetch;
65
+ }
66
+ /**
67
+ * Typed client for the SETI API (roster + SSE stream + input). One code path
68
+ * for browser and server: fetch-based SSE with automatic reconnect (1s → 5s
69
+ * backoff), so bearer headers work everywhere and no EventSource is needed.
70
+ */
71
+ declare class SetiClient {
72
+ private readonly base;
73
+ private readonly headers;
74
+ private readonly doFetch;
75
+ constructor(opts: SetiClientOptions);
76
+ listSessions(): Promise<SetiRoster>;
77
+ sendText(edge: string, session: string, text: string): Promise<SetiInputResult>;
78
+ sendKey(edge: string, session: string, key: SetiKey): Promise<SetiInputResult>;
79
+ private input;
80
+ /**
81
+ * Open the live frame stream for one edge session. Reconnects automatically
82
+ * until `close()` is called.
83
+ */
84
+ openStream(edge: string, session: string, handlers: SetiStreamHandlers): SetiStreamHandle;
85
+ /** Minimal SSE parser: `event:` + `data:` lines, events split on blank lines. */
86
+ private consume;
87
+ }
88
+
89
+ export { SETI_KEYS as S, SetiClient as a, type SetiClientOptions as b, type SetiEdge as c, type SetiInputResult as d, type SetiKey as e, type SetiRemoteSession as f, type SetiRoster as g, type SetiStreamHandle as h, type SetiStreamHandlers as i, type SetiStreamState as j };
@@ -0,0 +1,89 @@
1
+ /** A cc session registered on an edge (intercom channel snapshot). */
2
+ interface SetiRemoteSession {
3
+ ccSessionId: string | null;
4
+ sessionName: string | null;
5
+ cwd: string;
6
+ }
7
+ /** One edge host in the fleet roster. */
8
+ interface SetiEdge {
9
+ edgeId: string;
10
+ connected: boolean;
11
+ lastSeenMs: number;
12
+ connectedAtMs: number | null;
13
+ sessions: SetiRemoteSession[];
14
+ /**
15
+ * The tmux session names live on the edge — the STREAMABLE units. Stream and
16
+ * input target these by name (channel sessionNames can differ, e.g. container
17
+ * tmux "cc" vs channel "fly-arn-1-cc"). Empty = nothing streamable (M1 iTerm).
18
+ */
19
+ tmuxSessions: string[];
20
+ }
21
+ interface SetiRoster {
22
+ edges: SetiEdge[];
23
+ error?: string;
24
+ }
25
+ /** tmux key names accepted by input's `key` field (navigates cc's menus). */
26
+ declare const SETI_KEYS: readonly ["Escape", "Up", "Down", "Left", "Right", "Enter", "BSpace", "Tab"];
27
+ type SetiKey = (typeof SETI_KEYS)[number];
28
+ interface SetiInputResult {
29
+ ok: boolean;
30
+ edgeConnected: boolean;
31
+ error?: string;
32
+ }
33
+ type SetiStreamState = "connecting" | "open" | "reconnecting" | "closed";
34
+ interface SetiStreamHandlers {
35
+ /** First event after (re)connect: { edge, session, edgeConnected }. */
36
+ onHello?: (info: {
37
+ edge: string;
38
+ session: string;
39
+ edgeConnected: boolean;
40
+ }) => void;
41
+ /** A full capture-pane snapshot of the session's visible window. */
42
+ onFrame?: (content: string) => void;
43
+ /** Idle keep-alive carrying the latest edge connectivity. */
44
+ onPing?: (info: {
45
+ edgeConnected: boolean;
46
+ }) => void;
47
+ onStateChange?: (state: SetiStreamState) => void;
48
+ }
49
+ interface SetiStreamHandle {
50
+ close: () => void;
51
+ }
52
+
53
+ interface SetiClientOptions {
54
+ /**
55
+ * Base URL of the SETI surface. In a browser this is the host app's proxy
56
+ * mount (same-origin, e.g. "/api/seti" via @broberg/seti-server). Server-side
57
+ * it can be the cloud directly ("https://buddycloud.cc/api/seti/v1") together
58
+ * with `token`.
59
+ */
60
+ baseUrl: string;
61
+ /** Bearer token — only for server-side/direct use. NEVER ship to a browser. */
62
+ token?: string;
63
+ /** Override fetch (tests / custom runtimes). */
64
+ fetch?: typeof fetch;
65
+ }
66
+ /**
67
+ * Typed client for the SETI API (roster + SSE stream + input). One code path
68
+ * for browser and server: fetch-based SSE with automatic reconnect (1s → 5s
69
+ * backoff), so bearer headers work everywhere and no EventSource is needed.
70
+ */
71
+ declare class SetiClient {
72
+ private readonly base;
73
+ private readonly headers;
74
+ private readonly doFetch;
75
+ constructor(opts: SetiClientOptions);
76
+ listSessions(): Promise<SetiRoster>;
77
+ sendText(edge: string, session: string, text: string): Promise<SetiInputResult>;
78
+ sendKey(edge: string, session: string, key: SetiKey): Promise<SetiInputResult>;
79
+ private input;
80
+ /**
81
+ * Open the live frame stream for one edge session. Reconnects automatically
82
+ * until `close()` is called.
83
+ */
84
+ openStream(edge: string, session: string, handlers: SetiStreamHandlers): SetiStreamHandle;
85
+ /** Minimal SSE parser: `event:` + `data:` lines, events split on blank lines. */
86
+ private consume;
87
+ }
88
+
89
+ export { SETI_KEYS as S, SetiClient as a, type SetiClientOptions as b, type SetiEdge as c, type SetiInputResult as d, type SetiKey as e, type SetiRemoteSession as f, type SetiRoster as g, type SetiStreamHandle as h, type SetiStreamHandlers as i, type SetiStreamState as j };
package/dist/index.cjs ADDED
@@ -0,0 +1,195 @@
1
+ 'use strict';
2
+
3
+ // src/client.ts
4
+ var SetiClient = class {
5
+ base;
6
+ headers;
7
+ doFetch;
8
+ constructor(opts) {
9
+ this.base = opts.baseUrl.replace(/\/$/, "");
10
+ this.headers = opts.token ? { Authorization: `Bearer ${opts.token}` } : {};
11
+ this.doFetch = opts.fetch ?? globalThis.fetch.bind(globalThis);
12
+ }
13
+ async listSessions() {
14
+ const res = await this.doFetch(`${this.base}/sessions`, { headers: this.headers });
15
+ if (!res.ok) return { edges: [], error: `http_${res.status}` };
16
+ return await res.json();
17
+ }
18
+ async sendText(edge, session, text) {
19
+ return this.input({ edge, session, text });
20
+ }
21
+ async sendKey(edge, session, key) {
22
+ return this.input({ edge, session, key });
23
+ }
24
+ async input(body) {
25
+ try {
26
+ const res = await this.doFetch(`${this.base}/input`, {
27
+ method: "POST",
28
+ headers: { ...this.headers, "content-type": "application/json" },
29
+ body: JSON.stringify(body),
30
+ signal: AbortSignal.timeout(8e3)
31
+ });
32
+ const json = await res.json().catch(() => ({}));
33
+ return { ok: !!json.ok, edgeConnected: !!json.edgeConnected, error: json.error };
34
+ } catch (err) {
35
+ return { ok: false, edgeConnected: false, error: err instanceof Error ? err.message : "send_failed" };
36
+ }
37
+ }
38
+ /**
39
+ * Open the live frame stream for one edge session. Reconnects automatically
40
+ * until `close()` is called.
41
+ */
42
+ openStream(edge, session, handlers) {
43
+ let closed = false;
44
+ let controller = null;
45
+ const run = async () => {
46
+ let attempt = 0;
47
+ while (!closed) {
48
+ handlers.onStateChange?.(attempt === 0 ? "connecting" : "reconnecting");
49
+ controller = new AbortController();
50
+ try {
51
+ const res = await this.doFetch(
52
+ `${this.base}/stream?edge=${encodeURIComponent(edge)}&session=${encodeURIComponent(session)}`,
53
+ { headers: { ...this.headers, accept: "text/event-stream" }, signal: controller.signal }
54
+ );
55
+ if (!res.ok || !res.body) throw new Error(`http_${res.status}`);
56
+ handlers.onStateChange?.("open");
57
+ attempt = 0;
58
+ await this.consume(res.body, handlers);
59
+ } catch {
60
+ }
61
+ if (closed) break;
62
+ attempt++;
63
+ await new Promise((r) => setTimeout(r, Math.min(1e3 * attempt, 5e3)));
64
+ }
65
+ handlers.onStateChange?.("closed");
66
+ };
67
+ void run();
68
+ return {
69
+ close: () => {
70
+ closed = true;
71
+ controller?.abort();
72
+ }
73
+ };
74
+ }
75
+ /** Minimal SSE parser: `event:` + `data:` lines, events split on blank lines. */
76
+ async consume(body, handlers) {
77
+ const reader = body.getReader();
78
+ const decoder = new TextDecoder();
79
+ let buf = "";
80
+ for (; ; ) {
81
+ const { done, value } = await reader.read();
82
+ if (done) break;
83
+ buf += decoder.decode(value, { stream: true });
84
+ let sep;
85
+ while ((sep = buf.indexOf("\n\n")) !== -1) {
86
+ const chunk = buf.slice(0, sep);
87
+ buf = buf.slice(sep + 2);
88
+ let event = "message";
89
+ const data = [];
90
+ for (const line of chunk.split("\n")) {
91
+ if (line.startsWith("event:")) event = line.slice(6).trim();
92
+ else if (line.startsWith("data:")) data.push(line.slice(5).trimStart());
93
+ }
94
+ if (data.length === 0) continue;
95
+ let parsed;
96
+ try {
97
+ parsed = JSON.parse(data.join("\n"));
98
+ } catch {
99
+ continue;
100
+ }
101
+ if (event === "frame") {
102
+ const c = parsed.content;
103
+ if (typeof c === "string") handlers.onFrame?.(c);
104
+ } else if (event === "hello") {
105
+ handlers.onHello?.(parsed);
106
+ } else if (event === "ping") {
107
+ handlers.onPing?.(parsed);
108
+ }
109
+ }
110
+ }
111
+ }
112
+ };
113
+
114
+ // src/frame-accumulator.ts
115
+ var RULE = /^[─━-]{10,}\s*$/;
116
+ var SPIN = /^[✻✶✳·•*]\s/;
117
+ function splitFooter(lines) {
118
+ let inp = -1;
119
+ for (let i = lines.length - 1; i >= 0 && i >= lines.length - 8; i--) {
120
+ const t = lines[i].replace(/\s+$/, "");
121
+ if (t.charCodeAt(0) === 10095 || t[0] === ">") {
122
+ inp = i;
123
+ break;
124
+ }
125
+ }
126
+ let start;
127
+ if (inp === -1) start = Math.max(0, lines.length - 3);
128
+ else start = inp > 0 && RULE.test(lines[inp - 1].trim()) ? inp - 1 : inp;
129
+ while (start > 0 && SPIN.test(lines[start - 1])) start--;
130
+ return { body: lines.slice(0, start), footer: lines.slice(start) };
131
+ }
132
+ function mergeOverlap(hist, body) {
133
+ const max = Math.min(hist.length, body.length);
134
+ for (let k = max; k > 0; k--) {
135
+ let ok = true;
136
+ for (let i = 0; i < k; i++) {
137
+ if (hist[hist.length - k + i] !== body[i]) {
138
+ ok = false;
139
+ break;
140
+ }
141
+ }
142
+ if (ok) return hist.concat(body.slice(k));
143
+ }
144
+ return hist.concat(body);
145
+ }
146
+ var FrameAccumulator = class {
147
+ constructor(maxHistory = 5e3) {
148
+ this.maxHistory = maxHistory;
149
+ }
150
+ maxHistory;
151
+ history = [];
152
+ footer = [];
153
+ /** Feed a full frame snapshot; returns the updated view. */
154
+ feed(content) {
155
+ const lines = content.replace(/\s+$/, "").split("\n");
156
+ const { body, footer } = splitFooter(lines);
157
+ this.history = mergeOverlap(this.history, body);
158
+ if (this.history.length > this.maxHistory) {
159
+ this.history = this.history.slice(-this.maxHistory);
160
+ }
161
+ this.footer = footer;
162
+ return this.view;
163
+ }
164
+ get view() {
165
+ return { history: this.history, footer: this.footer };
166
+ }
167
+ /** Full rendered text (history + live footer). */
168
+ get text() {
169
+ return this.history.concat(this.footer).join("\n");
170
+ }
171
+ reset() {
172
+ this.history = [];
173
+ this.footer = [];
174
+ }
175
+ };
176
+
177
+ // src/types.ts
178
+ var SETI_KEYS = [
179
+ "Escape",
180
+ "Up",
181
+ "Down",
182
+ "Left",
183
+ "Right",
184
+ "Enter",
185
+ "BSpace",
186
+ "Tab"
187
+ ];
188
+
189
+ exports.FrameAccumulator = FrameAccumulator;
190
+ exports.SETI_KEYS = SETI_KEYS;
191
+ exports.SetiClient = SetiClient;
192
+ exports.mergeOverlap = mergeOverlap;
193
+ exports.splitFooter = splitFooter;
194
+ //# sourceMappingURL=index.cjs.map
195
+ //# sourceMappingURL=index.cjs.map