@soba-so/react 0.0.1

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/index.mjs ADDED
@@ -0,0 +1,930 @@
1
+ "use client"
2
+
3
+ // ../connect-ui/src/client.ts
4
+ var ConnectError = class extends Error {
5
+ status;
6
+ constructor(message, status) {
7
+ super(message);
8
+ this.name = "ConnectError";
9
+ this.status = status;
10
+ }
11
+ };
12
+ var ConnectClient = class {
13
+ session;
14
+ baseUrl;
15
+ doFetch;
16
+ constructor(opts) {
17
+ this.session = opts.session;
18
+ this.baseUrl = (opts.baseUrl ?? "").replace(/\/+$/, "");
19
+ this.doFetch = opts.fetchImpl ?? fetch.bind(globalThis);
20
+ }
21
+ context() {
22
+ return this.request("GET", "/v1/connect");
23
+ }
24
+ async machines() {
25
+ const body = await this.request("GET", "/v1/machines");
26
+ return body.machines;
27
+ }
28
+ lookup(code) {
29
+ return this.request("GET", `/v1/device/lookup?code=${encodeURIComponent(code)}`);
30
+ }
31
+ approve(code) {
32
+ return this.request("POST", "/v1/device/approve", { user_code: code });
33
+ }
34
+ deny(code) {
35
+ return this.request("POST", "/v1/device/deny", { user_code: code });
36
+ }
37
+ /** Let a runtime that appeared after pairing start serving. It can only ever
38
+ * widen this machine's own advertisement, never its grant. */
39
+ approveRuntime(machineId, runtimeId) {
40
+ return this.request("POST", "/v1/runtimes/approve", {
41
+ machine_id: machineId,
42
+ runtime_id: runtimeId
43
+ });
44
+ }
45
+ async request(method, path, body) {
46
+ const res = await this.doFetch(`${this.baseUrl}${path}`, {
47
+ method,
48
+ headers: {
49
+ // In a header, never a query string. The session came out of a URL
50
+ // fragment for the same reason: a query string reaches access logs,
51
+ // proxy logs and the `Referer` of every link on the page.
52
+ authorization: `Bearer ${this.session}`,
53
+ ...body ? { "content-type": "application/json" } : {}
54
+ },
55
+ ...body ? { body: JSON.stringify(body) } : {}
56
+ });
57
+ let payload = {};
58
+ try {
59
+ payload = await res.json();
60
+ } catch {
61
+ }
62
+ if (!res.ok) {
63
+ const message = typeof payload.error === "string" ? payload.error : `Request failed (${res.status})`;
64
+ throw new ConnectError(message, res.status);
65
+ }
66
+ return payload;
67
+ }
68
+ };
69
+ function authLabel(runtime) {
70
+ if (runtime.authenticated === true) return { tone: "ok", text: "signed in" };
71
+ if (runtime.authenticated === false) {
72
+ return { tone: "warn", text: runtime.auth_hint ?? "signed out on that machine" };
73
+ }
74
+ return { tone: "unknown", text: "could not tell" };
75
+ }
76
+ function stateLabel(machine) {
77
+ if (machine.state === "busy")
78
+ return { tone: "busy", text: `serving ${machine.active} of ${machine.capacity}` };
79
+ if (machine.state === "online") return { tone: "ok", text: "connected" };
80
+ return { tone: "off", text: "asleep or offline" };
81
+ }
82
+
83
+ // ../connect-ui/src/ComputeStatus.tsx
84
+ import { jsx, jsxs } from "react/jsx-runtime";
85
+ function ComputeStatus({
86
+ machines,
87
+ onApproveRuntime
88
+ }) {
89
+ if (!machines.length) {
90
+ return /* @__PURE__ */ jsx("p", { className: "sc-note", children: "Nothing connected yet." });
91
+ }
92
+ return /* @__PURE__ */ jsx("div", { children: machines.map((m) => {
93
+ const state = stateLabel(m);
94
+ return /* @__PURE__ */ jsxs("div", { className: "sc-machine", children: [
95
+ /* @__PURE__ */ jsxs("div", { className: "sc-row-between", children: [
96
+ /* @__PURE__ */ jsxs("div", { className: "sc-row", children: [
97
+ /* @__PURE__ */ jsx("span", { className: `sc-dot sc-dot-${state.tone}`, "aria-hidden": "true" }),
98
+ /* @__PURE__ */ jsx("strong", { children: m.label ?? "This machine" })
99
+ ] }),
100
+ /* @__PURE__ */ jsx("span", { className: "sc-note", children: state.text })
101
+ ] }),
102
+ /* @__PURE__ */ jsx("p", { className: "sc-note", children: [m.platform, m.arch, m.version && `worker ${m.version}`, m.always_on && "always on"].filter(Boolean).join(" \xB7 ") }),
103
+ /* @__PURE__ */ jsxs("ul", { className: "sc-runtimes", children: [
104
+ m.runtimes.map((r) => {
105
+ const auth = authLabel(r);
106
+ if (r.quarantined) {
107
+ return /* @__PURE__ */ jsxs("li", { className: "sc-row-between", children: [
108
+ /* @__PURE__ */ jsx("span", { children: r.display_name }),
109
+ onApproveRuntime ? /* @__PURE__ */ jsx(
110
+ "button",
111
+ {
112
+ type: "button",
113
+ className: "sc-btn",
114
+ onClick: () => onApproveRuntime(m.id, r.id),
115
+ children: "New here. Let it serve"
116
+ }
117
+ ) : /* @__PURE__ */ jsx("span", { className: "sc-tone-warn", children: "new, not serving yet" })
118
+ ] }, r.id);
119
+ }
120
+ return /* @__PURE__ */ jsxs("li", { className: "sc-row-between", children: [
121
+ /* @__PURE__ */ jsx("span", { children: r.display_name }),
122
+ /* @__PURE__ */ jsx("span", { className: `sc-tone-${auth.tone}`, children: auth.text })
123
+ ] }, r.id);
124
+ }),
125
+ !m.runtimes.length && /* @__PURE__ */ jsx("li", { className: "sc-note", children: "No runtime advertised. Install Claude Code, Codex or Ollama on that machine." })
126
+ ] })
127
+ ] }, m.id);
128
+ }) });
129
+ }
130
+
131
+ // ../connect-ui/src/ConnectPanel.tsx
132
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
133
+
134
+ // ../connect-ui/src/runtimes.ts
135
+ var RUNTIME_CHOICES = [
136
+ {
137
+ id: "claude",
138
+ title: "The Claude plan you already pay for",
139
+ cost: "Nothing beyond the plan",
140
+ prerequisite: "Claude Code installed, and signed in",
141
+ hint: "Install it from claude.ai/code, then run `claude` once and sign in.",
142
+ expects: ["claude-code"]
143
+ },
144
+ {
145
+ id: "chatgpt",
146
+ title: "The ChatGPT plan you already pay for",
147
+ cost: "Nothing beyond the plan",
148
+ prerequisite: "Codex installed, and signed in",
149
+ hint: "Install Codex, then run `codex` once and sign in.",
150
+ expects: ["codex"]
151
+ },
152
+ {
153
+ id: "local",
154
+ title: "A model on your own machine",
155
+ cost: "Free. You bought the hardware",
156
+ prerequisite: "Ollama running, with a model pulled",
157
+ hint: "Install Ollama, then `ollama pull` a model before pairing.",
158
+ expects: ["ollama", "openai-api"]
159
+ }
160
+ ];
161
+
162
+ // ../connect-ui/src/ConnectPanel.tsx
163
+ import { Fragment, jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
164
+ var POLL_MS = 2e3;
165
+ var STATUS_POLL_MS = 1e4;
166
+ function ConnectPanel({
167
+ client,
168
+ initialCode,
169
+ onConnected,
170
+ showStatus = true
171
+ }) {
172
+ const [phase, setPhase] = useState("loading");
173
+ const [context, setContext] = useState(null);
174
+ const [machines, setMachines] = useState([]);
175
+ const [choice, setChoice] = useState(null);
176
+ const [code, setCode] = useState(initialCode ?? "");
177
+ const [pending, setPending] = useState(null);
178
+ const [error, setError] = useState(null);
179
+ const [busy, setBusy] = useState(false);
180
+ const baseline = useRef(null);
181
+ useEffect(() => {
182
+ let live = true;
183
+ Promise.all([client.context(), client.machines()]).then(([ctx, list]) => {
184
+ if (!live) return;
185
+ setContext(ctx);
186
+ setMachines(list);
187
+ baseline.current = new Set(list.map((m) => m.id));
188
+ if (initialCode) setPhase("code");
189
+ else if (showStatus && list.length > 0) setPhase("manage");
190
+ else setPhase("choose");
191
+ }).catch((err) => {
192
+ if (!live) return;
193
+ setError(err.message);
194
+ setPhase("choose");
195
+ });
196
+ return () => {
197
+ live = false;
198
+ };
199
+ }, [client, initialCode, showStatus]);
200
+ useEffect(() => {
201
+ if (phase !== "manage") return;
202
+ let live = true;
203
+ const timer = setInterval(async () => {
204
+ try {
205
+ const list = await client.machines();
206
+ if (live) setMachines(list);
207
+ } catch {
208
+ }
209
+ }, STATUS_POLL_MS);
210
+ return () => {
211
+ live = false;
212
+ clearInterval(timer);
213
+ };
214
+ }, [phase, client]);
215
+ useEffect(() => {
216
+ if (phase !== "waiting") return;
217
+ let live = true;
218
+ const timer = setInterval(async () => {
219
+ try {
220
+ const list = await client.machines();
221
+ if (!live) return;
222
+ setMachines(list);
223
+ const fresh = list.filter((m) => !baseline.current?.has(m.id));
224
+ const arrived = fresh.find((m) => m.state !== "offline") ?? fresh[0];
225
+ if (arrived) {
226
+ setPhase("connected");
227
+ onConnected?.(list);
228
+ }
229
+ } catch {
230
+ }
231
+ }, POLL_MS);
232
+ return () => {
233
+ live = false;
234
+ clearInterval(timer);
235
+ };
236
+ }, [phase, client, onConnected]);
237
+ const lookup = useCallback(async () => {
238
+ setError(null);
239
+ setBusy(true);
240
+ try {
241
+ const found = await client.lookup(code);
242
+ setPending(found);
243
+ setPhase("confirm");
244
+ } catch (err) {
245
+ setError(err.message);
246
+ } finally {
247
+ setBusy(false);
248
+ }
249
+ }, [client, code]);
250
+ const approve = useCallback(async () => {
251
+ setError(null);
252
+ setBusy(true);
253
+ try {
254
+ await client.approve(code);
255
+ setPhase("waiting");
256
+ } catch (err) {
257
+ setError(err.message);
258
+ } finally {
259
+ setBusy(false);
260
+ }
261
+ }, [client, code]);
262
+ const deny = useCallback(async () => {
263
+ setBusy(true);
264
+ try {
265
+ await client.deny(code);
266
+ setPhase("denied");
267
+ } catch (err) {
268
+ setError(err.message);
269
+ } finally {
270
+ setBusy(false);
271
+ }
272
+ }, [client, code]);
273
+ const command = useMemo(() => context?.command ?? null, [context]);
274
+ const approveRuntime = useCallback(
275
+ async (machineId, runtimeId) => {
276
+ try {
277
+ await client.approveRuntime(machineId, runtimeId);
278
+ setMachines(await client.machines());
279
+ } catch (err) {
280
+ setError(err.message);
281
+ }
282
+ },
283
+ [client]
284
+ );
285
+ if (phase === "loading") {
286
+ return /* @__PURE__ */ jsx2("div", { className: "soba-connect", children: /* @__PURE__ */ jsx2("div", { className: "sc-card", children: /* @__PURE__ */ jsx2("p", { className: "sc-note", children: "Loading\u2026" }) }) });
287
+ }
288
+ return /* @__PURE__ */ jsxs2("div", { className: "soba-connect", children: [
289
+ phase === "manage" && /* @__PURE__ */ jsxs2("div", { className: "sc-card", children: [
290
+ /* @__PURE__ */ jsx2("h2", { className: "sc-title", children: "Your compute" }),
291
+ /* @__PURE__ */ jsxs2("p", { className: "sc-sub", children: [
292
+ context?.app.name ?? "This app",
293
+ " runs on these machines, while they are awake."
294
+ ] }),
295
+ /* @__PURE__ */ jsx2(ComputeStatus, { machines, onApproveRuntime: approveRuntime }),
296
+ error && /* @__PURE__ */ jsx2("p", { className: "sc-error", children: error }),
297
+ /* @__PURE__ */ jsx2(
298
+ "button",
299
+ {
300
+ type: "button",
301
+ className: "sc-btn",
302
+ style: { marginTop: 16 },
303
+ onClick: () => setPhase("choose"),
304
+ children: "Connect another machine"
305
+ }
306
+ )
307
+ ] }),
308
+ phase === "choose" && /* @__PURE__ */ jsxs2("div", { className: "sc-card", children: [
309
+ /* @__PURE__ */ jsx2("h2", { className: "sc-title", children: "Use the AI you already pay for" }),
310
+ /* @__PURE__ */ jsxs2("p", { className: "sc-sub", children: [
311
+ context?.app.name ?? "This app",
312
+ " can run on your machine instead of selling you credits. Pick what you already have."
313
+ ] }),
314
+ /* @__PURE__ */ jsx2("div", { className: "sc-choices", children: RUNTIME_CHOICES.map((c) => /* @__PURE__ */ jsxs2(
315
+ "button",
316
+ {
317
+ type: "button",
318
+ className: "sc-choice",
319
+ "aria-pressed": choice?.id === c.id,
320
+ onClick: () => setChoice(c),
321
+ children: [
322
+ /* @__PURE__ */ jsx2("span", { className: "sc-choice-title", children: c.title }),
323
+ /* @__PURE__ */ jsx2("br", {}),
324
+ /* @__PURE__ */ jsxs2("span", { className: "sc-choice-meta", children: [
325
+ c.cost,
326
+ " \xB7 ",
327
+ c.prerequisite
328
+ ] })
329
+ ]
330
+ },
331
+ c.id
332
+ )) }),
333
+ choice && /* @__PURE__ */ jsxs2("div", { className: "sc-stack", style: { marginTop: 16 }, children: [
334
+ /* @__PURE__ */ jsx2("p", { className: "sc-note", children: choice.hint }),
335
+ command ? /* @__PURE__ */ jsxs2(Fragment, { children: [
336
+ /* @__PURE__ */ jsx2("p", { className: "sc-label", children: "Run this on the machine you want to use" }),
337
+ /* @__PURE__ */ jsx2("code", { className: "sc-code", children: command }),
338
+ /* @__PURE__ */ jsx2("p", { className: "sc-note", children: "It will show you a short code. Nothing is pasted back: the credential is written straight to that machine." }),
339
+ /* @__PURE__ */ jsx2(
340
+ "button",
341
+ {
342
+ type: "button",
343
+ className: "sc-btn sc-btn-primary",
344
+ onClick: () => setPhase("code"),
345
+ children: "I have a code"
346
+ }
347
+ )
348
+ ] }) : /* @__PURE__ */ jsx2("p", { className: "sc-error", children: "This app has no publishable key yet, so there is nothing to pair against." })
349
+ ] })
350
+ ] }),
351
+ phase === "code" && /* @__PURE__ */ jsxs2("div", { className: "sc-card", children: [
352
+ /* @__PURE__ */ jsx2("h2", { className: "sc-title", children: "Enter the code from your terminal" }),
353
+ /* @__PURE__ */ jsx2("p", { className: "sc-sub", children: "Eight characters, shown after you ran the command." }),
354
+ /* @__PURE__ */ jsxs2(
355
+ "form",
356
+ {
357
+ className: "sc-stack",
358
+ onSubmit: (e) => {
359
+ e.preventDefault();
360
+ void lookup();
361
+ },
362
+ children: [
363
+ /* @__PURE__ */ jsx2(
364
+ "input",
365
+ {
366
+ className: "sc-input",
367
+ value: code,
368
+ onChange: (e) => setCode(e.target.value),
369
+ placeholder: "ACDE-F234",
370
+ autoComplete: "off",
371
+ autoFocus: true,
372
+ "aria-label": "Pairing code"
373
+ }
374
+ ),
375
+ error && /* @__PURE__ */ jsx2("p", { className: "sc-error", children: error }),
376
+ /* @__PURE__ */ jsx2(
377
+ "button",
378
+ {
379
+ type: "submit",
380
+ className: "sc-btn sc-btn-primary",
381
+ disabled: busy || code.length < 8,
382
+ children: busy ? "Checking\u2026" : "Continue"
383
+ }
384
+ )
385
+ ]
386
+ }
387
+ )
388
+ ] }),
389
+ phase === "confirm" && pending && /* @__PURE__ */ jsxs2("div", { className: "sc-card", children: [
390
+ /* @__PURE__ */ jsx2("h2", { className: "sc-title", children: "Attach this machine to your account?" }),
391
+ /* @__PURE__ */ jsxs2("p", { className: "sc-sub", children: [
392
+ "Once you approve, ",
393
+ context?.app.name ?? "this app",
394
+ " may send your runs to it. It can never see the account or the keys on it."
395
+ ] }),
396
+ /* @__PURE__ */ jsxs2("ul", { className: "sc-facts", children: [
397
+ /* @__PURE__ */ jsx2(Fact, { k: "Code", v: pending.user_code }),
398
+ /* @__PURE__ */ jsx2(Fact, { k: "Machine", v: pending.hostname ?? "not stated" }),
399
+ /* @__PURE__ */ jsx2(
400
+ Fact,
401
+ {
402
+ k: "System",
403
+ v: [pending.platform, pending.arch].filter(Boolean).join(" / ") || "not stated"
404
+ }
405
+ ),
406
+ /* @__PURE__ */ jsx2(Fact, { k: "Asked by", v: pending.client ?? "not stated" }),
407
+ pending.labels.length > 0 && /* @__PURE__ */ jsx2(Fact, { k: "Labels", v: pending.labels.join(", ") })
408
+ ] }),
409
+ /* @__PURE__ */ jsx2("p", { className: "sc-note", style: { marginTop: 12 }, children: "Every line above is what that machine said about itself. If none of it looks like a computer of yours, say no." }),
410
+ error && /* @__PURE__ */ jsx2("p", { className: "sc-error", children: error }),
411
+ /* @__PURE__ */ jsxs2("div", { className: "sc-row", style: { marginTop: 16 }, children: [
412
+ /* @__PURE__ */ jsx2(
413
+ "button",
414
+ {
415
+ type: "button",
416
+ className: "sc-btn sc-btn-primary",
417
+ onClick: () => void approve(),
418
+ disabled: busy,
419
+ children: "Approve"
420
+ }
421
+ ),
422
+ /* @__PURE__ */ jsx2("button", { type: "button", className: "sc-btn", onClick: () => void deny(), disabled: busy, children: "No, deny it" })
423
+ ] })
424
+ ] }),
425
+ phase === "waiting" && /* @__PURE__ */ jsxs2("div", { className: "sc-card", children: [
426
+ /* @__PURE__ */ jsx2("h2", { className: "sc-title", children: "Approved. Waiting for it to connect\u2026" }),
427
+ /* @__PURE__ */ jsx2("p", { className: "sc-sub", children: "The worker is starting on that machine. This page will change on its own the moment it arrives." }),
428
+ /* @__PURE__ */ jsx2("p", { className: "sc-note", children: "If nothing happens, look at the terminal you ran the command in: it says what it is doing." })
429
+ ] }),
430
+ phase === "connected" && /* @__PURE__ */ jsxs2("div", { className: "sc-card", children: [
431
+ /* @__PURE__ */ jsx2("h2", { className: "sc-title", children: "Connected" }),
432
+ /* @__PURE__ */ jsxs2("p", { className: "sc-sub", children: [
433
+ context?.app.name ?? "This app",
434
+ " will use this machine from now on, while it is awake."
435
+ ] }),
436
+ showStatus && /* @__PURE__ */ jsxs2(Fragment, { children: [
437
+ /* @__PURE__ */ jsx2(ComputeStatus, { machines, onApproveRuntime: approveRuntime }),
438
+ /* @__PURE__ */ jsx2(
439
+ "button",
440
+ {
441
+ type: "button",
442
+ className: "sc-btn",
443
+ style: { marginTop: 16 },
444
+ onClick: () => setPhase("manage"),
445
+ children: "Done"
446
+ }
447
+ )
448
+ ] })
449
+ ] }),
450
+ phase === "denied" && /* @__PURE__ */ jsxs2("div", { className: "sc-card", children: [
451
+ /* @__PURE__ */ jsx2("h2", { className: "sc-title", children: "Declined" }),
452
+ /* @__PURE__ */ jsx2("p", { className: "sc-sub", children: "That machine was not attached to your account, and the terminal has been told so." })
453
+ ] })
454
+ ] });
455
+ }
456
+ function Fact({ k, v }) {
457
+ return /* @__PURE__ */ jsxs2("li", { className: "sc-fact", children: [
458
+ /* @__PURE__ */ jsx2("span", { className: "sc-fact-key", children: k }),
459
+ /* @__PURE__ */ jsx2("span", { children: v })
460
+ ] });
461
+ }
462
+
463
+ // src/context.ts
464
+ import { createContext, useContext } from "react";
465
+ var SobaContext = createContext(null);
466
+ function useSoba() {
467
+ const value = useContext(SobaContext);
468
+ if (!value) {
469
+ throw new Error("useSoba() must be used inside a <SobaProvider>.");
470
+ }
471
+ return value;
472
+ }
473
+
474
+ // src/ComputeStatus.tsx
475
+ import { jsx as jsx3 } from "react/jsx-runtime";
476
+ function ComputeStatus2({ className }) {
477
+ const { compute } = useSoba();
478
+ const classes = className ? `soba-connect ${className}` : "soba-connect";
479
+ if (compute.loading && !compute.machines.length) {
480
+ return /* @__PURE__ */ jsx3("div", { className: classes, children: /* @__PURE__ */ jsx3("p", { className: "sc-note", children: "Loading\u2026" }) });
481
+ }
482
+ if (compute.error && !compute.machines.length) {
483
+ return /* @__PURE__ */ jsx3("div", { className: classes, children: /* @__PURE__ */ jsx3("p", { className: "sc-error", children: compute.error.message }) });
484
+ }
485
+ return /* @__PURE__ */ jsx3("div", { className: classes, children: /* @__PURE__ */ jsx3(ComputeStatus, { machines: compute.machines }) });
486
+ }
487
+
488
+ // src/internal.ts
489
+ import { createContext as createContext2, useContext as useContext2 } from "react";
490
+ var ClientContext = createContext2(null);
491
+ function useConnectClient() {
492
+ return useContext2(ClientContext);
493
+ }
494
+
495
+ // src/ConnectCompute.tsx
496
+ import { jsx as jsx4 } from "react/jsx-runtime";
497
+ function ConnectCompute({
498
+ initialCode,
499
+ onConnected,
500
+ className,
501
+ showStatus
502
+ }) {
503
+ const { error } = useSoba();
504
+ const client = useConnectClient();
505
+ if (error) {
506
+ return /* @__PURE__ */ jsx4("div", { className: wrapper(className), children: /* @__PURE__ */ jsx4("div", { className: "sc-card", children: /* @__PURE__ */ jsx4("p", { className: "sc-error", children: error.message }) }) });
507
+ }
508
+ if (!client) {
509
+ return /* @__PURE__ */ jsx4("div", { className: wrapper(className), children: /* @__PURE__ */ jsx4("div", { className: "sc-card", children: /* @__PURE__ */ jsx4("p", { className: "sc-note", children: "Loading\u2026" }) }) });
510
+ }
511
+ return /* @__PURE__ */ jsx4("div", { className, children: /* @__PURE__ */ jsx4(
512
+ ConnectPanel,
513
+ {
514
+ client,
515
+ ...initialCode ? { initialCode } : {},
516
+ ...onConnected ? { onConnected } : {},
517
+ ...showStatus === void 0 ? {} : { showStatus }
518
+ }
519
+ ) });
520
+ }
521
+ function wrapper(className) {
522
+ return className ? `soba-connect ${className}` : "soba-connect";
523
+ }
524
+
525
+ // src/events.ts
526
+ var EVENT_TYPES = /* @__PURE__ */ new Set(["delta", "thinking", "status", "done", "error"]);
527
+ function parseEvent(raw) {
528
+ let parsed;
529
+ try {
530
+ parsed = JSON.parse(raw);
531
+ } catch {
532
+ return null;
533
+ }
534
+ if (!parsed || typeof parsed !== "object") return null;
535
+ const frame = parsed;
536
+ const body = frame.type === "event" ? frame.event : frame;
537
+ if (!body || typeof body !== "object") return null;
538
+ const o = body;
539
+ if (typeof o.type !== "string" || !EVENT_TYPES.has(o.type)) return null;
540
+ const event = { type: o.type };
541
+ if (typeof o.text === "string") event.text = o.text;
542
+ if (typeof o.message === "string") event.message = o.message;
543
+ if (typeof o.tool === "string") event.tool = o.tool;
544
+ if (typeof o.tier === "string") event.tier = o.tier;
545
+ if (o.usage && typeof o.usage === "object") event.usage = o.usage;
546
+ return event;
547
+ }
548
+ async function* readEvents(response) {
549
+ const body = response.body;
550
+ if (!body) throw new Error("That run response had no body to stream.");
551
+ const ndjson = (response.headers.get("content-type") ?? "").includes("ndjson");
552
+ const reader = body.getReader();
553
+ const decoder = new TextDecoder();
554
+ let buffer = "";
555
+ let data = [];
556
+ try {
557
+ for (; ; ) {
558
+ const chunk = await reader.read();
559
+ if (chunk.done) break;
560
+ buffer += decoder.decode(chunk.value, { stream: true });
561
+ for (; ; ) {
562
+ const nl = buffer.indexOf("\n");
563
+ if (nl === -1) break;
564
+ const line = stripCr(buffer.slice(0, nl));
565
+ buffer = buffer.slice(nl + 1);
566
+ if (ndjson) {
567
+ const event = line.trim() ? parseEvent(line) : null;
568
+ if (event) yield event;
569
+ continue;
570
+ }
571
+ if (line === "") {
572
+ const payload = data.join("\n");
573
+ data = [];
574
+ if (payload === "[DONE]") return;
575
+ const event = payload ? parseEvent(payload) : null;
576
+ if (event) yield event;
577
+ continue;
578
+ }
579
+ collect(line, data);
580
+ }
581
+ }
582
+ if (!ndjson && buffer) collect(stripCr(buffer), data);
583
+ const tail = ndjson ? buffer.trim() : data.join("\n");
584
+ if (tail && tail !== "[DONE]") {
585
+ const event = parseEvent(tail);
586
+ if (event) yield event;
587
+ }
588
+ } finally {
589
+ reader.cancel().catch(() => {
590
+ });
591
+ }
592
+ }
593
+ function collect(line, data) {
594
+ if (!line || line.startsWith(":")) return;
595
+ const colon = line.indexOf(":");
596
+ if (colon === -1 || line.slice(0, colon) !== "data") return;
597
+ const value = line.slice(colon + 1);
598
+ data.push(value.startsWith(" ") ? value.slice(1) : value);
599
+ }
600
+ function stripCr(line) {
601
+ return line.endsWith("\r") ? line.slice(0, -1) : line;
602
+ }
603
+ async function collectText(events) {
604
+ let text = "";
605
+ for await (const event of events) {
606
+ if (event.type === "delta" && event.text) text += event.text;
607
+ if (event.type === "error") throw new Error(event.message ?? "The run ended with an error.");
608
+ }
609
+ return text;
610
+ }
611
+
612
+ // src/SobaProvider.tsx
613
+ import { useCallback as useCallback2, useEffect as useEffect2, useMemo as useMemo2, useRef as useRef2, useState as useState2 } from "react";
614
+
615
+ // src/session.ts
616
+ var REFRESH_MARGIN_MS = 6e4;
617
+ var MIN_REFRESH_MS = 1e4;
618
+ var SessionHolder = class {
619
+ #token;
620
+ #expiresAt = null;
621
+ #inFlight = null;
622
+ #options;
623
+ #listeners = /* @__PURE__ */ new Set();
624
+ constructor(options) {
625
+ this.#options = options;
626
+ this.#token = options.token ?? null;
627
+ }
628
+ get token() {
629
+ return this.#token;
630
+ }
631
+ /** When the current token stops working, if the endpoint said. */
632
+ get expiresAt() {
633
+ return this.#expiresAt;
634
+ }
635
+ /** False for a token handed in directly: there is nowhere to get another. */
636
+ get canRefresh() {
637
+ return Boolean(this.#options.endpoint);
638
+ }
639
+ /** When to ask for the next one, or null when there is nothing to schedule. */
640
+ get refreshAt() {
641
+ if (!this.canRefresh || this.#expiresAt === null) return null;
642
+ return Math.max(Date.now() + MIN_REFRESH_MS, this.#expiresAt - REFRESH_MARGIN_MS);
643
+ }
644
+ subscribe(listener) {
645
+ this.#listeners.add(listener);
646
+ return () => this.#listeners.delete(listener);
647
+ }
648
+ /** The token, minting one if there is none. Concurrent callers share a
649
+ * request: three components mounting together must not mint three sessions. */
650
+ async ensure() {
651
+ if (this.#token) return this.#token;
652
+ return this.refresh();
653
+ }
654
+ async refresh() {
655
+ if (this.#inFlight) return this.#inFlight;
656
+ const endpoint = this.#options.endpoint;
657
+ if (!endpoint) {
658
+ throw new Error(
659
+ "This Soba session cannot be refreshed. Pass `sessionEndpoint` so the provider can mint a new one."
660
+ );
661
+ }
662
+ const doFetch = this.#options.fetchImpl ?? fetch.bind(globalThis);
663
+ this.#inFlight = (async () => {
664
+ const response = await doFetch(endpoint, {
665
+ method: "POST",
666
+ headers: { "content-type": "application/json" },
667
+ // The endpoint is the customer's own, so it authenticates the person the
668
+ // way the rest of their app does. Their cookies, not our credential.
669
+ credentials: "same-origin",
670
+ body: JSON.stringify(this.#options.user ? { user: this.#options.user } : {})
671
+ });
672
+ if (!response.ok) {
673
+ throw new Error(`Could not start a Soba session (${response.status} from ${endpoint}).`);
674
+ }
675
+ const body = await response.json();
676
+ const token = body.session ?? body.token;
677
+ if (!token) {
678
+ throw new Error(`${endpoint} answered without a \`session\`. See @soba-so/react's README.`);
679
+ }
680
+ const ttl = body.expires_in ?? body.expiresIn;
681
+ this.#token = token;
682
+ this.#expiresAt = typeof ttl === "number" ? Date.now() + ttl * 1e3 : null;
683
+ for (const listener of this.#listeners) listener();
684
+ return token;
685
+ })();
686
+ try {
687
+ return await this.#inFlight;
688
+ } finally {
689
+ this.#inFlight = null;
690
+ }
691
+ }
692
+ };
693
+ function createSessionFetch(holder, base = fetch.bind(globalThis)) {
694
+ return async (input, init) => {
695
+ const response = await base(input, init);
696
+ if (response.status !== 401 || !holder.canRefresh) return response;
697
+ let fresh;
698
+ try {
699
+ fresh = await holder.refresh();
700
+ } catch {
701
+ return response;
702
+ }
703
+ return base(input, withAuthorization(init, fresh));
704
+ };
705
+ }
706
+ function withAuthorization(init, token) {
707
+ const headers = new Headers(init?.headers);
708
+ headers.set("authorization", `Bearer ${token}`);
709
+ return { ...init, headers };
710
+ }
711
+
712
+ // src/styles.ts
713
+ var MARKER = "data-soba-react";
714
+ var injected = false;
715
+ function injectStyles() {
716
+ if (injected || typeof document === "undefined") return;
717
+ injected = true;
718
+ if (document.querySelector(`style[${MARKER}]`)) return;
719
+ const style = document.createElement("style");
720
+ style.setAttribute(MARKER, "");
721
+ style.textContent = '/*\n * Self-contained on purpose.\n *\n * This renders inside the Next app, inside a Tauri window, and eventually inside\n * a customer\'s own page, which will have its own reset, its own font stack and\n * quite possibly Tailwind. So: one class prefix, no element selectors, no\n * assumptions about a reset, and every colour behind a token a host can\n * override by redefining it on `.soba-connect`.\n */\n\n.soba-connect {\n --sc-ground: #ffffff;\n --sc-sheet: #ffffff;\n --sc-raised: #f6f6f7;\n --sc-line: #e9e9eb;\n --sc-line-2: #dcdce0;\n --sc-text: #1a1a1c;\n --sc-text-2: #3f3f46;\n --sc-muted: #6b6b73;\n --sc-accent: #18181b;\n --sc-accent-fg: #ffffff;\n --sc-ok: #22a06b;\n --sc-warn: #b06f14;\n --sc-r: 10px;\n\n box-sizing: border-box;\n max-width: 560px;\n margin: 0 auto;\n color: var(--sc-text);\n font-family: "Inter", ui-sans-serif, -apple-system, "Helvetica Neue", Arial, sans-serif;\n font-size: 15px;\n line-height: 1.5;\n letter-spacing: -0.005em;\n}\n.soba-connect *,\n.soba-connect *::before,\n.soba-connect *::after {\n box-sizing: inherit;\n}\n\n.sc-card {\n background: var(--sc-sheet);\n border: 1px solid var(--sc-line);\n border-radius: var(--sc-r);\n padding: 20px;\n}\n.sc-card + .sc-card {\n margin-top: 12px;\n}\n\n.sc-title {\n font-size: 20px;\n font-weight: 650;\n letter-spacing: -0.02em;\n margin: 0 0 4px;\n}\n.sc-sub {\n color: var(--sc-muted);\n margin: 0 0 16px;\n}\n.sc-label {\n font-size: 13px;\n color: var(--sc-muted);\n margin: 0 0 6px;\n letter-spacing: -0.005em;\n}\n\n.sc-choices {\n display: grid;\n gap: 8px;\n}\n.sc-choice {\n display: block;\n width: 100%;\n text-align: left;\n background: var(--sc-sheet);\n border: 1px solid var(--sc-line-2);\n border-radius: var(--sc-r);\n padding: 12px 14px;\n cursor: pointer;\n font: inherit;\n color: inherit;\n}\n.sc-choice:hover {\n background: var(--sc-raised);\n}\n.sc-choice[aria-pressed="true"] {\n border-color: var(--sc-accent);\n box-shadow: inset 0 0 0 1px var(--sc-accent);\n}\n.sc-choice-title {\n font-weight: 550;\n}\n.sc-choice-meta {\n color: var(--sc-muted);\n font-size: 13px;\n}\n\n.sc-code {\n display: block;\n background: var(--sc-raised);\n border: 1px solid var(--sc-line);\n border-radius: 8px;\n padding: 12px 14px;\n font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace;\n font-size: 13px;\n overflow-x: auto;\n white-space: pre;\n}\n\n.sc-row {\n display: flex;\n gap: 8px;\n align-items: center;\n}\n.sc-row-between {\n display: flex;\n gap: 12px;\n align-items: center;\n justify-content: space-between;\n}\n.sc-stack {\n display: grid;\n gap: 12px;\n}\n\n.sc-btn {\n font: inherit;\n font-weight: 550;\n border-radius: 8px;\n padding: 9px 14px;\n border: 1px solid var(--sc-line-2);\n background: var(--sc-sheet);\n color: var(--sc-text);\n cursor: pointer;\n}\n.sc-btn:hover {\n background: var(--sc-raised);\n}\n.sc-btn-primary {\n background: var(--sc-accent);\n color: var(--sc-accent-fg);\n border-color: var(--sc-accent);\n}\n.sc-btn-primary:hover {\n opacity: 0.9;\n background: var(--sc-accent);\n}\n.sc-btn:disabled {\n opacity: 0.5;\n cursor: default;\n}\n\n.sc-input {\n font: inherit;\n font-family: ui-monospace, SFMono-Regular, Menlo, monospace;\n letter-spacing: 0.08em;\n text-transform: uppercase;\n padding: 10px 12px;\n border: 1px solid var(--sc-line-2);\n border-radius: 8px;\n background: var(--sc-sheet);\n color: inherit;\n width: 100%;\n}\n\n.sc-dot {\n width: 8px;\n height: 8px;\n border-radius: 50%;\n flex: none;\n background: var(--sc-muted);\n}\n.sc-dot-ok {\n background: var(--sc-ok);\n}\n.sc-dot-busy {\n background: var(--sc-warn);\n}\n.sc-dot-off {\n background: var(--sc-line-2);\n}\n\n.sc-tone-ok {\n color: var(--sc-ok);\n}\n.sc-tone-warn {\n color: var(--sc-warn);\n}\n.sc-tone-unknown {\n color: var(--sc-muted);\n}\n\n.sc-error {\n color: var(--sc-warn);\n font-size: 14px;\n}\n.sc-note {\n color: var(--sc-muted);\n font-size: 13px;\n}\n\n.sc-facts {\n display: grid;\n gap: 4px;\n margin: 0;\n padding: 0;\n list-style: none;\n}\n.sc-fact {\n display: flex;\n gap: 10px;\n font-size: 14px;\n}\n.sc-fact-key {\n color: var(--sc-muted);\n min-width: 92px;\n}\n\n.sc-machine {\n border-top: 1px solid var(--sc-line);\n padding-top: 12px;\n margin-top: 12px;\n}\n.sc-machine:first-child {\n border-top: 0;\n padding-top: 0;\n margin-top: 0;\n}\n.sc-runtimes {\n display: grid;\n gap: 4px;\n margin: 8px 0 0;\n padding: 0;\n list-style: none;\n font-size: 14px;\n}\n\n@media (prefers-color-scheme: dark) {\n .soba-connect:not([data-theme="light"]) {\n --sc-ground: #0f0f11;\n --sc-sheet: #161618;\n --sc-raised: #1d1d20;\n --sc-line: #26262a;\n --sc-line-2: #34343a;\n --sc-text: #f2f2f3;\n --sc-text-2: #c9c9cd;\n --sc-muted: #8d8d95;\n --sc-accent: #f2f2f3;\n --sc-accent-fg: #131316;\n }\n}\n';
722
+ document.head.appendChild(style);
723
+ }
724
+
725
+ // src/SobaProvider.tsx
726
+ import { jsx as jsx5 } from "react/jsx-runtime";
727
+ var DEFAULT_BASE_URL = "https://soba.so";
728
+ var DEFAULT_POLL_MS = 15e3;
729
+ function SobaProvider(props) {
730
+ const {
731
+ children,
732
+ session: given,
733
+ sessionEndpoint,
734
+ user,
735
+ runEndpoint,
736
+ baseUrl = DEFAULT_BASE_URL,
737
+ pollMs = DEFAULT_POLL_MS,
738
+ injectStyles: withStyles = true,
739
+ fetchImpl,
740
+ onError
741
+ } = props;
742
+ const [session, setSession] = useState2(given ?? null);
743
+ const [error, setError] = useState2(null);
744
+ const [context, setContext] = useState2(null);
745
+ const [contextError, setContextError] = useState2(null);
746
+ const [contextLoading, setContextLoading] = useState2(true);
747
+ const [machines, setMachines] = useState2([]);
748
+ const [machinesError, setMachinesError] = useState2(null);
749
+ const [machinesLoading, setMachinesLoading] = useState2(true);
750
+ const report = useRef2(onError);
751
+ report.current = onError;
752
+ const fail = useCallback2((err) => {
753
+ setError(err);
754
+ report.current?.(err);
755
+ }, []);
756
+ useEffect2(() => {
757
+ if (withStyles) injectStyles();
758
+ }, [withStyles]);
759
+ const holder = useMemo2(
760
+ () => new SessionHolder({
761
+ ...given ? { token: given } : {},
762
+ ...sessionEndpoint ? { endpoint: sessionEndpoint } : {},
763
+ ...user ? { user } : {},
764
+ ...fetchImpl ? { fetchImpl } : {}
765
+ }),
766
+ [given, sessionEndpoint, user, fetchImpl]
767
+ );
768
+ useEffect2(() => {
769
+ let live = true;
770
+ const unsubscribe = holder.subscribe(() => {
771
+ if (live) setSession(holder.token);
772
+ });
773
+ if (!holder.token && !holder.canRefresh) {
774
+ fail(
775
+ new Error(
776
+ "<SobaProvider> needs a `session` or a `sessionEndpoint`. A browser cannot mint one: that takes your secret key."
777
+ )
778
+ );
779
+ } else {
780
+ holder.ensure().then(
781
+ () => live && setSession(holder.token),
782
+ (err) => live && fail(err)
783
+ );
784
+ }
785
+ return () => {
786
+ live = false;
787
+ unsubscribe();
788
+ };
789
+ }, [holder, fail]);
790
+ useEffect2(() => {
791
+ const at = holder.refreshAt;
792
+ if (at === null) return;
793
+ const timer = setTimeout(
794
+ () => {
795
+ holder.refresh().catch((err) => fail(err));
796
+ },
797
+ Math.max(0, at - Date.now())
798
+ );
799
+ return () => clearTimeout(timer);
800
+ }, [holder, fail, session]);
801
+ const client = useMemo2(() => {
802
+ if (!session) return null;
803
+ return new ConnectClient({
804
+ session,
805
+ baseUrl,
806
+ // Heals a session that died under a request, transparently, for every
807
+ // consumer of this client, including the connect panel's own polling.
808
+ fetchImpl: createSessionFetch(holder, fetchImpl ?? fetch.bind(globalThis))
809
+ });
810
+ }, [session, baseUrl, holder, fetchImpl]);
811
+ useEffect2(() => {
812
+ if (!client) return;
813
+ let live = true;
814
+ setContextLoading(true);
815
+ client.context().then(
816
+ (value2) => {
817
+ if (!live) return;
818
+ setContext(value2);
819
+ setContextError(null);
820
+ setContextLoading(false);
821
+ },
822
+ (err) => {
823
+ if (!live) return;
824
+ setContextError(err);
825
+ setContextLoading(false);
826
+ }
827
+ );
828
+ return () => {
829
+ live = false;
830
+ };
831
+ }, [client]);
832
+ const load = useCallback2(async () => {
833
+ if (!client) return;
834
+ try {
835
+ const list = await client.machines();
836
+ setMachines(list);
837
+ setMachinesError(null);
838
+ } catch (err) {
839
+ setMachinesError(err);
840
+ } finally {
841
+ setMachinesLoading(false);
842
+ }
843
+ }, [client]);
844
+ useEffect2(() => {
845
+ if (!client) return;
846
+ let live = true;
847
+ void load();
848
+ const timer = setInterval(() => {
849
+ if (typeof document !== "undefined" && document.hidden) return;
850
+ if (live) void load();
851
+ }, pollMs);
852
+ return () => {
853
+ live = false;
854
+ clearInterval(timer);
855
+ };
856
+ }, [client, load, pollMs]);
857
+ const run = useCallback2(
858
+ async (request) => {
859
+ if (!runEndpoint) {
860
+ throw new Error(
861
+ "Pass `runEndpoint` to <SobaProvider> to start runs from the browser. It is your own route: it holds your secret key, calls soba.run(), and forwards the stream."
862
+ );
863
+ }
864
+ const { signal, ...body } = request;
865
+ const doFetch = fetchImpl ?? fetch.bind(globalThis);
866
+ const response = await doFetch(runEndpoint, {
867
+ method: "POST",
868
+ headers: { "content-type": "application/json", accept: "text/event-stream" },
869
+ // Your route, your auth. The Soba session is not sent: it says who is
870
+ // connecting a machine, never who may spend your key.
871
+ credentials: "same-origin",
872
+ body: JSON.stringify(body),
873
+ ...signal ? { signal } : {}
874
+ });
875
+ if (!response.ok) {
876
+ throw new Error(`${runEndpoint} answered ${response.status}.`);
877
+ }
878
+ return readEvents(response);
879
+ },
880
+ [runEndpoint, fetchImpl]
881
+ );
882
+ const value = useMemo2(
883
+ () => ({
884
+ session,
885
+ client,
886
+ error,
887
+ compute: {
888
+ machines,
889
+ connected: machines.length > 0,
890
+ online: machines.some((m) => m.state !== "offline"),
891
+ loading: machinesLoading,
892
+ error: machinesError,
893
+ refresh: load
894
+ },
895
+ connect: {
896
+ app: context?.app ?? null,
897
+ command: context?.command ?? null,
898
+ publishableKey: context?.publishable_key ?? null,
899
+ loading: contextLoading,
900
+ error: contextError
901
+ },
902
+ run
903
+ }),
904
+ [
905
+ session,
906
+ client,
907
+ error,
908
+ machines,
909
+ machinesLoading,
910
+ machinesError,
911
+ load,
912
+ context,
913
+ contextLoading,
914
+ contextError,
915
+ run
916
+ ]
917
+ );
918
+ return /* @__PURE__ */ jsx5(ClientContext.Provider, { value: client, children: /* @__PURE__ */ jsx5(SobaContext.Provider, { value, children }) });
919
+ }
920
+ export {
921
+ ComputeStatus2 as ComputeStatus,
922
+ ConnectCompute,
923
+ SobaProvider,
924
+ collectText,
925
+ injectStyles,
926
+ parseEvent,
927
+ readEvents,
928
+ useSoba
929
+ };
930
+ //# sourceMappingURL=index.mjs.map