@frockbot/applet-sdk 0.7.150 → 0.7.151

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.
@@ -1,504 +0,0 @@
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
- APPLET_PROTOCOL_VERSION,
13
- decodeServerFrame,
14
- encodeFrame,
15
- type AppletChangeV1,
16
- type AppletMutationV1,
17
- type AppletSnapshotTablesV1,
18
- type AppletViewerV1,
19
- } from "../protocol/index.js";
20
-
21
- export interface AppletInitV1 {
22
- /** Absolute ws(s):// URL of the Applet's socket, minted by the kernel. */
23
- socketUrl: string;
24
- /** Short-lived viewer token; appended as `?token=`. */
25
- token: string;
26
- generationId: string;
27
- tokenTransport?: "subprotocol-v1";
28
- /**
29
- * Report each hop of the open path — socket open, hello, ready — to the
30
- * host, for the timing log it keeps behind its own flag. Off by default.
31
- */
32
- timing?: boolean;
33
- }
34
-
35
- /** The hops a page reports when the host asked for timing. */
36
- export type AppletTimingHop = "socket-open" | "hello" | "ready";
37
-
38
- export type AppletStatus =
39
- "idle" | "connecting" | "ready" | "reconnecting" | "closed";
40
-
41
- export interface AppletState {
42
- status: AppletStatus;
43
- viewer: AppletViewerV1 | null;
44
- generationId: string | null;
45
- }
46
-
47
- /** Minimal socket shape, so tests and the dev runner can supply their own. */
48
- export interface AppletSocket {
49
- send(data: string): void;
50
- close(code?: number, reason?: string): void;
51
- onopen: ((event: unknown) => void) | null;
52
- onmessage: ((event: { data: unknown }) => void) | null;
53
- onclose: ((event: unknown) => void) | null;
54
- onerror: ((event: unknown) => void) | null;
55
- }
56
-
57
- export type AppletSocketFactory = (
58
- url: string,
59
- protocols?: string[],
60
- ) => AppletSocket;
61
-
62
- /** What a TanStack DB collection hands the transport for one table. */
63
- export interface AppletTableSink {
64
- begin(): void;
65
- write(message: {
66
- type: "insert" | "update" | "delete";
67
- key?: string;
68
- value?: Record<string, unknown>;
69
- }): void;
70
- commit(): void;
71
- markReady(): void;
72
- truncate(): void;
73
- }
74
-
75
- export interface AppletTransportOptions {
76
- socketFactory?: AppletSocketFactory;
77
- /** Reconnection backoff bounds, in milliseconds. */
78
- minimumBackoffMs?: number;
79
- maximumBackoffMs?: number;
80
- /** Scheduler seam so tests do not wait in real time. */
81
- schedule?: (closure: () => void, delayMs: number) => unknown;
82
- /** Where a timing hop goes when `init.timing` is set; the page posts to its host. */
83
- onTiming?: (hop: AppletTimingHop) => void;
84
- }
85
-
86
- class RejectedMutation extends Error {}
87
-
88
- function defaultSocketFactory(url: string, protocols?: string[]): AppletSocket {
89
- return new WebSocket(url, protocols) as unknown as AppletSocket;
90
- }
91
-
92
- export class AppletTransport {
93
- #options: Required<
94
- Omit<AppletTransportOptions, "socketFactory" | "onTiming">
95
- > & {
96
- socketFactory: AppletSocketFactory;
97
- onTiming: ((hop: AppletTimingHop) => void) | undefined;
98
- };
99
- #socket?: AppletSocket;
100
- #init?: AppletInitV1;
101
- #state: AppletState = { status: "idle", viewer: null, generationId: null };
102
- #listeners = new Set<() => void>();
103
- #sinks = new Map<string, AppletTableSink>();
104
- #pending = new Map<
105
- string,
106
- {
107
- resolve: (changes: AppletChangeV1[]) => void;
108
- reject: (error: Error) => void;
109
- }
110
- >();
111
- #lastChangeId = 0;
112
- #synced = false;
113
- #buffer: AppletChangeV1[] = [];
114
- #attempt = 0;
115
- #closed = false;
116
- #resyncQueued = false;
117
- #txnSeq = 0;
118
- /**
119
- * Every socket this transport has opened gets a number, and only the newest
120
- * one owns the shared connection state. `error` and `close` both fire for a
121
- * single failure, and a socket the transport has already given up on can
122
- * still call back later, so a callback carrying a superseded identity is
123
- * dropped instead of reconnecting or clearing state a live socket owns.
124
- */
125
- #socketSeq = 0;
126
- #currentSocketId = 0;
127
- /**
128
- * At most one reconnect is outstanding. The scheduler seam cannot cancel a
129
- * timer, so a retry carries an identity too: `#pendingRetryId` is cleared or
130
- * replaced whenever the retry is superseded, and a closure that fires with a
131
- * stale identity does nothing.
132
- */
133
- #retrySeq = 0;
134
- #pendingRetryId = 0;
135
- /**
136
- * Whether this socket has sent its own `hello`. A v2 socket that opened with
137
- * no cursor sends none: the server's `hello` carries the snapshot, and only
138
- * a hello that arrives without one — a server that could not fit it, or one
139
- * that speaks v1 — is answered.
140
- */
141
- #helloSent = false;
142
-
143
- constructor(options: AppletTransportOptions = {}) {
144
- this.#options = {
145
- socketFactory: options.socketFactory ?? defaultSocketFactory,
146
- minimumBackoffMs: options.minimumBackoffMs ?? 250,
147
- maximumBackoffMs: options.maximumBackoffMs ?? 8_000,
148
- schedule:
149
- options.schedule ?? ((closure, delay) => setTimeout(closure, delay)),
150
- onTiming: options.onTiming,
151
- };
152
- }
153
-
154
- get state(): AppletState {
155
- return this.#state;
156
- }
157
-
158
- subscribe(listener: () => void): () => void {
159
- this.#listeners.add(listener);
160
- return () => this.#listeners.delete(listener);
161
- }
162
-
163
- /**
164
- * Open the socket. Called by the dev runner, the tests, and the `init`
165
- * bridge. Calling it again — a fresh viewer token after the old one expired,
166
- * say — supersedes whatever socket or pending reconnect is outstanding, so a
167
- * reconnect never races the connection the caller just asked for.
168
- */
169
- connect(init: AppletInitV1): void {
170
- this.#init = init;
171
- this.#closed = false;
172
- this.#reset();
173
- this.#attempt = 0;
174
- this.#open();
175
- }
176
-
177
- /**
178
- * A fresh viewer credential for the page that is already running: the
179
- * host's `refresh` message. The document stays; the transport reconnects
180
- * in place with the new token, and since it carries its cursor the server
181
- * answers with `changes` rather than a snapshot. The same credential
182
- * offered twice — a host re-sending what the page already holds — is not
183
- * a reason to drop a live socket.
184
- */
185
- refresh(init: AppletInitV1): void {
186
- const held = this.#init;
187
- if (
188
- held &&
189
- this.#socket &&
190
- !this.#closed &&
191
- held.token === init.token &&
192
- held.socketUrl === init.socketUrl &&
193
- held.generationId === init.generationId
194
- ) {
195
- this.#init = init;
196
- return;
197
- }
198
- this.connect(init);
199
- }
200
-
201
- close(): void {
202
- this.#closed = true;
203
- this.#reset(1000, "closed");
204
- this.#failPending(new Error("The Applet connection was closed"));
205
- this.#setState({ status: "closed" });
206
- }
207
-
208
- /**
209
- * Give up the current socket and any pending reconnect. The socket is closed
210
- * rather than merely forgotten, so a superseded connection cannot keep
211
- * receiving frames, and its late `close` arrives with a stale identity.
212
- */
213
- #reset(code = 1000, reason = "superseded"): void {
214
- this.#pendingRetryId = 0;
215
- const socket = this.#socket;
216
- this.#socket = undefined;
217
- this.#currentSocketId = 0;
218
- this.#synced = false;
219
- if (socket) {
220
- try {
221
- socket.close(code, reason);
222
- } catch {
223
- // A socket that is already gone needs no closing.
224
- }
225
- }
226
- }
227
-
228
- #open(): void {
229
- if (!this.#init || this.#closed) return;
230
- // Never open while another attempt is current.
231
- if (this.#socket) return;
232
- this.#pendingRetryId = 0;
233
- const id = ++this.#socketSeq;
234
- this.#currentSocketId = id;
235
- this.#setState({
236
- status: this.#attempt === 0 ? "connecting" : "reconnecting",
237
- });
238
- const url = new URL(this.#init.socketUrl);
239
- // The version this page speaks and, on a reconnect, the cursor it will
240
- // resume from, both settled before the first frame so the server's hello
241
- // can carry the snapshot exactly when one is needed.
242
- url.searchParams.set("v", String(APPLET_PROTOCOL_VERSION));
243
- const since = this.#lastChangeId === 0 ? undefined : this.#lastChangeId;
244
- if (since !== undefined) url.searchParams.set("since", String(since));
245
- const protocols =
246
- this.#init.tokenTransport === "subprotocol-v1"
247
- ? ["frockbot.applet.v1", `frockbot.viewer.${this.#init.token}`]
248
- : undefined;
249
- if (!protocols) url.searchParams.set("token", this.#init.token);
250
- const socket = this.#options.socketFactory(url.toString(), protocols);
251
- this.#socket = socket;
252
- this.#helloSent = false;
253
- socket.onopen = () => {
254
- if (this.#currentSocketId !== id) return;
255
- this.#timing("socket-open");
256
- // A resume asks for its catch-up at once, crossing the server's hello
257
- // on the wire as it always has. A first connection waits: the hello on
258
- // its way carries the snapshot.
259
- if (since !== undefined) this.#handshake();
260
- };
261
- socket.onmessage = (event) => {
262
- if (this.#currentSocketId === id) this.#receive(event.data);
263
- };
264
- socket.onclose = () => this.#dropped(id);
265
- socket.onerror = () => this.#dropped(id);
266
- }
267
-
268
- #handshake(): void {
269
- const since = this.#lastChangeId === 0 ? undefined : this.#lastChangeId;
270
- this.#helloSent = true;
271
- this.#write({
272
- v: APPLET_PROTOCOL_VERSION,
273
- type: "hello",
274
- contract: APPLET_CONTRACT_VERSION,
275
- ...(since === undefined ? {} : { since }),
276
- });
277
- }
278
-
279
- #timing(hop: AppletTimingHop): void {
280
- if (this.#init?.timing) this.#options.onTiming?.(hop);
281
- }
282
-
283
- /**
284
- * One failure reaches here twice — `error` then `close` — and a socket the
285
- * transport already replaced can reach here at any time. Only the socket that
286
- * still owns the connection schedules a replacement.
287
- */
288
- #dropped(id: number): void {
289
- if (id !== this.#currentSocketId) return;
290
- if (this.#closed) return;
291
- this.#reset(1000, "dropped");
292
- this.#failPending(new Error("The Applet connection dropped"));
293
- const retryId = ++this.#retrySeq;
294
- this.#pendingRetryId = retryId;
295
- const delay = Math.min(
296
- this.#options.maximumBackoffMs,
297
- this.#options.minimumBackoffMs * 2 ** this.#attempt,
298
- );
299
- this.#attempt += 1;
300
- this.#setState({ status: "reconnecting" });
301
- this.#options.schedule(
302
- () => {
303
- if (this.#pendingRetryId !== retryId) return;
304
- this.#pendingRetryId = 0;
305
- this.#open();
306
- },
307
- delay * (0.5 + Math.random() / 2),
308
- );
309
- }
310
-
311
- #receive(data: unknown): void {
312
- let frame;
313
- try {
314
- frame = decodeServerFrame(data);
315
- } catch {
316
- // A frame this client cannot understand is a protocol break, not a blip:
317
- // drop the socket so the reconnect path takes a clean snapshot.
318
- this.#socket?.close(1008, "Unreadable frame");
319
- return;
320
- }
321
-
322
- if (frame.type === "hello") {
323
- this.#timing("hello");
324
- const changedGeneration =
325
- this.#state.generationId !== null &&
326
- this.#state.generationId !== frame.generationId;
327
- this.#setState({
328
- viewer: frame.viewer,
329
- generationId: frame.generationId,
330
- });
331
- if (changedGeneration) {
332
- // New code over the same storage: never replay across the boundary.
333
- this.#lastChangeId = 0;
334
- this.#synced = false;
335
- this.#buffer = [];
336
- }
337
- if (frame.snapshot) {
338
- // The whole state came with the greeting: render on it, and send
339
- // nothing back. A hello for a new generation carries that
340
- // generation's rows, so the reset above is what it needs.
341
- this.#snapshot(frame.lastChangeId, frame.snapshot);
342
- return;
343
- }
344
- if (changedGeneration || !this.#helloSent) {
345
- // Nothing came with the greeting: a server that could not fit the
346
- // snapshot, one that speaks v1, or new code over the same storage.
347
- // Ask, as v1 always did.
348
- this.#helloSent = true;
349
- this.#write({
350
- v: APPLET_PROTOCOL_VERSION,
351
- type: "hello",
352
- contract: APPLET_CONTRACT_VERSION,
353
- });
354
- }
355
- return;
356
- }
357
-
358
- if (frame.type === "snapshot") {
359
- this.#snapshot(frame.lastChangeId, frame.tables);
360
- return;
361
- }
362
-
363
- if (frame.type === "changes") {
364
- this.#lastChangeId = frame.lastChangeId;
365
- if (!this.#synced) {
366
- // Catch-up after a reconnect arrives without a snapshot; the sinks are
367
- // already populated from the previous session, so apply it directly.
368
- this.#synced = true;
369
- this.#attempt = 0;
370
- this.#setState({ status: "ready" });
371
- for (const sink of this.#sinks.values()) sink.markReady();
372
- this.#timing("ready");
373
- }
374
- this.#apply(frame.changes);
375
- return;
376
- }
377
-
378
- if (frame.type === "ack") {
379
- this.#lastChangeId = frame.lastChangeId;
380
- // The authoritative rows must reach the sync layer before the optimistic
381
- // transaction is discarded, or the row would vanish on resolve.
382
- this.#apply(frame.changes);
383
- this.#pending.get(frame.txnId)?.resolve(frame.changes);
384
- this.#pending.delete(frame.txnId);
385
- return;
386
- }
387
-
388
- this.#pending.get(frame.txnId)?.reject(new RejectedMutation(frame.reason));
389
- this.#pending.delete(frame.txnId);
390
- }
391
-
392
- /** The whole state, from a `snapshot` frame or a v2 hello: replace and mark ready. */
393
- #snapshot(lastChangeId: number, tables: AppletSnapshotTablesV1): void {
394
- this.#lastChangeId = lastChangeId;
395
- for (const [name, sink] of this.#sinks) {
396
- // `truncate` only has meaning inside an open sync transaction.
397
- sink.begin();
398
- sink.truncate();
399
- for (const row of tables[name] ?? [])
400
- sink.write({ type: "insert", value: row });
401
- sink.commit();
402
- sink.markReady();
403
- }
404
- this.#synced = true;
405
- this.#attempt = 0;
406
- this.#setState({ status: "ready" });
407
- this.#timing("ready");
408
- const buffered = this.#buffer;
409
- this.#buffer = [];
410
- if (buffered.length > 0) this.#apply(buffered);
411
- }
412
-
413
- #apply(changes: AppletChangeV1[]): void {
414
- if (!this.#synced) {
415
- this.#buffer.push(...changes);
416
- return;
417
- }
418
- const byTable = new Map<string, AppletChangeV1[]>();
419
- for (const change of changes) {
420
- const list = byTable.get(change.table);
421
- if (list) list.push(change);
422
- else byTable.set(change.table, [change]);
423
- }
424
- for (const [name, list] of byTable) {
425
- const sink = this.#sinks.get(name);
426
- if (!sink) continue;
427
- sink.begin();
428
- for (const change of list) {
429
- if (change.op === "delete")
430
- sink.write({ type: "delete", key: change.key });
431
- else sink.write({ type: change.op, value: change.row });
432
- }
433
- sink.commit();
434
- }
435
- }
436
-
437
- /** Register a table's sink; returns the cleanup TanStack DB expects. */
438
- registerTable(name: string, sink: AppletTableSink): () => void {
439
- this.#sinks.set(name, sink);
440
- if (this.#synced) this.#queueResync();
441
- return () => {
442
- if (this.#sinks.get(name) === sink) this.#sinks.delete(name);
443
- };
444
- }
445
-
446
- /**
447
- * A table that mounts after the first snapshot has no rows of its own, so ask
448
- * for a fresh snapshot. Coalesced, because a first render registers them all
449
- * in the same tick.
450
- */
451
- #queueResync(): void {
452
- if (this.#resyncQueued) return;
453
- this.#resyncQueued = true;
454
- queueMicrotask(() => {
455
- this.#resyncQueued = false;
456
- if (!this.#socket) return;
457
- this.#synced = false;
458
- this.#helloSent = true;
459
- this.#write({
460
- v: APPLET_PROTOCOL_VERSION,
461
- type: "hello",
462
- contract: APPLET_CONTRACT_VERSION,
463
- });
464
- });
465
- }
466
-
467
- /** Send one client transaction; resolves on `ack`, rejects on `reject`. */
468
- mutate(mutations: AppletMutationV1[]): Promise<AppletChangeV1[]> {
469
- if (!this.#socket) {
470
- return Promise.reject(new Error("The Applet is not connected"));
471
- }
472
- const txnId = `c${++this.#txnSeq}`;
473
- return new Promise<AppletChangeV1[]>((resolve, reject) => {
474
- this.#pending.set(txnId, { resolve, reject });
475
- try {
476
- this.#write({
477
- v: APPLET_PROTOCOL_VERSION,
478
- type: "mutate",
479
- txnId,
480
- mutations,
481
- });
482
- } catch (error) {
483
- this.#pending.delete(txnId);
484
- reject(error instanceof Error ? error : new Error(String(error)));
485
- }
486
- });
487
- }
488
-
489
- #write(frame: Parameters<typeof encodeFrame>[0]): void {
490
- const socket = this.#socket;
491
- if (!socket) throw new Error("The Applet is not connected");
492
- socket.send(encodeFrame(frame));
493
- }
494
-
495
- #failPending(error: Error): void {
496
- for (const entry of this.#pending.values()) entry.reject(error);
497
- this.#pending.clear();
498
- }
499
-
500
- #setState(patch: Partial<AppletState>): void {
501
- this.#state = { ...this.#state, ...patch };
502
- for (const listener of this.#listeners) listener();
503
- }
504
- }
package/src/kit/README.md DELETED
@@ -1,130 +0,0 @@
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`.