@frockbot/applet-sdk 0.0.0 → 0.3.13

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.
@@ -0,0 +1,334 @@
1
+ /**
2
+ * The one socket an open Applet holds, and everything that hangs off it:
3
+ * the v1 handshake, snapshot and catch-up, mutate/ack/reject, reconnection.
4
+ *
5
+ * TanStack DB never sees a frame. It sees a sink per table (`begin`/`write`/
6
+ * `commit`/`markReady`/`truncate`) and a promise per client transaction, which
7
+ * is the entire seam between this module and `collections.ts`.
8
+ */
9
+
10
+ import {
11
+ APPLET_CONTRACT_VERSION,
12
+ decodeServerFrame,
13
+ encodeFrame,
14
+ type AppletChangeV1,
15
+ type AppletMutationV1,
16
+ type AppletViewerV1,
17
+ } from "../protocol/index.js";
18
+
19
+ export interface AppletInitV1 {
20
+ /** Absolute ws(s):// URL of the Applet's socket, minted by the kernel. */
21
+ socketUrl: string;
22
+ /** Short-lived viewer token; appended as `?token=`. */
23
+ token: string;
24
+ generationId: string;
25
+ }
26
+
27
+ export type AppletStatus =
28
+ "idle" | "connecting" | "ready" | "reconnecting" | "closed";
29
+
30
+ export interface AppletState {
31
+ status: AppletStatus;
32
+ viewer: AppletViewerV1 | null;
33
+ generationId: string | null;
34
+ }
35
+
36
+ /** Minimal socket shape, so tests and the dev runner can supply their own. */
37
+ export interface AppletSocket {
38
+ send(data: string): void;
39
+ close(code?: number, reason?: string): void;
40
+ onopen: ((event: unknown) => void) | null;
41
+ onmessage: ((event: { data: unknown }) => void) | null;
42
+ onclose: ((event: unknown) => void) | null;
43
+ onerror: ((event: unknown) => void) | null;
44
+ }
45
+
46
+ export type AppletSocketFactory = (url: string) => AppletSocket;
47
+
48
+ /** What a TanStack DB collection hands the transport for one table. */
49
+ export interface AppletTableSink {
50
+ begin(): void;
51
+ write(message: {
52
+ type: "insert" | "update" | "delete";
53
+ key?: string;
54
+ value?: Record<string, unknown>;
55
+ }): void;
56
+ commit(): void;
57
+ markReady(): void;
58
+ truncate(): void;
59
+ }
60
+
61
+ export interface AppletTransportOptions {
62
+ socketFactory?: AppletSocketFactory;
63
+ /** Reconnection backoff bounds, in milliseconds. */
64
+ minimumBackoffMs?: number;
65
+ maximumBackoffMs?: number;
66
+ /** Scheduler seam so tests do not wait in real time. */
67
+ schedule?: (closure: () => void, delayMs: number) => unknown;
68
+ }
69
+
70
+ class RejectedMutation extends Error {}
71
+
72
+ function defaultSocketFactory(url: string): AppletSocket {
73
+ return new WebSocket(url) as unknown as AppletSocket;
74
+ }
75
+
76
+ export class AppletTransport {
77
+ #options: Required<Omit<AppletTransportOptions, "socketFactory">> & {
78
+ socketFactory: AppletSocketFactory;
79
+ };
80
+ #socket?: AppletSocket;
81
+ #init?: AppletInitV1;
82
+ #state: AppletState = { status: "idle", viewer: null, generationId: null };
83
+ #listeners = new Set<() => void>();
84
+ #sinks = new Map<string, AppletTableSink>();
85
+ #pending = new Map<
86
+ string,
87
+ {
88
+ resolve: (changes: AppletChangeV1[]) => void;
89
+ reject: (error: Error) => void;
90
+ }
91
+ >();
92
+ #lastChangeId = 0;
93
+ #synced = false;
94
+ #buffer: AppletChangeV1[] = [];
95
+ #attempt = 0;
96
+ #closed = false;
97
+ #resyncQueued = false;
98
+ #txnSeq = 0;
99
+
100
+ constructor(options: AppletTransportOptions = {}) {
101
+ this.#options = {
102
+ socketFactory: options.socketFactory ?? defaultSocketFactory,
103
+ minimumBackoffMs: options.minimumBackoffMs ?? 250,
104
+ maximumBackoffMs: options.maximumBackoffMs ?? 8_000,
105
+ schedule:
106
+ options.schedule ?? ((closure, delay) => setTimeout(closure, delay)),
107
+ };
108
+ }
109
+
110
+ get state(): AppletState {
111
+ return this.#state;
112
+ }
113
+
114
+ subscribe(listener: () => void): () => void {
115
+ this.#listeners.add(listener);
116
+ return () => this.#listeners.delete(listener);
117
+ }
118
+
119
+ /** Open the socket. Called by the dev runner, the tests, and the `init` bridge. */
120
+ connect(init: AppletInitV1): void {
121
+ this.#init = init;
122
+ this.#closed = false;
123
+ this.#open();
124
+ }
125
+
126
+ close(): void {
127
+ this.#closed = true;
128
+ this.#socket?.close(1000, "closed");
129
+ this.#socket = undefined;
130
+ this.#failPending(new Error("The Applet connection was closed"));
131
+ this.#setState({ status: "closed" });
132
+ }
133
+
134
+ #open(): void {
135
+ if (!this.#init || this.#closed) return;
136
+ this.#setState({
137
+ status: this.#attempt === 0 ? "connecting" : "reconnecting",
138
+ });
139
+ const url = new URL(this.#init.socketUrl);
140
+ url.searchParams.set("token", this.#init.token);
141
+ const socket = this.#options.socketFactory(url.toString());
142
+ this.#socket = socket;
143
+ socket.onopen = () => this.#handshake();
144
+ socket.onmessage = (event) => this.#receive(event.data);
145
+ socket.onclose = () => this.#dropped();
146
+ socket.onerror = () => this.#dropped();
147
+ }
148
+
149
+ #handshake(): void {
150
+ const since = this.#lastChangeId === 0 ? undefined : this.#lastChangeId;
151
+ this.#write({
152
+ v: 1,
153
+ type: "hello",
154
+ contract: APPLET_CONTRACT_VERSION,
155
+ ...(since === undefined ? {} : { since }),
156
+ });
157
+ }
158
+
159
+ #dropped(): void {
160
+ if (this.#closed) return;
161
+ this.#socket = undefined;
162
+ this.#synced = false;
163
+ this.#failPending(new Error("The Applet connection dropped"));
164
+ const delay = Math.min(
165
+ this.#options.maximumBackoffMs,
166
+ this.#options.minimumBackoffMs * 2 ** this.#attempt,
167
+ );
168
+ this.#attempt += 1;
169
+ this.#setState({ status: "reconnecting" });
170
+ this.#options.schedule(
171
+ () => this.#open(),
172
+ delay * (0.5 + Math.random() / 2),
173
+ );
174
+ }
175
+
176
+ #receive(data: unknown): void {
177
+ let frame;
178
+ try {
179
+ frame = decodeServerFrame(data);
180
+ } catch {
181
+ // A frame this client cannot understand is a protocol break, not a blip:
182
+ // drop the socket so the reconnect path takes a clean snapshot.
183
+ this.#socket?.close(1008, "Unreadable frame");
184
+ return;
185
+ }
186
+
187
+ if (frame.type === "hello") {
188
+ const changedGeneration =
189
+ this.#state.generationId !== null &&
190
+ this.#state.generationId !== frame.generationId;
191
+ this.#setState({
192
+ viewer: frame.viewer,
193
+ generationId: frame.generationId,
194
+ });
195
+ if (changedGeneration) {
196
+ // New code over the same storage: never replay across the boundary.
197
+ this.#lastChangeId = 0;
198
+ this.#synced = false;
199
+ this.#buffer = [];
200
+ this.#write({ v: 1, type: "hello", contract: APPLET_CONTRACT_VERSION });
201
+ }
202
+ return;
203
+ }
204
+
205
+ if (frame.type === "snapshot") {
206
+ this.#lastChangeId = frame.lastChangeId;
207
+ for (const [name, sink] of this.#sinks) {
208
+ // `truncate` only has meaning inside an open sync transaction.
209
+ sink.begin();
210
+ sink.truncate();
211
+ for (const row of frame.tables[name] ?? [])
212
+ sink.write({ type: "insert", value: row });
213
+ sink.commit();
214
+ sink.markReady();
215
+ }
216
+ this.#synced = true;
217
+ this.#attempt = 0;
218
+ this.#setState({ status: "ready" });
219
+ const buffered = this.#buffer;
220
+ this.#buffer = [];
221
+ if (buffered.length > 0) this.#apply(buffered);
222
+ return;
223
+ }
224
+
225
+ if (frame.type === "changes") {
226
+ this.#lastChangeId = frame.lastChangeId;
227
+ if (!this.#synced) {
228
+ // Catch-up after a reconnect arrives without a snapshot; the sinks are
229
+ // already populated from the previous session, so apply it directly.
230
+ this.#synced = true;
231
+ this.#attempt = 0;
232
+ this.#setState({ status: "ready" });
233
+ for (const sink of this.#sinks.values()) sink.markReady();
234
+ }
235
+ this.#apply(frame.changes);
236
+ return;
237
+ }
238
+
239
+ if (frame.type === "ack") {
240
+ this.#lastChangeId = frame.lastChangeId;
241
+ // The authoritative rows must reach the sync layer before the optimistic
242
+ // transaction is discarded, or the row would vanish on resolve.
243
+ this.#apply(frame.changes);
244
+ this.#pending.get(frame.txnId)?.resolve(frame.changes);
245
+ this.#pending.delete(frame.txnId);
246
+ return;
247
+ }
248
+
249
+ this.#pending.get(frame.txnId)?.reject(new RejectedMutation(frame.reason));
250
+ this.#pending.delete(frame.txnId);
251
+ }
252
+
253
+ #apply(changes: AppletChangeV1[]): void {
254
+ if (!this.#synced) {
255
+ this.#buffer.push(...changes);
256
+ return;
257
+ }
258
+ const byTable = new Map<string, AppletChangeV1[]>();
259
+ for (const change of changes) {
260
+ const list = byTable.get(change.table);
261
+ if (list) list.push(change);
262
+ else byTable.set(change.table, [change]);
263
+ }
264
+ for (const [name, list] of byTable) {
265
+ const sink = this.#sinks.get(name);
266
+ if (!sink) continue;
267
+ sink.begin();
268
+ for (const change of list) {
269
+ if (change.op === "delete")
270
+ sink.write({ type: "delete", key: change.key });
271
+ else sink.write({ type: change.op, value: change.row });
272
+ }
273
+ sink.commit();
274
+ }
275
+ }
276
+
277
+ /** Register a table's sink; returns the cleanup TanStack DB expects. */
278
+ registerTable(name: string, sink: AppletTableSink): () => void {
279
+ this.#sinks.set(name, sink);
280
+ if (this.#synced) this.#queueResync();
281
+ return () => {
282
+ if (this.#sinks.get(name) === sink) this.#sinks.delete(name);
283
+ };
284
+ }
285
+
286
+ /**
287
+ * A table that mounts after the first snapshot has no rows of its own, so ask
288
+ * for a fresh snapshot. Coalesced, because a first render registers them all
289
+ * in the same tick.
290
+ */
291
+ #queueResync(): void {
292
+ if (this.#resyncQueued) return;
293
+ this.#resyncQueued = true;
294
+ queueMicrotask(() => {
295
+ this.#resyncQueued = false;
296
+ if (!this.#socket) return;
297
+ this.#synced = false;
298
+ this.#write({ v: 1, type: "hello", contract: APPLET_CONTRACT_VERSION });
299
+ });
300
+ }
301
+
302
+ /** Send one client transaction; resolves on `ack`, rejects on `reject`. */
303
+ mutate(mutations: AppletMutationV1[]): Promise<AppletChangeV1[]> {
304
+ if (!this.#socket) {
305
+ return Promise.reject(new Error("The Applet is not connected"));
306
+ }
307
+ const txnId = `c${++this.#txnSeq}`;
308
+ return new Promise<AppletChangeV1[]>((resolve, reject) => {
309
+ this.#pending.set(txnId, { resolve, reject });
310
+ try {
311
+ this.#write({ v: 1, type: "mutate", txnId, mutations });
312
+ } catch (error) {
313
+ this.#pending.delete(txnId);
314
+ reject(error instanceof Error ? error : new Error(String(error)));
315
+ }
316
+ });
317
+ }
318
+
319
+ #write(frame: Parameters<typeof encodeFrame>[0]): void {
320
+ const socket = this.#socket;
321
+ if (!socket) throw new Error("The Applet is not connected");
322
+ socket.send(encodeFrame(frame));
323
+ }
324
+
325
+ #failPending(error: Error): void {
326
+ for (const entry of this.#pending.values()) entry.reject(error);
327
+ this.#pending.clear();
328
+ }
329
+
330
+ #setState(patch: Partial<AppletState>): void {
331
+ this.#state = { ...this.#state, ...patch };
332
+ for (const listener of this.#listeners) listener();
333
+ }
334
+ }
@@ -0,0 +1,130 @@
1
+ # The Applet component kit
2
+
3
+ `import { … } from "@frockbot/applet-sdk/kit"`
4
+
5
+ Fourteen components. They are the whole visual vocabulary of an Applet: there
6
+ is no CSS file to write and no colour to choose. Every surface, edge, and
7
+ accent resolves through the nine semantic tokens the host injects, so an Applet
8
+ follows the User's theme — light, dark, or anything FrockBot ships later —
9
+ without knowing which one is on.
10
+
11
+ **Do not** write `#hex`, `rgb(...)`, `hsl(...)`, or a colour name anywhere. The
12
+ linter rejects them in `.ts`, `.tsx`, and `.css` alike. If a component cannot
13
+ express what you want, the kit is missing something — say so rather than
14
+ styling around it.
15
+
16
+ ## Layout
17
+
18
+ ### `Stack`
19
+
20
+ The only layout primitive. Rows and columns, nothing else.
21
+
22
+ | Prop | Type | Default |
23
+ | ----------- | --------------------------------------------- | ---------- |
24
+ | `direction` | `"row" \| "column"` | `"column"` |
25
+ | `gap` | `"none" \| "small" \| "medium" \| "large"` | `"medium"` |
26
+ | `align` | `"start" \| "center" \| "end" \| "stretch"` | — |
27
+ | `justify` | `"start" \| "center" \| "end" \| "between"` | — |
28
+ | `wrap` | `boolean` | `false` |
29
+ | `root` | `boolean` — put exactly one at the page's top | `false` |
30
+
31
+ ```tsx
32
+ <Stack root gap="large">
33
+ <Stack direction="row" gap="small" align="end">
34
+
35
+ </Stack>
36
+ </Stack>
37
+ ```
38
+
39
+ ### `Text`
40
+
41
+ | Prop | Type | Default |
42
+ | ------ | ------------------------------------------------ | ----------- |
43
+ | `size` | `"title" \| "heading" \| "body" \| "small"` | `"body"` |
44
+ | `tone` | `"default" \| "muted"` | `"default"` |
45
+ | `as` | `"p" \| "span" \| "div" \| "h1" \| "h2" \| "h3"` | `"p"` |
46
+
47
+ ## Controls
48
+
49
+ ### `Button`
50
+
51
+ | Prop | Type | Default |
52
+ | ---------- | ----------------------------------- | ----------- |
53
+ | `variant` | `"default" \| "primary" \| "ghost"` | `"default"` |
54
+ | `onClick` | `() => void` | — |
55
+ | `disabled` | `boolean` | `false` |
56
+
57
+ Also accepts the ordinary `<button>` attributes except `className` and `style`.
58
+ `type` defaults to `"button"`, so it never submits a form by accident.
59
+
60
+ ### `Input`, `Textarea`
61
+
62
+ | Prop | Type | Notes |
63
+ | --------------- | ------------------------- | ------------------------------------ |
64
+ | `label` | `string` | rendered above the control |
65
+ | `error` | `string` | rendered below, in the accent colour |
66
+ | `value` | `string` | controlled |
67
+ | `onValueChange` | `(value: string) => void` | receives the value, not the event |
68
+ | `placeholder` | `string` | |
69
+
70
+ `onKeyDown`, `disabled`, and the rest of the native attributes pass through.
71
+
72
+ ### `Select`
73
+
74
+ Adds `options: Array<{ value: string; label: string }>`; otherwise identical to
75
+ `Input`.
76
+
77
+ ### `Checkbox`
78
+
79
+ | Prop | Type | Notes |
80
+ | ----------- | ---------------------------- | --------------------------------- |
81
+ | `checked` | `boolean` | required |
82
+ | `onChange` | `(checked: boolean) => void` | required |
83
+ | `label` | `ReactNode` | optional visible label |
84
+ | `ariaLabel` | `string` | required when there is no `label` |
85
+ | `disabled` | `boolean` | |
86
+
87
+ ## Surfaces
88
+
89
+ ### `Card`
90
+
91
+ `title?: ReactNode`, plus children. A bordered panel.
92
+
93
+ ### `Toolbar`
94
+
95
+ `children` sit at the leading edge; `end?: ReactNode` is pushed to the trailing
96
+ edge. Use it for the Applet's title and its status or primary action.
97
+
98
+ ### `List` and `ListItem`
99
+
100
+ `List` takes `bordered?: boolean` (default `true`) and `ListItem` children.
101
+
102
+ `ListItem`: `start?: ReactNode` (a checkbox, a badge), `end?: ReactNode`
103
+ (actions), `onClick?: () => void` (makes the row interactive), and children as
104
+ the body.
105
+
106
+ ### `Badge`
107
+
108
+ `tone?: "default" | "accent"`, plus children.
109
+
110
+ ### `EmptyState`
111
+
112
+ `title: string`, `description?: string`, `action?: ReactNode`. Show it whenever
113
+ a live query comes back empty — an Applet should never render a blank page.
114
+
115
+ ### `Dialog`
116
+
117
+ | Prop | Type | Notes |
118
+ | --------- | ------------ | ----------------------------------------- |
119
+ | `open` | `boolean` | renders nothing when false |
120
+ | `onClose` | `() => void` | fires on Escape and on a backdrop click |
121
+ | `title` | `ReactNode` | also becomes the dialog's accessible name |
122
+ | `actions` | `ReactNode` | a trailing row, usually two `Button`s |
123
+
124
+ ## The nine tokens
125
+
126
+ The host re-emits these into the page on `init`; the kit reads them and so may
127
+ you, always through `var(--frockbot-<name>)`:
128
+
129
+ `surface`, `surface-raised`, `surface-subtle`, `text`, `text-muted`, `border`,
130
+ `accent-surface`, `accent-text`, `radius-card`.