@soba-so/react 0.0.5

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/LICENSE ADDED
@@ -0,0 +1,19 @@
1
+ Copyright © 2026 Soba. All rights reserved.
2
+
3
+ This software is licensed, not sold, and it is not open source.
4
+
5
+ Subject to your agreement with Soba, you are granted a non-exclusive,
6
+ non-transferable, non-sublicensable, revocable licence to install this package
7
+ and use it in your own applications for the sole purpose of interfacing with
8
+ the Soba service.
9
+
10
+ You may not redistribute it, publish it, sell it, sublicense it, incorporate it
11
+ into another distributed library, or create and distribute derivative works of
12
+ it. No rights are granted in the Soba name or logo.
13
+
14
+ This software is provided "as is", without warranty of any kind, express or
15
+ implied. To the maximum extent permitted by law, Soba shall not be liable for
16
+ any claim, damages or other liability arising from the software or its use.
17
+
18
+ Nothing here limits any right you have that cannot be limited by the law that
19
+ applies to you.
package/README.md ADDED
@@ -0,0 +1,151 @@
1
+ # @soba-so/react
2
+
3
+ **Let your users bring the AI they already pay for, from your own React app.**
4
+
5
+ ```jsx
6
+ import { SobaProvider, ConnectCompute, ComputeStatus } from "@soba-so/react"
7
+
8
+ <SobaProvider sessionEndpoint="/api/soba/session" user={session.user.id}>
9
+ <ConnectCompute />
10
+ <ComputeStatus />
11
+ </SobaProvider>
12
+ ```
13
+
14
+ Drop that in your settings page. A person pairs their machine once, and their
15
+ Claude Code, Codex or local model starts serving your runs, **with no change at
16
+ your call site**.
17
+
18
+ ## The one thing your server has to do
19
+
20
+ A browser cannot mint a Soba session: that takes your `sk_soba_` secret key, and
21
+ that key can start runs and read every user's usage. So the provider asks a route
22
+ of **yours** for a short-lived, single-user credential, the way Stripe's
23
+ ephemeral keys work.
24
+
25
+ ```ts
26
+ // app/api/soba/session/route.ts
27
+ export async function POST() {
28
+ const session = await auth() // your auth, your rules
29
+ const res = await fetch("https://www.soba.so/v1/end_users/session", {
30
+ method: "POST",
31
+ headers: {
32
+ authorization: `Bearer ${process.env.SOBA_KEY}`,
33
+ "content-type": "application/json",
34
+ },
35
+ body: JSON.stringify({ user: session.user.id }),
36
+ })
37
+ return Response.json(await res.json())
38
+ }
39
+ ```
40
+
41
+ That is the whole integration. `user` is the same id you pass on a run, which is
42
+ what makes the person whose machine this is and the person a run is attributed to
43
+ the same row.
44
+
45
+ The session lives ten minutes. A settings page left open for an hour is the
46
+ ordinary case, so the provider refreshes it before it expires **and** heals a
47
+ request that meets a dead one: a 401 mints a new session and retries once, for
48
+ every component in the tree.
49
+
50
+ If you would rather mint it yourself, pass `session` instead. It cannot be
51
+ refreshed, and the provider says so rather than failing quietly at minute eleven.
52
+
53
+ ## The components
54
+
55
+ ### `<ConnectCompute />`
56
+
57
+ The connect step, and the component that carries the real friction. It asks which
58
+ compute they have, shows the one command to run on the machine they want to use,
59
+ takes the short code that command prints, and then **waits for the machine to
60
+ actually appear**.
61
+
62
+ That last part is the whole design. A pairing that never dials the gateway looks
63
+ exactly like a successful one right up until the first run fails, so the panel
64
+ does not stop at "approved". Detected, not asserted.
65
+
66
+ Nothing is ever pasted back. The credential is written straight to that machine,
67
+ at `~/.soba/worker.json`, and neither this component nor your app ever sees it.
68
+
69
+ ### `<ComputeStatus />`
70
+
71
+ The component with no equivalent in an auth product. When the compute is
72
+ someone's own laptop, the state of that laptop is user-facing: asleep, offline,
73
+ signed out of its CLI, or serving a run right now are all things only the person
74
+ in front of it can fix, so all of them are shown.
75
+
76
+ `authenticated` stays **tri-state** all the way to the screen. `true` is signed
77
+ in, `false` is a positive finding that it is signed out, and `null` means we could
78
+ not tell. Rendering the third as the second tells someone their working setup is
79
+ broken.
80
+
81
+ Several of these on one page is still one request: they read the provider's
82
+ polling rather than starting their own, and a hidden tab does not poll at all.
83
+
84
+ ## The hook
85
+
86
+ ```js
87
+ const { compute, connect, run, client, session } = useSoba()
88
+ ```
89
+
90
+ | | |
91
+ |---|---|
92
+ | `compute` | `machines`, `connected`, `online`, `loading`, `error`, `refresh()` |
93
+ | `connect` | `app`, `command`, `publishableKey` |
94
+ | `run` | Start a run through your own endpoint and read its events |
95
+ | `client` | The framework-free API underneath, for anything the hook does not cover |
96
+
97
+ `connected` means they have paired something. `online` means something of theirs
98
+ could serve a run right now. They are different questions and a UI usually needs
99
+ both.
100
+
101
+ ## Runs from the browser
102
+
103
+ A run is started by **your** server, because starting one takes your secret key.
104
+ Point the provider at a route that does it, and the hook will read the stream:
105
+
106
+ ```ts
107
+ // app/api/soba/run/route.ts
108
+ import { Soba } from "@soba-so/sdk"
109
+
110
+ export async function POST(request: Request) {
111
+ const session = await auth()
112
+ const soba = new Soba({ apiKey: process.env.SOBA_KEY })
113
+ const run = await soba.run({ user: session.user.id, ...(await request.json()) })
114
+ return new Response(toSSE(run), { headers: { "content-type": "text/event-stream" } })
115
+ }
116
+ ```
117
+
118
+ ```jsx
119
+ const { run } = useSoba()
120
+ const events = await run({ prompt: "Summarise everything I saved this week" })
121
+ for await (const event of events) {
122
+ if (event.type === "delta") append(event.text)
123
+ }
124
+ ```
125
+
126
+ Only the five frozen event types reach the browser. A tool call never does: your
127
+ server already executed it, against the data it already touches.
128
+
129
+ ## Styling
130
+
131
+ The stylesheet injects itself once, so there is no import to get wrong in an RSC
132
+ tree. Everything is scoped under `.soba-connect` with no element selectors and no
133
+ assumed reset, and every colour is a custom property you can redefine:
134
+
135
+ ```css
136
+ .soba-connect {
137
+ --sc-accent: #1a1a1c;
138
+ --sc-r: 6px;
139
+ }
140
+ ```
141
+
142
+ Pass `injectStyles={false}` to the provider and import `@soba-so/react/styles.css`
143
+ yourself if you would rather own it.
144
+
145
+ ## Requirements
146
+
147
+ React 18 or newer, as a peer dependency. No other runtime dependencies. The
148
+ package is a client component: it carries its own `"use client"`, so it works in
149
+ a Next app router tree without a wrapper.
150
+
151
+ © 2026 Soba. All rights reserved. Licensed for use with the Soba service; see LICENSE.
@@ -0,0 +1,15 @@
1
+ export interface ComputeStatusProps {
2
+ className?: string;
3
+ }
4
+ /**
5
+ * What this person has connected, and what it is doing right now.
6
+ *
7
+ * The component with no equivalent in an auth product. When the compute is
8
+ * someone's own laptop, the state of that laptop is user-facing: asleep,
9
+ * offline, signed out of its CLI or serving a run are all things only the
10
+ * person in front of it can do anything about, so all of them are shown.
11
+ *
12
+ * It reads the provider's polling rather than starting its own, so three of
13
+ * these on one page is still one request.
14
+ */
15
+ export declare function ComputeStatus({ className }: ComputeStatusProps): import("react").JSX.Element;
@@ -0,0 +1,21 @@
1
+ import type { Machine } from "./types.js";
2
+ export interface ConnectComputeProps {
3
+ /** From `?code=` when they arrived from a terminal that already has one. */
4
+ initialCode?: string;
5
+ onConnected?: (machines: Machine[]) => void;
6
+ className?: string;
7
+ /** False when you place `<ComputeStatus />` yourself, so the machines are not
8
+ * told twice. Left true the panel opens on what this person already has. */
9
+ showStatus?: boolean;
10
+ }
11
+ /**
12
+ * The connect step, and the component that carries the real friction.
13
+ *
14
+ * It does not stop at "approved": a pairing that never dials the gateway looks
15
+ * exactly like a successful one right up until the first run fails, so it waits
16
+ * for the machine to actually appear. Detected, not asserted.
17
+ *
18
+ * This is the same panel the hosted page uses. The only difference is where the
19
+ * session comes from, which is what <SobaProvider> is for.
20
+ */
21
+ export declare function ConnectCompute({ initialCode, onConnected, className, showStatus, }: ConnectComputeProps): import("react").JSX.Element;
@@ -0,0 +1,28 @@
1
+ import type { Plan } from "./types.js";
2
+ export interface SobaCheckoutProps {
3
+ /** The plan to buy. A free one is refused by the endpoint — use `<SobaPlans />`,
4
+ * which routes free and priced plans to the right place on its own. */
5
+ plan: Plan;
6
+ /** Where Stripe returns on success. Defaults to the page they are on, which
7
+ * is right for a settings page and wrong for a landing page. */
8
+ successUrl?: string;
9
+ cancelUrl?: string;
10
+ /** Override the button's words. The default names the plan and its price,
11
+ * because a button that says "Subscribe" makes the reader look back up. */
12
+ label?: string;
13
+ className?: string;
14
+ }
15
+ /**
16
+ * THE BUY BUTTON, and everything it deliberately does not do.
17
+ *
18
+ * It creates a Checkout Session and navigates. It does not take a card number,
19
+ * it never sees one, and it grants nothing: the entitlement is written by
20
+ * Stripe's webhook when the money actually moves. That split is the whole
21
+ * security model — a component that granted the plan on redirect would hand one
22
+ * to anyone who could reach the URL and close the tab.
23
+ *
24
+ * The payment lands on the APP's own Stripe account, with Soba's fee taken off
25
+ * the top. Their user sees their name on the statement, because they are the
26
+ * merchant; Soba is the rail underneath.
27
+ */
28
+ export declare function SobaCheckout({ plan, successUrl, cancelUrl, label, className }: SobaCheckoutProps): import("react").JSX.Element;
@@ -0,0 +1,35 @@
1
+ import type { Plan } from "./types.js";
2
+ export interface SobaPlansProps {
3
+ /**
4
+ * Handle a priced plan yourself instead of using Soba's checkout.
5
+ *
6
+ * The default is to send them to Stripe — the app's own connected account,
7
+ * with Soba's fee off the top. Pass this only when you already have billing
8
+ * and want Soba to stay out of the money: you take the payment, then your
9
+ * server puts the user on the plan.
10
+ */
11
+ onSelect?: (plan: Plan) => void;
12
+ /** Where Stripe returns after a successful payment. Defaults to this page. */
13
+ successUrl?: string;
14
+ cancelUrl?: string;
15
+ /** Hide the compute row. It is shown by default because on this product the
16
+ * plan decides which compute a run reaches, which is the differentiator. */
17
+ showCompute?: boolean;
18
+ className?: string;
19
+ }
20
+ /**
21
+ * THE PRICE LIST, rendered from the plans themselves.
22
+ *
23
+ * `docs/components.md`: "Prices, allowances and the connected reward all come
24
+ * from the plan objects, so a pricing change is not a deploy." That is the whole
25
+ * design. Nothing here is configured in the app — the cards are whatever
26
+ * `/v1/plans` returns, so editing a plan in the dashboard changes this page.
27
+ *
28
+ * What makes it worth shipping rather than leaving to every app's own markup is
29
+ * the compute row. A plan here does not only decide what someone is charged; it
30
+ * decides WHICH COMPUTE their runs may reach, and that is the thing a person
31
+ * choosing between two plans most needs to see. It is also the thing an app
32
+ * would have to hand-maintain in a second place, and get wrong the first time
33
+ * the plan changed.
34
+ */
35
+ export declare function SobaPlans({ onSelect, successUrl, cancelUrl, showCompute, className, }: SobaPlansProps): import("react").JSX.Element;
@@ -0,0 +1,39 @@
1
+ import { type ReactNode } from "react";
2
+ export interface SobaProviderProps {
3
+ children: ReactNode;
4
+ /**
5
+ * An `est_` session you minted on your server. Cannot be refreshed, so prefer
6
+ * `sessionEndpoint` for anything a person leaves open.
7
+ */
8
+ session?: string;
9
+ /**
10
+ * Your own route. The provider POSTs `{ user }` to it with your cookies, and
11
+ * expects `{ session, expires_in }` back. Four lines on your server:
12
+ *
13
+ * ```ts
14
+ * const res = await fetch("https://www.soba.so/v1/end_users/session", {
15
+ * method: "POST",
16
+ * headers: { authorization: `Bearer ${process.env.SOBA_KEY}` },
17
+ * body: JSON.stringify({ user: session.user.id }),
18
+ * })
19
+ * return Response.json(await res.json())
20
+ * ```
21
+ */
22
+ sessionEndpoint?: string;
23
+ /** The same id you pass as `user` on a run. It is what makes the person whose
24
+ * machine this is and the person a run is attributed to the same row. */
25
+ user?: string;
26
+ /** Your route that starts a run with `@soba-so/sdk` and forwards the stream. */
27
+ runEndpoint?: string;
28
+ baseUrl?: string;
29
+ pollMs?: number;
30
+ /** The stylesheet injects itself once. Turn it off to ship your own. */
31
+ injectStyles?: boolean;
32
+ fetchImpl?: typeof fetch;
33
+ onError?: (error: Error) => void;
34
+ }
35
+ /**
36
+ * Holds the one thing everything else needs: a live session for one of your
37
+ * users. Everything below it, component or hook, reads from here.
38
+ */
39
+ export declare function SobaProvider(props: SobaProviderProps): import("react").JSX.Element;
@@ -0,0 +1,82 @@
1
+ import type { AgentEvent, ConnectApi, CurrentPlan, Machine, Message, Plan } from "./types.js";
2
+ /** What your endpoint is asked for. Everything but `signal` is forwarded to it
3
+ * verbatim, because the run's shape is `soba.run()`'s and yours to widen. */
4
+ export interface RunRequest {
5
+ prompt?: string;
6
+ messages?: Message[];
7
+ model?: string;
8
+ system?: string;
9
+ signal?: AbortSignal;
10
+ [key: string]: unknown;
11
+ }
12
+ export interface ComputeState {
13
+ /** Every machine this person has paired, in whatever state it is in. */
14
+ machines: Machine[];
15
+ /** They have paired something. It may still be asleep. */
16
+ connected: boolean;
17
+ /** Something of theirs could serve a run right now. */
18
+ online: boolean;
19
+ loading: boolean;
20
+ error: Error | null;
21
+ refresh(): Promise<void>;
22
+ }
23
+ export interface ConnectState {
24
+ /** Your app's name, as the person connecting will see it. */
25
+ app: {
26
+ name: string;
27
+ } | null;
28
+ /** The exact command to run on the machine being paired. */
29
+ command: string | null;
30
+ publishableKey: string | null;
31
+ loading: boolean;
32
+ error: Error | null;
33
+ }
34
+ /**
35
+ * The plans this app sells, and the one this user is on.
36
+ *
37
+ * `docs/components.md` lists `plan` beside `compute` and `connect` as "the
38
+ * user's current plan, allowance and usage", and it belongs there for the same
39
+ * reason `compute` does: on this product the plan decides which COMPUTE a run
40
+ * may reach, so it is user-facing in a way a subscription tier usually is not.
41
+ */
42
+ export interface PlanState {
43
+ /** Everything the app sells, cheapest first. */
44
+ plans: Plan[];
45
+ /** The user's assignment, or null when they are on no plan. */
46
+ current: CurrentPlan | null;
47
+ /** The plan object behind `current`, resolved for convenience. */
48
+ plan: Plan | null;
49
+ loading: boolean;
50
+ error: Error | null;
51
+ /** Take a plan. A priced one rejects with `payment_required`; send it to
52
+ * `checkout` instead, which is what `<SobaCheckout />` does. */
53
+ select(planId: string): Promise<void>;
54
+ /**
55
+ * Start a Stripe checkout for a priced plan and return where to send the
56
+ * person. Nothing is granted by this call — the entitlement is written when
57
+ * Stripe reports the payment, so a caller that navigates and never comes back
58
+ * still gets what they paid for, and one that never pays gets nothing.
59
+ */
60
+ checkout(planId: string, urls?: {
61
+ successUrl?: string;
62
+ cancelUrl?: string;
63
+ }): Promise<string>;
64
+ /** Leave the current plan; the ceiling falls back to the environment's. */
65
+ leave(): Promise<void>;
66
+ refresh(): Promise<void>;
67
+ }
68
+ export interface SobaContextValue {
69
+ /** The `est_` session, once there is one. */
70
+ session: string | null;
71
+ /** The framework-free client, for anything the hook does not cover. */
72
+ client: ConnectApi | null;
73
+ error: Error | null;
74
+ compute: ComputeState;
75
+ connect: ConnectState;
76
+ plan: PlanState;
77
+ /** Start a run through YOUR endpoint and read its events. Throws when the
78
+ * provider was given no `runEndpoint`, since a browser cannot start one. */
79
+ run(request: RunRequest): Promise<AsyncIterable<AgentEvent>>;
80
+ }
81
+ export declare const SobaContext: import("react").Context<SobaContextValue | null>;
82
+ export declare function useSoba(): SobaContextValue;
@@ -0,0 +1,7 @@
1
+ import type { AgentEvent } from "./types.js";
2
+ export declare function parseEvent(raw: string): AgentEvent | null;
3
+ /** Server-sent events off a response body. Also reads newline-delimited JSON,
4
+ * since a hand-written proxy route usually forwards lines rather than frames. */
5
+ export declare function readEvents(response: Response): AsyncGenerator<AgentEvent>;
6
+ /** Everything a run said, for a caller that only wants the answer. */
7
+ export declare function collectText(events: AsyncIterable<AgentEvent>): Promise<string>;
@@ -0,0 +1,9 @@
1
+ export { ComputeStatus, type ComputeStatusProps } from "./ComputeStatus.js";
2
+ export { ConnectCompute, type ConnectComputeProps } from "./ConnectCompute.js";
3
+ export { type ComputeState, type ConnectState, type PlanState, type RunRequest, type SobaContextValue, useSoba, } from "./context.js";
4
+ export { collectText, parseEvent, readEvents } from "./events.js";
5
+ export { SobaCheckout, type SobaCheckoutProps } from "./SobaCheckout.js";
6
+ export { SobaPlans, type SobaPlansProps } from "./SobaPlans.js";
7
+ export { SobaProvider, type SobaProviderProps } from "./SobaProvider.js";
8
+ export { injectStyles } from "./styles.js";
9
+ export type { AgentEvent, AgentEventType, ConnectApi, ConnectContext, CostClass, CurrentPlan, Machine, MachineState, Message, PendingCode, Plan, Runtime, RunUsage, } from "./types.js";
package/dist/index.mjs ADDED
@@ -0,0 +1,306 @@
1
+ "use client"
2
+ var ee=class extends Error{status;constructor(t,n){super(t),this.name="ConnectError",this.status=n}},Q=class{session;baseUrl;doFetch;constructor(t){this.session=t.session,this.baseUrl=(t.baseUrl??"").replace(/\/+$/,""),this.doFetch=t.fetchImpl??fetch.bind(globalThis)}context(){return this.request("GET","/v1/connect")}async machines(){return(await this.request("GET","/v1/machines")).machines}lookup(t){return this.request("GET",`/v1/device/lookup?code=${encodeURIComponent(t)}`)}approve(t){return this.request("POST","/v1/device/approve",{user_code:t})}deny(t){return this.request("POST","/v1/device/deny",{user_code:t})}plans(){return this.request("GET","/v1/plans")}selectPlan(t){return this.request("POST","/v1/plans/select",{plan:t})}checkoutPlan(t,n){return this.request("POST","/v1/plans/checkout",{plan:t,...n?.successUrl?{success_url:n.successUrl}:{},...n?.cancelUrl?{cancel_url:n.cancelUrl}:{}})}leavePlan(){return this.request("DELETE","/v1/plans/select")}approveRuntime(t,n){return this.request("POST","/v1/runtimes/approve",{machine_id:t,runtime_id:n})}async request(t,n,i){let s=await this.doFetch(`${this.baseUrl}${n}`,{method:t,headers:{authorization:`Bearer ${this.session}`,...i?{"content-type":"application/json"}:{}},...i?{body:JSON.stringify(i)}:{}}),r={};try{r=await s.json()}catch{}if(!s.ok){let u=typeof r.error=="string"?r.error:`Request failed (${s.status})`;throw new ee(u,s.status)}return r}};function ae(e){return e.authenticated===!0?{tone:"ok",text:"signed in"}:e.authenticated===!1?{tone:"warn",text:e.auth_hint??"signed out on that machine"}:{tone:"unknown",text:"could not tell"}}function ie(e){return e.state==="busy"?{tone:"busy",text:`serving ${e.active} of ${e.capacity}`}:e.state==="online"?{tone:"ok",text:"connected"}:{tone:"off",text:"asleep or offline"}}import{jsx as P,jsxs as G}from"react/jsx-runtime";function H({machines:e,onApproveRuntime:t}){return e.length?P("div",{children:e.map(n=>{let i=ie(n);return G("div",{className:"sc-machine",children:[G("div",{className:"sc-row-between",children:[G("div",{className:"sc-row",children:[P("span",{className:`sc-dot sc-dot-${i.tone}`,"aria-hidden":"true"}),P("strong",{children:n.label??"This machine"})]}),P("span",{className:"sc-note",children:i.text})]}),P("p",{className:"sc-note",children:[n.platform,n.arch,n.version&&`worker ${n.version}`,n.always_on&&"always on"].filter(Boolean).join(" \xB7 ")}),G("ul",{className:"sc-runtimes",children:[n.runtimes.map(s=>{let r=ae(s);return s.quarantined?G("li",{className:"sc-row-between",children:[P("span",{children:s.display_name}),t?P("button",{type:"button",className:"sc-btn",onClick:()=>t(n.id,s.id),children:"New here. Let it serve"}):P("span",{className:"sc-tone-warn",children:"new, not serving yet"})]},s.id):G("li",{className:"sc-row-between",children:[P("span",{children:s.display_name}),P("span",{className:`sc-tone-${r.tone}`,children:r.text})]},s.id)}),!n.runtimes.length&&P("li",{className:"sc-note",children:"No runtime advertised. Install Claude Code, Codex or Ollama on that machine."})]})]},n.id)})}):P("p",{className:"sc-note",children:"Nothing connected yet."})}import{useCallback as te,useEffect as le,useMemo as qe,useRef as je,useState as O}from"react";var ce=[{id:"claude",title:"The Claude plan you already pay for",cost:"Nothing beyond the plan",prerequisite:"Claude Code installed, and signed in",hint:"Install it from claude.ai/code, then run `claude` once and sign in.",expects:["claude-code"]},{id:"chatgpt",title:"The ChatGPT plan you already pay for",cost:"Nothing beyond the plan",prerequisite:"Codex installed, and signed in",hint:"Install Codex, then run `codex` once and sign in.",expects:["codex"]},{id:"local",title:"A model on your own machine",cost:"Free. You bought the hardware",prerequisite:"Ollama running, with a model pulled",hint:"Install Ollama, then `ollama pull` a model before pairing.",expects:["ollama","openai-api"]}];import{Fragment as Pe,jsx as a,jsxs as f}from"react/jsx-runtime";var Fe=2e3,De=1e4;function ue({client:e,initialCode:t,onConnected:n,showStatus:i=!0}){let[s,r]=O("loading"),[u,y]=O(null),[g,h]=O([]),[b,o]=O(null),[d,$]=O(t??""),[E,j]=O(null),[M,k]=O(null),[U,_]=O(!1),F=je(null);le(()=>{let l=!0;return Promise.all([e.context(),e.machines()]).then(([C,w])=>{l&&(y(C),h(w),F.current=new Set(w.map(R=>R.id)),t?r("code"):i&&w.length>0?r("manage"):r("choose"))}).catch(C=>{l&&(k(C.message),r("choose"))}),()=>{l=!1}},[e,t,i]),le(()=>{if(s!=="manage")return;let l=!0,C=setInterval(async()=>{try{let w=await e.machines();l&&h(w)}catch{}},De);return()=>{l=!1,clearInterval(C)}},[s,e]),le(()=>{if(s!=="waiting")return;let l=!0,C=setInterval(async()=>{try{let w=await e.machines();if(!l)return;h(w);let R=w.filter(B=>!F.current?.has(B.id));(R.find(B=>B.state!=="offline")??R[0])&&(r("connected"),n?.(w))}catch{}},Fe);return()=>{l=!1,clearInterval(C)}},[s,e,n]);let D=te(async()=>{k(null),_(!0);try{let l=await e.lookup(d);j(l),r("confirm")}catch(l){k(l.message)}finally{_(!1)}},[e,d]),re=te(async()=>{k(null),_(!0);try{await e.approve(d),r("waiting")}catch(l){k(l.message)}finally{_(!1)}},[e,d]),Z=te(async()=>{_(!0);try{await e.deny(d),r("denied")}catch(l){k(l.message)}finally{_(!1)}},[e,d]),W=qe(()=>u?.connect_url??null,[u]),Y=te(async(l,C)=>{try{await e.approveRuntime(l,C),h(await e.machines())}catch(w){k(w.message)}},[e]);return s==="loading"?a("div",{className:"soba-connect",children:a("div",{className:"sc-card",children:a("p",{className:"sc-note",children:"Loading\u2026"})})}):f("div",{className:"soba-connect",children:[s==="manage"&&f("div",{className:"sc-card",children:[a("h2",{className:"sc-title",children:"Your compute"}),f("p",{className:"sc-sub",children:[u?.app.name??"This app"," runs on these machines, while they are awake."]}),a(H,{machines:g,onApproveRuntime:Y}),M&&a("p",{className:"sc-error",children:M}),a("button",{type:"button",className:"sc-btn",style:{marginTop:16},onClick:()=>r("choose"),children:"Connect another machine"})]}),s==="choose"&&f("div",{className:"sc-card",children:[a("h2",{className:"sc-title",children:"Use the AI you already pay for"}),f("p",{className:"sc-sub",children:[u?.app.name??"This app"," can run on your machine instead of selling you credits. Pick what you already have."]}),a("div",{className:"sc-choices",children:ce.map(l=>f("button",{type:"button",className:"sc-choice","aria-pressed":b?.id===l.id,onClick:()=>o(l),children:[a("span",{className:"sc-choice-title",children:l.title}),a("br",{}),f("span",{className:"sc-choice-meta",children:[l.cost," \xB7 ",l.prerequisite]})]},l.id))}),b&&f("div",{className:"sc-stack",style:{marginTop:16},children:[a("p",{className:"sc-note",children:b.hint}),W?f(Pe,{children:[a("p",{className:"sc-label",children:"Install Soba Connect on the machine you want to use"}),a("a",{className:"sc-btn sc-btn-primary",href:"https://soba.so/soba-connect",target:"_blank",rel:"noreferrer noopener",children:"Get Soba Connect"}),a("p",{className:"sc-label",children:"Then paste this link into it"}),a("code",{className:"sc-code",children:W}),a("p",{className:"sc-note",children:"It will show you a short code. Nothing is pasted back: the credential is written straight to that machine."}),a("button",{type:"button",className:"sc-btn",onClick:()=>r("code"),children:"I have a code"})]}):a("p",{className:"sc-error",children:"This app has no publishable key yet, so there is nothing to pair against."})]})]}),s==="code"&&f("div",{className:"sc-card",children:[a("h2",{className:"sc-title",children:"Enter the code from that machine"}),a("p",{className:"sc-sub",children:"Eight characters, shown by Soba Connect once you paste the link."}),f("form",{className:"sc-stack",onSubmit:l=>{l.preventDefault(),D()},children:[a("input",{className:"sc-input",value:d,onChange:l=>$(l.target.value),placeholder:"ACDE-F234",autoComplete:"off",autoFocus:!0,"aria-label":"Pairing code"}),M&&a("p",{className:"sc-error",children:M}),a("button",{type:"submit",className:"sc-btn sc-btn-primary",disabled:U||d.length<8,children:U?"Checking\u2026":"Continue"})]})]}),s==="confirm"&&E&&f("div",{className:"sc-card",children:[a("h2",{className:"sc-title",children:"Attach this machine to your account?"}),f("p",{className:"sc-sub",children:["Once you approve, ",u?.app.name??"this app"," may send your runs to it. It can never see the account or the keys on it."]}),f("ul",{className:"sc-facts",children:[a(X,{k:"Code",v:E.user_code}),a(X,{k:"Machine",v:E.hostname??"not stated"}),a(X,{k:"System",v:[E.platform,E.arch].filter(Boolean).join(" / ")||"not stated"}),a(X,{k:"Asked by",v:E.client??"not stated"}),E.labels.length>0&&a(X,{k:"Labels",v:E.labels.join(", ")})]}),a("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."}),M&&a("p",{className:"sc-error",children:M}),f("div",{className:"sc-row",style:{marginTop:16},children:[a("button",{type:"button",className:"sc-btn sc-btn-primary",onClick:()=>void re(),disabled:U,children:"Approve"}),a("button",{type:"button",className:"sc-btn",onClick:()=>void Z(),disabled:U,children:"No, deny it"})]})]}),s==="waiting"&&f("div",{className:"sc-card",children:[a("h2",{className:"sc-title",children:"Approved. Waiting for it to connect\u2026"}),a("p",{className:"sc-sub",children:"The worker is starting on that machine. This page will change on its own the moment it arrives."}),a("p",{className:"sc-note",children:"If nothing happens, look at Soba Connect on that machine: it says what it is doing."})]}),s==="connected"&&f("div",{className:"sc-card",children:[a("h2",{className:"sc-title",children:"Connected"}),f("p",{className:"sc-sub",children:[u?.app.name??"This app"," will use this machine from now on, while it is awake."]}),i&&f(Pe,{children:[a(H,{machines:g,onApproveRuntime:Y}),a("button",{type:"button",className:"sc-btn",style:{marginTop:16},onClick:()=>r("manage"),children:"Done"})]})]}),s==="denied"&&f("div",{className:"sc-card",children:[a("h2",{className:"sc-title",children:"Declined"}),a("p",{className:"sc-sub",children:"That machine was not attached to your account, and the terminal has been told so."})]})]})}function X({k:e,v:t}){return f("li",{className:"sc-fact",children:[a("span",{className:"sc-fact-key",children:e}),a("span",{children:t})]})}import{createContext as Be,useContext as Ge}from"react";var pe=Be(null);function T(){let e=Ge(pe);if(!e)throw new Error("useSoba() must be used inside a <SobaProvider>.");return e}import{jsx as V}from"react/jsx-runtime";function He({className:e}){let{compute:t}=T(),n=e?`soba-connect ${e}`:"soba-connect";return t.loading&&!t.machines.length?V("div",{className:n,children:V("p",{className:"sc-note",children:"Loading\u2026"})}):t.error&&!t.machines.length?V("div",{className:n,children:V("p",{className:"sc-error",children:t.error.message})}):V("div",{className:n,children:V(H,{machines:t.machines})})}import{createContext as Ve,useContext as Je}from"react";var de=Ve(null);function xe(){return Je(de)}import{jsx as L}from"react/jsx-runtime";function We({initialCode:e,onConnected:t,className:n,showStatus:i}){let{error:s}=T(),r=xe();return s?L("div",{className:Ee(n),children:L("div",{className:"sc-card",children:L("p",{className:"sc-error",children:s.message})})}):r?L("div",{className:n,children:L(ue,{client:r,...e?{initialCode:e}:{},...t?{onConnected:t}:{},...i===void 0?{}:{showStatus:i}})}):L("div",{className:Ee(n),children:L("div",{className:"sc-card",children:L("p",{className:"sc-note",children:"Loading\u2026"})})})}function Ee(e){return e?`soba-connect ${e}`:"soba-connect"}var Ye=new Set(["delta","thinking","status","done","error"]);function ne(e){let t;try{t=JSON.parse(e)}catch{return null}if(!t||typeof t!="object")return null;let n=t,i=n.type==="event"?n.event:n;if(!i||typeof i!="object")return null;let s=i;if(typeof s.type!="string"||!Ye.has(s.type))return null;let r={type:s.type};return typeof s.text=="string"&&(r.text=s.text),typeof s.message=="string"&&(r.message=s.message),typeof s.tool=="string"&&(r.tool=s.tool),typeof s.tier=="string"&&(r.tier=s.tier),s.usage&&typeof s.usage=="object"&&(r.usage=s.usage),r}async function*me(e){let t=e.body;if(!t)throw new Error("That run response had no body to stream.");let n=(e.headers.get("content-type")??"").includes("ndjson"),i=t.getReader(),s=new TextDecoder,r="",u=[];try{for(;;){let g=await i.read();if(g.done)break;for(r+=s.decode(g.value,{stream:!0});;){let h=r.indexOf(`
3
+ `);if(h===-1)break;let b=_e(r.slice(0,h));if(r=r.slice(h+1),n){let o=b.trim()?ne(b):null;o&&(yield o);continue}if(b===""){let o=u.join(`
4
+ `);if(u=[],o==="[DONE]")return;let d=o?ne(o):null;d&&(yield d);continue}ke(b,u)}}!n&&r&&ke(_e(r),u);let y=n?r.trim():u.join(`
5
+ `);if(y&&y!=="[DONE]"){let g=ne(y);g&&(yield g)}}finally{i.cancel().catch(()=>{})}}function ke(e,t){if(!e||e.startsWith(":"))return;let n=e.indexOf(":");if(n===-1||e.slice(0,n)!=="data")return;let i=e.slice(n+1);t.push(i.startsWith(" ")?i.slice(1):i)}function _e(e){return e.endsWith("\r")?e.slice(0,-1):e}async function ze(e){let t="";for await(let n of e)if(n.type==="delta"&&n.text&&(t+=n.text),n.type==="error")throw new Error(n.message??"The run ended with an error.");return t}import{useState as Re}from"react";import{jsx as Te,jsxs as Qe}from"react/jsx-runtime";function Ke({plan:e,successUrl:t,cancelUrl:n,label:i,className:s}){let{plan:r}=T(),[u,y]=Re(!1),[g,h]=Re(null);async function b(){y(!0),h(null);try{let o=await r.checkout(e.id,{...t?{successUrl:t}:{},...n?{cancelUrl:n}:{}});window.location.assign(o)}catch(o){h(o.message),y(!1)}}return Qe("div",{className:s,children:[Te("button",{type:"button",className:"sc-btn sc-btn-primary",disabled:u,onClick:()=>void b(),children:u?"Opening\u2026":i??`Subscribe \u2014 $${e.price.toLocaleString()}/${e.interval}`}),g&&Te("p",{className:"sc-error",children:g})]})}import{useState as Me}from"react";import{Fragment as et,jsx as m,jsxs as N}from"react/jsx-runtime";var Xe={"user-hardware":"your own machine","user-subscription":"your own AI subscription","open-weights":"open models we host",frontier:"frontier models"};function Ze({onSelect:e,successUrl:t,cancelUrl:n,showCompute:i=!0,className:s}){let{plan:r}=T(),[u,y]=Me(null),[g,h]=Me(null);if(r.error)return m("div",{className:s,children:m("div",{className:"sc-card",children:m("p",{className:"sc-error",children:r.error.message})})});if(r.loading)return m("div",{className:s,children:m("div",{className:"sc-card",children:m("p",{className:"sc-note",children:"Loading plans\u2026"})})});if(r.plans.length===0)return m("div",{className:s,children:m("div",{className:"sc-card",children:m("p",{className:"sc-note",children:"This app has no plans yet."})})});async function b(o){y(o.id),h(null);try{if(o.selectable)await r.select(o.id);else if(e)e(o);else{let d=await r.checkout(o.id,{...t?{successUrl:t}:{},...n?{cancelUrl:n}:{}});window.location.assign(d);return}}catch(d){h(d.message)}finally{y(null)}}return N("div",{className:s,children:[m("div",{className:"sc-plans",children:r.plans.map(o=>{let d=r.current?.plan_id===o.id;return N("div",{className:d?"sc-plan sc-plan-current":"sc-plan",children:[N("div",{className:"sc-row-between",children:[m("span",{className:"sc-title",children:o.name}),d&&m("span",{className:"sc-plan-badge",children:"Current"})]}),o.description&&m("p",{className:"sc-sub",children:o.description}),m("p",{className:"sc-plan-price",children:o.price===0?m("span",{className:"sc-plan-amount",children:"Free"}):N(et,{children:[N("span",{className:"sc-plan-amount",children:["$",o.price.toLocaleString()]}),N("span",{className:"sc-plan-interval",children:["/",o.interval]})]})}),N("div",{className:"sc-facts",children:[N("div",{className:"sc-fact",children:[m("span",{className:"sc-fact-key",children:"Included"}),m("span",{children:o.included_units===null?"Unlimited":`${o.included_units.toLocaleString()} ${o.unit}${o.included_units===1?"":"s"}`})]}),o.overage_per_unit>0&&N("div",{className:"sc-fact",children:[m("span",{className:"sc-fact-key",children:"Then"}),N("span",{children:["$",o.overage_per_unit," / ",o.unit]})]}),o.trial_days>0&&N("div",{className:"sc-fact",children:[m("span",{className:"sc-fact-key",children:"Trial"}),N("span",{children:[o.trial_days," days"]})]}),i&&N("div",{className:"sc-fact",children:[m("span",{className:"sc-fact-key",children:"Runs on"}),m("span",{children:o.route_allow===null?"any compute":o.route_allow.length===0?"no compute":o.route_allow.map($=>Xe[$]??$).join(", ")})]})]}),m("button",{type:"button",className:d?"sc-btn":"sc-btn sc-btn-primary",disabled:d||u!==null,onClick:()=>void b(o),children:d?"Your plan":u===o.id?o.selectable?"Switching\u2026":"Opening\u2026":o.selectable?"Choose":`Subscribe \u2014 $${o.price.toLocaleString()}/${o.interval}`})]},o.id)})}),g&&m("p",{className:"sc-error",children:g})]})}import{useCallback as q,useEffect as J,useMemo as fe,useRef as nt,useState as x}from"react";var se=class{#t;#s=null;#n=null;#e;#r=new Set;constructor(t){this.#e=t,this.#t=t.token??null}get token(){return this.#t}get expiresAt(){return this.#s}get canRefresh(){return!!this.#e.endpoint}get refreshAt(){return!this.canRefresh||this.#s===null?null:Math.max(Date.now()+1e4,this.#s-6e4)}subscribe(t){return this.#r.add(t),()=>this.#r.delete(t)}async ensure(){return this.#t?this.#t:this.refresh()}async refresh(){if(this.#n)return this.#n;let t=this.#e.endpoint;if(!t)throw new Error("This Soba session cannot be refreshed. Pass `sessionEndpoint` so the provider can mint a new one.");let n=this.#e.fetchImpl??fetch.bind(globalThis);this.#n=(async()=>{let i=await n(t,{method:"POST",headers:{"content-type":"application/json"},credentials:"same-origin",body:JSON.stringify(this.#e.user?{user:this.#e.user}:{})});if(!i.ok)throw new Error(`Could not start a Soba session (${i.status} from ${t}).`);let s=await i.json(),r=s.session??s.token;if(!r)throw new Error(`${t} answered without a \`session\`. See @soba-so/react's README.`);let u=s.expires_in??s.expiresIn;this.#t=r,this.#s=typeof u=="number"?Date.now()+u*1e3:null;for(let y of this.#r)y();return r})();try{return await this.#n}finally{this.#n=null}}};function Ae(e,t=fetch.bind(globalThis)){return async(n,i)=>{let s=await t(n,i);if(s.status!==401||!e.canRefresh)return s;let r;try{r=await e.refresh()}catch{return s}return t(n,tt(i,r))}}function tt(e,t){let n=new Headers(e?.headers);return n.set("authorization",`Bearer ${t}`),{...e,headers:n}}var Ie="data-soba-react",Oe=!1;function he(){if(Oe||typeof document>"u"||(Oe=!0,document.querySelector(`style[${Ie}]`)))return;let e=document.createElement("style");e.setAttribute(Ie,""),e.textContent=`.soba-connect {
6
+ --sc-ground: #ffffff;
7
+ --sc-sheet: #ffffff;
8
+ --sc-raised: #f6f6f7;
9
+ --sc-line: #e9e9eb;
10
+ --sc-line-2: #dcdce0;
11
+ --sc-text: #1a1a1c;
12
+ --sc-text-2: #3f3f46;
13
+ --sc-muted: #6b6b73;
14
+ --sc-accent: #18181b;
15
+ --sc-accent-fg: #ffffff;
16
+ --sc-ok: #22a06b;
17
+ --sc-warn: #b06f14;
18
+ --sc-r: 10px;
19
+
20
+ box-sizing: border-box;
21
+ max-width: 560px;
22
+ margin: 0 auto;
23
+ color: var(--sc-text);
24
+ font-family: "Inter", ui-sans-serif, -apple-system, "Helvetica Neue", Arial, sans-serif;
25
+ font-size: 15px;
26
+ line-height: 1.5;
27
+ letter-spacing: -0.005em;
28
+ }
29
+ .soba-connect *,
30
+ .soba-connect *::before,
31
+ .soba-connect *::after {
32
+ box-sizing: inherit;
33
+ }
34
+
35
+ .sc-card {
36
+ background: var(--sc-sheet);
37
+ border: 1px solid var(--sc-line);
38
+ border-radius: var(--sc-r);
39
+ padding: 20px;
40
+ }
41
+ .sc-card + .sc-card {
42
+ margin-top: 12px;
43
+ }
44
+
45
+ .sc-title {
46
+ font-size: 20px;
47
+ font-weight: 650;
48
+ letter-spacing: -0.02em;
49
+ margin: 0 0 4px;
50
+ }
51
+ .sc-sub {
52
+ color: var(--sc-muted);
53
+ margin: 0 0 16px;
54
+ }
55
+ .sc-label {
56
+ font-size: 13px;
57
+ color: var(--sc-muted);
58
+ margin: 0 0 6px;
59
+ letter-spacing: -0.005em;
60
+ }
61
+
62
+ .sc-choices {
63
+ display: grid;
64
+ gap: 8px;
65
+ }
66
+ .sc-choice {
67
+ display: block;
68
+ width: 100%;
69
+ text-align: left;
70
+ background: var(--sc-sheet);
71
+ border: 1px solid var(--sc-line-2);
72
+ border-radius: var(--sc-r);
73
+ padding: 12px 14px;
74
+ cursor: pointer;
75
+ font: inherit;
76
+ color: inherit;
77
+ }
78
+ .sc-choice:hover {
79
+ background: var(--sc-raised);
80
+ }
81
+ .sc-choice[aria-pressed="true"] {
82
+ border-color: var(--sc-accent);
83
+ box-shadow: inset 0 0 0 1px var(--sc-accent);
84
+ }
85
+ .sc-choice-title {
86
+ font-weight: 550;
87
+ }
88
+ .sc-choice-meta {
89
+ color: var(--sc-muted);
90
+ font-size: 13px;
91
+ }
92
+
93
+ .sc-code {
94
+ display: block;
95
+ background: var(--sc-raised);
96
+ border: 1px solid var(--sc-line);
97
+ border-radius: 8px;
98
+ padding: 12px 14px;
99
+ font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace;
100
+ font-size: 13px;
101
+ overflow-x: auto;
102
+ white-space: pre;
103
+ }
104
+
105
+ .sc-row {
106
+ display: flex;
107
+ gap: 8px;
108
+ align-items: center;
109
+ }
110
+ .sc-row-between {
111
+ display: flex;
112
+ gap: 12px;
113
+ align-items: center;
114
+ justify-content: space-between;
115
+ }
116
+ .sc-stack {
117
+ display: grid;
118
+ gap: 12px;
119
+ }
120
+
121
+ .sc-btn {
122
+ font: inherit;
123
+ font-weight: 550;
124
+ border-radius: 8px;
125
+ padding: 9px 14px;
126
+ border: 1px solid var(--sc-line-2);
127
+ background: var(--sc-sheet);
128
+ color: var(--sc-text);
129
+ cursor: pointer;
130
+ }
131
+ .sc-btn:hover {
132
+ background: var(--sc-raised);
133
+ }
134
+
135
+ a.sc-btn {
136
+ display: inline-block;
137
+ text-align: center;
138
+ text-decoration: none;
139
+ }
140
+ .sc-btn-primary {
141
+ background: var(--sc-accent);
142
+ color: var(--sc-accent-fg);
143
+ border-color: var(--sc-accent);
144
+ }
145
+ .sc-btn-primary:hover {
146
+ opacity: 0.9;
147
+ background: var(--sc-accent);
148
+ }
149
+ .sc-btn:disabled {
150
+ opacity: 0.5;
151
+ cursor: default;
152
+ }
153
+
154
+ .sc-input {
155
+ font: inherit;
156
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
157
+ letter-spacing: 0.08em;
158
+ text-transform: uppercase;
159
+ padding: 10px 12px;
160
+ border: 1px solid var(--sc-line-2);
161
+ border-radius: 8px;
162
+ background: var(--sc-sheet);
163
+ color: inherit;
164
+ width: 100%;
165
+ }
166
+
167
+ .sc-dot {
168
+ width: 8px;
169
+ height: 8px;
170
+ border-radius: 50%;
171
+ flex: none;
172
+ background: var(--sc-muted);
173
+ }
174
+ .sc-dot-ok {
175
+ background: var(--sc-ok);
176
+ }
177
+ .sc-dot-busy {
178
+ background: var(--sc-warn);
179
+ }
180
+ .sc-dot-off {
181
+ background: var(--sc-line-2);
182
+ }
183
+
184
+ .sc-tone-ok {
185
+ color: var(--sc-ok);
186
+ }
187
+ .sc-tone-warn {
188
+ color: var(--sc-warn);
189
+ }
190
+ .sc-tone-unknown {
191
+ color: var(--sc-muted);
192
+ }
193
+
194
+ .sc-error {
195
+ color: var(--sc-warn);
196
+ font-size: 14px;
197
+ }
198
+ .sc-note {
199
+ color: var(--sc-muted);
200
+ font-size: 13px;
201
+ }
202
+
203
+ .sc-facts {
204
+ display: grid;
205
+ gap: 4px;
206
+ margin: 0;
207
+ padding: 0;
208
+ list-style: none;
209
+ }
210
+ .sc-fact {
211
+ display: flex;
212
+ gap: 10px;
213
+ font-size: 14px;
214
+ }
215
+ .sc-fact-key {
216
+ color: var(--sc-muted);
217
+ min-width: 92px;
218
+ }
219
+
220
+ .sc-machine {
221
+ border-top: 1px solid var(--sc-line);
222
+ padding-top: 12px;
223
+ margin-top: 12px;
224
+ }
225
+ .sc-machine:first-child {
226
+ border-top: 0;
227
+ padding-top: 0;
228
+ margin-top: 0;
229
+ }
230
+ .sc-runtimes {
231
+ display: grid;
232
+ gap: 4px;
233
+ margin: 8px 0 0;
234
+ padding: 0;
235
+ list-style: none;
236
+ font-size: 14px;
237
+ }
238
+
239
+ @media (prefers-color-scheme: dark) {
240
+ .soba-connect:not([data-theme="light"]) {
241
+ --sc-ground: #0f0f11;
242
+ --sc-sheet: #161618;
243
+ --sc-raised: #1d1d20;
244
+ --sc-line: #26262a;
245
+ --sc-line-2: #34343a;
246
+ --sc-text: #f2f2f3;
247
+ --sc-text-2: #c9c9cd;
248
+ --sc-muted: #8d8d95;
249
+ --sc-accent: #f2f2f3;
250
+ --sc-accent-fg: #131316;
251
+ }
252
+ }
253
+
254
+ .sc-plans {
255
+ display: grid;
256
+ gap: 12px;
257
+
258
+ grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
259
+ }
260
+
261
+ .sc-plan {
262
+ display: flex;
263
+ flex-direction: column;
264
+ gap: 10px;
265
+ padding: 16px;
266
+ border: 1px solid var(--sc-line, #e5e5e7);
267
+ border-radius: var(--sc-radius, 10px);
268
+ background: var(--sc-card, #fff);
269
+ }
270
+
271
+ .sc-plan-current {
272
+ border-color: var(--sc-text, #18181b);
273
+ }
274
+
275
+ .sc-plan-badge {
276
+ font-size: 11px;
277
+ padding: 2px 6px;
278
+ border-radius: 999px;
279
+ background: var(--sc-text, #18181b);
280
+ color: var(--sc-card, #fff);
281
+ }
282
+
283
+ .sc-plan-price {
284
+ margin: 0;
285
+ display: flex;
286
+ align-items: baseline;
287
+ gap: 2px;
288
+ }
289
+
290
+ .sc-plan-amount {
291
+ font-size: 28px;
292
+ font-weight: 600;
293
+ letter-spacing: -0.02em;
294
+
295
+ font-variant-numeric: tabular-nums;
296
+ }
297
+
298
+ .sc-plan-interval {
299
+ font-size: 13px;
300
+ color: var(--sc-muted, #6b6b73);
301
+ }
302
+
303
+ .sc-plan .sc-btn {
304
+ margin-top: auto;
305
+ }
306
+ `,document.head.appendChild(e)}import{jsx as Le}from"react/jsx-runtime";var st="https://www.soba.so",rt=15e3;function ot(e){let{children:t,session:n,sessionEndpoint:i,user:s,runEndpoint:r,baseUrl:u=st,pollMs:y=rt,injectStyles:g=!0,fetchImpl:h,onError:b}=e,[o,d]=x(n??null),[$,E]=x(null),[j,M]=x(null),[k,U]=x(null),[_,F]=x(!0),[D,re]=x([]),[Z,W]=x(null),[Y,l]=x(!0),[C,w]=x([]),[R,ge]=x(null),[B,ye]=x(null),[be,$e]=x(!0),ve=nt(b);ve.current=b;let z=q(c=>{E(c),ve.current?.(c)},[]);J(()=>{g&&he()},[g]);let S=fe(()=>new se({...n?{token:n}:{},...i?{endpoint:i}:{},...s?{user:s}:{},...h?{fetchImpl:h}:{}}),[n,i,s,h]);J(()=>{let c=!0,v=S.subscribe(()=>{c&&d(S.token)});return!S.token&&!S.canRefresh?z(new Error("<SobaProvider> needs a `session` or a `sessionEndpoint`. A browser cannot mint one: that takes your secret key.")):S.ensure().then(()=>c&&d(S.token),I=>c&&z(I)),()=>{c=!1,v()}},[S,z]),J(()=>{let c=S.refreshAt;if(c===null)return;let v=setTimeout(()=>{S.refresh().catch(I=>z(I))},Math.max(0,c-Date.now()));return()=>clearTimeout(v)},[S,z,o]);let p=fe(()=>o?new Q({session:o,baseUrl:u,fetchImpl:Ae(S,h??fetch.bind(globalThis))}):null,[o,u,S,h]);J(()=>{if(!p)return;let c=!0;return F(!0),p.context().then(v=>{c&&(M(v),U(null),F(!1))},v=>{c&&(U(v),F(!1))}),()=>{c=!1}},[p]);let K=q(async()=>{if(p)try{let c=await p.machines();re(c),W(null)}catch(c){W(c)}finally{l(!1)}},[p]),A=q(async()=>{if(p)try{let c=await p.plans();w(c.plans),ge(c.current),ye(null)}catch(c){ye(c)}finally{$e(!1)}},[p]);J(()=>{p&&A()},[p,A]);let Ce=q(async c=>{if(!p)throw new Error("No session yet.");await p.selectPlan(c),await A()},[p,A]),we=q(async(c,v)=>{if(!p)throw new Error("No session yet.");let I=await p.checkoutPlan(c,v);if(!I.url)throw new Error("Checkout could not be started.");return I.url},[p]),Ne=q(async()=>{if(!p)throw new Error("No session yet.");await p.leavePlan(),await A()},[p,A]);J(()=>{if(!p)return;let c=!0;K();let v=setInterval(()=>{typeof document<"u"&&document.hidden||c&&K()},y);return()=>{c=!1,clearInterval(v)}},[p,K,y]);let Se=q(async c=>{if(!r)throw new Error("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.");let{signal:v,...I}=c,oe=await(h??fetch.bind(globalThis))(r,{method:"POST",headers:{"content-type":"application/json",accept:"text/event-stream"},credentials:"same-origin",body:JSON.stringify(I),...v?{signal:v}:{}});if(!oe.ok)throw new Error(`${r} answered ${oe.status}.`);return me(oe)},[r,h]),Ue=fe(()=>({session:o,client:p,error:$,plan:{plans:C,current:R,plan:R?C.find(c=>c.id===R.plan_id)??null:null,loading:be,error:B,select:Ce,checkout:we,leave:Ne,refresh:A},compute:{machines:D,connected:D.length>0,online:D.some(c=>c.state!=="offline"),loading:Y,error:Z,refresh:K},connect:{app:j?.app??null,command:j?.command??null,publishableKey:j?.publishable_key??null,loading:_,error:k},run:Se}),[o,p,$,D,Y,Z,K,C,R,be,B,Ce,we,Ne,A,j,_,k,Se]);return Le(de.Provider,{value:p,children:Le(pe.Provider,{value:Ue,children:t})})}export{He as ComputeStatus,We as ConnectCompute,Ke as SobaCheckout,Ze as SobaPlans,ot as SobaProvider,ze as collectText,he as injectStyles,ne as parseEvent,me as readEvents,T as useSoba};
@@ -0,0 +1,60 @@
1
+ /**
2
+ * THE PIECE THE BROWSER CANNOT DO FOR ITSELF.
3
+ *
4
+ * `ConnectClient` takes an `est_` session token and uses it. Nothing in a
5
+ * browser can MINT one: that takes the app's `sk_soba_` secret key, which must
6
+ * never leave the customer's server. So this holds a session, asks the
7
+ * customer's own endpoint for a new one when there is none, and refreshes it
8
+ * before and after it expires.
9
+ *
10
+ * Sessions live ten minutes. A settings page left open for an hour is the
11
+ * ordinary case, not the edge case, so refresh is not optional: without it every
12
+ * component in the tree would quietly start failing at minute eleven.
13
+ *
14
+ * Framework-free, so the same logic is testable without a renderer.
15
+ */
16
+ export interface SessionResponse {
17
+ /** The `est_` token. `session` is the platform's own field name, so a
18
+ * customer can proxy `POST /v1/end_users/session` verbatim. */
19
+ session?: string;
20
+ /** Seconds. `token` and `expires_in` are accepted as aliases because a
21
+ * hand-rolled endpoint tends to invent one of them. */
22
+ token?: string;
23
+ expires_in?: number;
24
+ expiresIn?: number;
25
+ }
26
+ export interface SessionSourceOptions {
27
+ /** A token you minted yourself. Cannot be refreshed: when it expires, the
28
+ * holder reports it rather than pretending. */
29
+ token?: string;
30
+ /** Your route. POSTed `{ user }`, expected to answer `{ session, expires_in }`. */
31
+ endpoint?: string;
32
+ /** Passed to your endpoint so it knows whose session to mint. */
33
+ user?: string;
34
+ fetchImpl?: typeof fetch;
35
+ }
36
+ export declare class SessionHolder {
37
+ #private;
38
+ constructor(options: SessionSourceOptions);
39
+ get token(): string | null;
40
+ /** When the current token stops working, if the endpoint said. */
41
+ get expiresAt(): number | null;
42
+ /** False for a token handed in directly: there is nowhere to get another. */
43
+ get canRefresh(): boolean;
44
+ /** When to ask for the next one, or null when there is nothing to schedule. */
45
+ get refreshAt(): number | null;
46
+ subscribe(listener: () => void): () => void;
47
+ /** The token, minting one if there is none. Concurrent callers share a
48
+ * request: three components mounting together must not mint three sessions. */
49
+ ensure(): Promise<string>;
50
+ refresh(): Promise<string>;
51
+ }
52
+ /**
53
+ * A `fetch` that heals a dead session.
54
+ *
55
+ * `ConnectClient` writes the `Authorization` header itself from the token it was
56
+ * built with, so a refresh has to rewrite the header on the retry rather than
57
+ * hope the client picks it up. One retry only: a 401 that survives a fresh
58
+ * session is a real 401, and retrying it forever would hammer the endpoint.
59
+ */
60
+ export declare function createSessionFetch(holder: SessionHolder, base?: typeof fetch): typeof fetch;
@@ -0,0 +1,301 @@
1
+ .soba-connect {
2
+ --sc-ground: #ffffff;
3
+ --sc-sheet: #ffffff;
4
+ --sc-raised: #f6f6f7;
5
+ --sc-line: #e9e9eb;
6
+ --sc-line-2: #dcdce0;
7
+ --sc-text: #1a1a1c;
8
+ --sc-text-2: #3f3f46;
9
+ --sc-muted: #6b6b73;
10
+ --sc-accent: #18181b;
11
+ --sc-accent-fg: #ffffff;
12
+ --sc-ok: #22a06b;
13
+ --sc-warn: #b06f14;
14
+ --sc-r: 10px;
15
+
16
+ box-sizing: border-box;
17
+ max-width: 560px;
18
+ margin: 0 auto;
19
+ color: var(--sc-text);
20
+ font-family: "Inter", ui-sans-serif, -apple-system, "Helvetica Neue", Arial, sans-serif;
21
+ font-size: 15px;
22
+ line-height: 1.5;
23
+ letter-spacing: -0.005em;
24
+ }
25
+ .soba-connect *,
26
+ .soba-connect *::before,
27
+ .soba-connect *::after {
28
+ box-sizing: inherit;
29
+ }
30
+
31
+ .sc-card {
32
+ background: var(--sc-sheet);
33
+ border: 1px solid var(--sc-line);
34
+ border-radius: var(--sc-r);
35
+ padding: 20px;
36
+ }
37
+ .sc-card + .sc-card {
38
+ margin-top: 12px;
39
+ }
40
+
41
+ .sc-title {
42
+ font-size: 20px;
43
+ font-weight: 650;
44
+ letter-spacing: -0.02em;
45
+ margin: 0 0 4px;
46
+ }
47
+ .sc-sub {
48
+ color: var(--sc-muted);
49
+ margin: 0 0 16px;
50
+ }
51
+ .sc-label {
52
+ font-size: 13px;
53
+ color: var(--sc-muted);
54
+ margin: 0 0 6px;
55
+ letter-spacing: -0.005em;
56
+ }
57
+
58
+ .sc-choices {
59
+ display: grid;
60
+ gap: 8px;
61
+ }
62
+ .sc-choice {
63
+ display: block;
64
+ width: 100%;
65
+ text-align: left;
66
+ background: var(--sc-sheet);
67
+ border: 1px solid var(--sc-line-2);
68
+ border-radius: var(--sc-r);
69
+ padding: 12px 14px;
70
+ cursor: pointer;
71
+ font: inherit;
72
+ color: inherit;
73
+ }
74
+ .sc-choice:hover {
75
+ background: var(--sc-raised);
76
+ }
77
+ .sc-choice[aria-pressed="true"] {
78
+ border-color: var(--sc-accent);
79
+ box-shadow: inset 0 0 0 1px var(--sc-accent);
80
+ }
81
+ .sc-choice-title {
82
+ font-weight: 550;
83
+ }
84
+ .sc-choice-meta {
85
+ color: var(--sc-muted);
86
+ font-size: 13px;
87
+ }
88
+
89
+ .sc-code {
90
+ display: block;
91
+ background: var(--sc-raised);
92
+ border: 1px solid var(--sc-line);
93
+ border-radius: 8px;
94
+ padding: 12px 14px;
95
+ font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace;
96
+ font-size: 13px;
97
+ overflow-x: auto;
98
+ white-space: pre;
99
+ }
100
+
101
+ .sc-row {
102
+ display: flex;
103
+ gap: 8px;
104
+ align-items: center;
105
+ }
106
+ .sc-row-between {
107
+ display: flex;
108
+ gap: 12px;
109
+ align-items: center;
110
+ justify-content: space-between;
111
+ }
112
+ .sc-stack {
113
+ display: grid;
114
+ gap: 12px;
115
+ }
116
+
117
+ .sc-btn {
118
+ font: inherit;
119
+ font-weight: 550;
120
+ border-radius: 8px;
121
+ padding: 9px 14px;
122
+ border: 1px solid var(--sc-line-2);
123
+ background: var(--sc-sheet);
124
+ color: var(--sc-text);
125
+ cursor: pointer;
126
+ }
127
+ .sc-btn:hover {
128
+ background: var(--sc-raised);
129
+ }
130
+
131
+ a.sc-btn {
132
+ display: inline-block;
133
+ text-align: center;
134
+ text-decoration: none;
135
+ }
136
+ .sc-btn-primary {
137
+ background: var(--sc-accent);
138
+ color: var(--sc-accent-fg);
139
+ border-color: var(--sc-accent);
140
+ }
141
+ .sc-btn-primary:hover {
142
+ opacity: 0.9;
143
+ background: var(--sc-accent);
144
+ }
145
+ .sc-btn:disabled {
146
+ opacity: 0.5;
147
+ cursor: default;
148
+ }
149
+
150
+ .sc-input {
151
+ font: inherit;
152
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
153
+ letter-spacing: 0.08em;
154
+ text-transform: uppercase;
155
+ padding: 10px 12px;
156
+ border: 1px solid var(--sc-line-2);
157
+ border-radius: 8px;
158
+ background: var(--sc-sheet);
159
+ color: inherit;
160
+ width: 100%;
161
+ }
162
+
163
+ .sc-dot {
164
+ width: 8px;
165
+ height: 8px;
166
+ border-radius: 50%;
167
+ flex: none;
168
+ background: var(--sc-muted);
169
+ }
170
+ .sc-dot-ok {
171
+ background: var(--sc-ok);
172
+ }
173
+ .sc-dot-busy {
174
+ background: var(--sc-warn);
175
+ }
176
+ .sc-dot-off {
177
+ background: var(--sc-line-2);
178
+ }
179
+
180
+ .sc-tone-ok {
181
+ color: var(--sc-ok);
182
+ }
183
+ .sc-tone-warn {
184
+ color: var(--sc-warn);
185
+ }
186
+ .sc-tone-unknown {
187
+ color: var(--sc-muted);
188
+ }
189
+
190
+ .sc-error {
191
+ color: var(--sc-warn);
192
+ font-size: 14px;
193
+ }
194
+ .sc-note {
195
+ color: var(--sc-muted);
196
+ font-size: 13px;
197
+ }
198
+
199
+ .sc-facts {
200
+ display: grid;
201
+ gap: 4px;
202
+ margin: 0;
203
+ padding: 0;
204
+ list-style: none;
205
+ }
206
+ .sc-fact {
207
+ display: flex;
208
+ gap: 10px;
209
+ font-size: 14px;
210
+ }
211
+ .sc-fact-key {
212
+ color: var(--sc-muted);
213
+ min-width: 92px;
214
+ }
215
+
216
+ .sc-machine {
217
+ border-top: 1px solid var(--sc-line);
218
+ padding-top: 12px;
219
+ margin-top: 12px;
220
+ }
221
+ .sc-machine:first-child {
222
+ border-top: 0;
223
+ padding-top: 0;
224
+ margin-top: 0;
225
+ }
226
+ .sc-runtimes {
227
+ display: grid;
228
+ gap: 4px;
229
+ margin: 8px 0 0;
230
+ padding: 0;
231
+ list-style: none;
232
+ font-size: 14px;
233
+ }
234
+
235
+ @media (prefers-color-scheme: dark) {
236
+ .soba-connect:not([data-theme="light"]) {
237
+ --sc-ground: #0f0f11;
238
+ --sc-sheet: #161618;
239
+ --sc-raised: #1d1d20;
240
+ --sc-line: #26262a;
241
+ --sc-line-2: #34343a;
242
+ --sc-text: #f2f2f3;
243
+ --sc-text-2: #c9c9cd;
244
+ --sc-muted: #8d8d95;
245
+ --sc-accent: #f2f2f3;
246
+ --sc-accent-fg: #131316;
247
+ }
248
+ }
249
+
250
+ .sc-plans {
251
+ display: grid;
252
+ gap: 12px;
253
+
254
+ grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
255
+ }
256
+
257
+ .sc-plan {
258
+ display: flex;
259
+ flex-direction: column;
260
+ gap: 10px;
261
+ padding: 16px;
262
+ border: 1px solid var(--sc-line, #e5e5e7);
263
+ border-radius: var(--sc-radius, 10px);
264
+ background: var(--sc-card, #fff);
265
+ }
266
+
267
+ .sc-plan-current {
268
+ border-color: var(--sc-text, #18181b);
269
+ }
270
+
271
+ .sc-plan-badge {
272
+ font-size: 11px;
273
+ padding: 2px 6px;
274
+ border-radius: 999px;
275
+ background: var(--sc-text, #18181b);
276
+ color: var(--sc-card, #fff);
277
+ }
278
+
279
+ .sc-plan-price {
280
+ margin: 0;
281
+ display: flex;
282
+ align-items: baseline;
283
+ gap: 2px;
284
+ }
285
+
286
+ .sc-plan-amount {
287
+ font-size: 28px;
288
+ font-weight: 600;
289
+ letter-spacing: -0.02em;
290
+
291
+ font-variant-numeric: tabular-nums;
292
+ }
293
+
294
+ .sc-plan-interval {
295
+ font-size: 13px;
296
+ color: var(--sc-muted, #6b6b73);
297
+ }
298
+
299
+ .sc-plan .sc-btn {
300
+ margin-top: auto;
301
+ }
@@ -0,0 +1 @@
1
+ export declare function injectStyles(): void;
@@ -0,0 +1,155 @@
1
+ /**
2
+ * THE BROWSER-FACING SHAPES.
3
+ *
4
+ * These are the JSON shapes of `GET /v1/machines`, so they are snake_case. That
5
+ * is deliberate: renaming fields on the way through would make the network tab
6
+ * and the code disagree about what a thing is called.
7
+ */
8
+ export type MachineState = "online" | "busy" | "offline";
9
+ export type CostClass = "user-hardware" | "user-subscription" | "open-weights" | "frontier";
10
+ export interface Runtime {
11
+ id: string;
12
+ display_name: string;
13
+ version: string | null;
14
+ models: string[];
15
+ cost_class: CostClass;
16
+ supports_tools: boolean;
17
+ /**
18
+ * TRI-STATE, and it stays tri-state all the way to the screen. `true` is
19
+ * signed in, `false` is a positive finding that it is signed OUT and the one
20
+ * a person can fix, and `null` means the probe could not tell. Rendering the
21
+ * third as the second tells someone their working setup is broken.
22
+ */
23
+ authenticated: boolean | null;
24
+ auth_hint: string | null;
25
+ }
26
+ export interface Machine {
27
+ id: string;
28
+ label: string | null;
29
+ platform: string | null;
30
+ arch: string | null;
31
+ version: string | null;
32
+ state: MachineState;
33
+ active: number;
34
+ capacity: number;
35
+ always_on: boolean;
36
+ last_seen_at: string | null;
37
+ runtimes: Runtime[];
38
+ }
39
+ /** A device code someone is being asked to approve. */
40
+ export interface PendingCode {
41
+ user_code: string;
42
+ client: string | null;
43
+ hostname: string | null;
44
+ platform: string | null;
45
+ arch: string | null;
46
+ labels: string[];
47
+ requested_at: string;
48
+ expires_at: string;
49
+ approved: boolean;
50
+ denied: boolean;
51
+ }
52
+ /** Everything the approval page needs, in one request. */
53
+ export interface ConnectContext {
54
+ app: {
55
+ name: string;
56
+ };
57
+ publishable_key: string | null;
58
+ /** The link a person pastes into Soba Connect. */
59
+ connect_url: string | null;
60
+ /** Retired: always null. There is no pairing command any more. */
61
+ command: string | null;
62
+ }
63
+ /**
64
+ * The escape hatch, as an interface rather than the class behind it. Structural
65
+ * on purpose: you can stub it in a test, and nothing about the class leaks into
66
+ * this package's public types.
67
+ */
68
+ /** A plan, as the pricing endpoints return it. */
69
+ export interface Plan {
70
+ id: string;
71
+ name: string;
72
+ description: string | null;
73
+ /** Dollars per interval. */
74
+ price: number;
75
+ interval: string;
76
+ unit: string;
77
+ included_units: number | null;
78
+ /** Dollars per unit past the allowance. */
79
+ overage_per_unit: number;
80
+ trial_days: number;
81
+ /** What a run on this plan may use, in order. The dimension no other pricing
82
+ * component has, and the reason these plans are worth rendering from data. */
83
+ route_prefer: string[];
84
+ route_allow: string[] | null;
85
+ /** Whether it can be taken without a payment rail. */
86
+ selectable: boolean;
87
+ }
88
+ export interface CurrentPlan {
89
+ plan_id: string;
90
+ units_used: number;
91
+ period_end: string | null;
92
+ trial_ends_at: string | null;
93
+ }
94
+ export interface ConnectApi {
95
+ context(): Promise<ConnectContext>;
96
+ machines(): Promise<Machine[]>;
97
+ plans(): Promise<{
98
+ plans: Plan[];
99
+ current: CurrentPlan | null;
100
+ }>;
101
+ selectPlan(planId: string): Promise<unknown>;
102
+ checkoutPlan(planId: string, urls?: {
103
+ successUrl?: string;
104
+ cancelUrl?: string;
105
+ }): Promise<{
106
+ url: string | null;
107
+ }>;
108
+ leavePlan(): Promise<{
109
+ ok: true;
110
+ }>;
111
+ lookup(code: string): Promise<PendingCode>;
112
+ approve(code: string): Promise<{
113
+ ok: true;
114
+ }>;
115
+ deny(code: string): Promise<{
116
+ ok: true;
117
+ }>;
118
+ }
119
+ /**
120
+ * THE EVENT STREAM.
121
+ *
122
+ * The five type values are stable. New information arrives as optional fields
123
+ * on the existing types, never as a new type, so a client written against this
124
+ * list stays correct. Declared here so a browser app does not have to install a
125
+ * server SDK to read five field names.
126
+ */
127
+ export type AgentEventType = "delta" | "thinking" | "status" | "done" | "error";
128
+ export interface RunUsage {
129
+ costClass?: CostClass;
130
+ costMicros?: number;
131
+ model: string;
132
+ provider?: string;
133
+ inputTokens: number;
134
+ outputTokens: number;
135
+ cacheReadTokens: number;
136
+ cacheWriteTokens: number;
137
+ webSearches: number;
138
+ }
139
+ export interface AgentEvent {
140
+ type: AgentEventType;
141
+ /** `delta`: the NEW text, append it. `thinking`: the RUNNING TOTAL, replace it. */
142
+ text?: string;
143
+ /** `status` / `error`: human-readable copy, safe to show a user. */
144
+ message?: string;
145
+ /** On a `status` raised by a tool: a stable slug to map to an icon. */
146
+ tool?: string;
147
+ /** On `done`: what the run consumed, when the runtime could report it. */
148
+ usage?: RunUsage;
149
+ /** Which economics is serving this run, announced before any output. */
150
+ tier?: CostClass;
151
+ }
152
+ export interface Message {
153
+ role: "user" | "assistant";
154
+ content: string;
155
+ }
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "@soba-so/react",
3
+ "version": "0.0.5",
4
+ "description": "Connect the AI your users already pay for, from your own React app.",
5
+ "license": "UNLICENSED",
6
+ "homepage": "https://soba.so/docs/components",
7
+ "type": "module",
8
+ "main": "./dist/index.mjs",
9
+ "types": "./dist/index.d.ts",
10
+ "exports": {
11
+ ".": {
12
+ "types": "./dist/index.d.ts",
13
+ "default": "./dist/index.mjs"
14
+ },
15
+ "./styles.css": "./dist/styles.css"
16
+ },
17
+ "files": [
18
+ "dist",
19
+ "!dist/**/*.map",
20
+ "README.md"
21
+ ],
22
+ "scripts": {
23
+ "build": "node ../../scripts/build-react.mjs",
24
+ "typecheck": "tsc -p tsconfig.json",
25
+ "prepublishOnly": "node ../../scripts/build-react.mjs"
26
+ },
27
+ "peerDependencies": {
28
+ "react": ">=18"
29
+ },
30
+ "devDependencies": {
31
+ "@soba-so/connect-ui": "workspace:*",
32
+ "@types/react": "^19.0.0",
33
+ "typescript": "^7.0.2"
34
+ },
35
+ "engines": {
36
+ "node": ">=22"
37
+ },
38
+ "keywords": [
39
+ "soba",
40
+ "react",
41
+ "byoc",
42
+ "claude",
43
+ "codex"
44
+ ],
45
+ "publishConfig": {
46
+ "access": "public"
47
+ }
48
+ }