index.ts199 lines · main
1/**
2 * @briven/client — framework-agnostic browser client for briven projects.
3 *
4 * const briven = createBrivenClient({
5 * projectId: 'p_...',
6 * apiOrigin: 'https://api.briven.tech',
7 * wsOrigin: 'wss://ws.briven.tech',
8 * });
9 *
10 * const notes = await briven.invoke('listNotes');
11 *
12 * const sub = briven.subscribe('listNotes', { userId: 'u_...' }, (frame) => {
13 * console.log(frame.value);
14 * });
15 * // sub.close() when done
16 */
17
18export interface BrivenClientOptions {
19 /** Briven project id (`p_...`). Required. */
20 readonly projectId: string;
21 /** REST control plane origin, e.g. `https://api.briven.tech`. */
22 readonly apiOrigin: string;
23 /** WebSocket origin for reactive queries, e.g. `wss://ws.briven.tech`. */
24 readonly wsOrigin?: string;
25 /** Session token / api key forwarded as `Authorization: Bearer <token>`. */
26 readonly token?: string | (() => string | Promise<string>);
27 /** Auto-reconnect after a transient disconnect. Default: true. */
28 readonly reconnect?: boolean;
29}
30
31export type InvokeFrame =
32 | { ok: true; value: unknown; durationMs: number; deploymentId?: string }
33 | { ok: false; code: string; message: string; durationMs: number };
34
35export interface SubscribeHandle {
36 readonly subscriptionId: string;
37 /** Unsubscribe and stop receiving frames. Idempotent. */
38 close(): void;
39}
40
41export interface BrivenClient {
42 /** One-shot invoke over HTTP. */
43 invoke(functionName: string, args?: unknown): Promise<InvokeFrame>;
44 /** Subscribe to a function. The handler is called once on initial value
45 * and again every time the touched tables change. */
46 subscribe(
47 functionName: string,
48 args: unknown,
49 handler: (frame: InvokeFrame) => void,
50 ): SubscribeHandle;
51 /** Force-close all subscriptions and the underlying socket. */
52 close(): void;
53}
54
55interface ActiveSubscription {
56 subscriptionId: string;
57 functionName: string;
58 args: unknown;
59 handler: (frame: InvokeFrame) => void;
60}
61
62export function createBrivenClient(options: BrivenClientOptions): BrivenClient {
63 const reconnect = options.reconnect !== false;
64 let ws: WebSocket | null = null;
65 let connecting: Promise<WebSocket> | null = null;
66 let backoffMs = 500;
67 let closed = false;
68 const active = new Map<string, ActiveSubscription>();
69
70 async function resolveToken(): Promise<string | null> {
71 if (!options.token) return null;
72 return typeof options.token === 'function' ? options.token() : options.token;
73 }
74
75 async function ensureSocket(): Promise<WebSocket> {
76 if (closed) throw new Error('client closed');
77 if (ws && ws.readyState === WebSocket.OPEN) return ws;
78 if (connecting) return connecting;
79
80 if (!options.wsOrigin) {
81 throw new Error('wsOrigin not configured — subscribe() requires it');
82 }
83
84 connecting = (async () => {
85 const token = await resolveToken();
86 const url = new URL('/v1/subscribe', options.wsOrigin!).toString();
87 const sock = new WebSocket(url + (token ? `?token=${encodeURIComponent(token)}` : ''));
88 await new Promise<void>((resolve, reject) => {
89 sock.addEventListener('open', () => resolve(), { once: true });
90 sock.addEventListener('error', () => reject(new Error('ws_open_failed')), { once: true });
91 });
92 sock.addEventListener('message', (e) => onMessage(e.data));
93 sock.addEventListener('close', () => onClose());
94 ws = sock;
95 backoffMs = 500;
96 // Re-send all active subscriptions on (re)connect.
97 for (const sub of active.values()) sendSubscribe(sock, sub);
98 return sock;
99 })();
100
101 try {
102 return await connecting;
103 } finally {
104 connecting = null;
105 }
106 }
107
108 function onClose() {
109 ws = null;
110 if (closed || !reconnect || active.size === 0) return;
111 const delay = Math.min(backoffMs, 30_000);
112 backoffMs = Math.min(backoffMs * 2, 30_000);
113 setTimeout(() => {
114 void ensureSocket().catch(() => undefined);
115 }, delay);
116 }
117
118 function onMessage(raw: string | ArrayBuffer | Blob): void {
119 if (typeof raw !== 'string') return;
120 let frame: { type: string; subscriptionId?: string } & Record<string, unknown>;
121 try {
122 frame = JSON.parse(raw);
123 } catch {
124 return;
125 }
126 if (frame.type !== 'data' || !frame.subscriptionId) return;
127 const sub = active.get(frame.subscriptionId);
128 if (!sub) return;
129 sub.handler(frame as unknown as InvokeFrame);
130 }
131
132 function sendSubscribe(sock: WebSocket, sub: ActiveSubscription): void {
133 sock.send(
134 JSON.stringify({
135 type: 'subscribe',
136 subscriptionId: sub.subscriptionId,
137 projectId: options.projectId,
138 functionName: sub.functionName,
139 args: sub.args,
140 }),
141 );
142 }
143
144 async function invoke(functionName: string, args: unknown = {}): Promise<InvokeFrame> {
145 const token = await resolveToken();
146 const headers: Record<string, string> = { 'content-type': 'application/json' };
147 if (token) headers['authorization'] = `Bearer ${token}`;
148 const url = `${options.apiOrigin}/v1/projects/${options.projectId}/functions/${functionName}`;
149 const res = await fetch(url, {
150 method: 'POST',
151 headers,
152 body: JSON.stringify(args ?? {}),
153 credentials: token ? 'omit' : 'include',
154 });
155 return (await res.json()) as InvokeFrame;
156 }
157
158 function subscribe(
159 functionName: string,
160 args: unknown,
161 handler: (frame: InvokeFrame) => void,
162 ): SubscribeHandle {
163 const subscriptionId = crypto.randomUUID();
164 const sub: ActiveSubscription = { subscriptionId, functionName, args, handler };
165 active.set(subscriptionId, sub);
166
167 void ensureSocket()
168 .then((sock) => sendSubscribe(sock, sub))
169 .catch((err) => {
170 handler({
171 ok: false,
172 code: 'connect_failed',
173 message: err instanceof Error ? err.message : 'unknown',
174 durationMs: 0,
175 });
176 });
177
178 return {
179 subscriptionId,
180 close: () => {
181 active.delete(subscriptionId);
182 if (ws && ws.readyState === WebSocket.OPEN) {
183 ws.send(JSON.stringify({ type: 'unsubscribe', subscriptionId }));
184 }
185 },
186 };
187 }
188
189 function close(): void {
190 closed = true;
191 active.clear();
192 if (ws) {
193 ws.close();
194 ws = null;
195 }
196 }
197
198 return { invoke, subscribe, close };
199}