@frockbot/applet-sdk 0.7.149 → 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,248 +0,0 @@
1
- /**
2
- * `@frockbot/applet-sdk/client` — everything an Applet's `ui.tsx` imports.
3
- *
4
- * ```tsx
5
- * import { createApplet, newId } from "@frockbot/applet-sdk/client";
6
- * import type TodoApplet from "./server";
7
- *
8
- * const applet = createApplet<TodoApplet>();
9
- *
10
- * export default function App() {
11
- * const { data: todos } = applet.useLiveQuery((q) =>
12
- * q.from({ t: applet.tables.todos }).orderBy(({ t }) => t.createdAt),
13
- * );
14
- * }
15
- * ```
16
- *
17
- * The page never opens the socket itself: the host sends an `init` postMessage
18
- * carrying the theme tokens and a short-lived viewer token, and `createApplet`
19
- * connects from that. `connect(init)` is the same path, called by hand, which
20
- * is what the tests use.
21
- */
22
-
23
- import type { Collection } from "@tanstack/db";
24
- import { useLiveQuery } from "@tanstack/react-db";
25
- import { useSyncExternalStore, type ReactNode } from "react";
26
- import { createRoot } from "react-dom/client";
27
-
28
- import type { RowOf, TablesShape } from "../schema/index.js";
29
- import { createAppletCollection, type AppletRow } from "./collections.js";
30
- import {
31
- AppletTransport,
32
- type AppletInitV1,
33
- type AppletState,
34
- type AppletStatus,
35
- type AppletTransportOptions,
36
- } from "./transport.js";
37
-
38
- export type {
39
- AppletInitV1,
40
- AppletState,
41
- AppletStatus,
42
- AppletSocket,
43
- AppletSocketFactory,
44
- AppletTransportOptions,
45
- } from "./transport.js";
46
- export { AppletTransport } from "./transport.js";
47
- export { useLiveQuery } from "@tanstack/react-db";
48
- export { eq, gt, gte, ilike, like, lt, lte, not, or, and } from "@tanstack/db";
49
-
50
- /** A fresh row key. Client inserts must carry one; server inserts need not. */
51
- export function newId(): string {
52
- return crypto.randomUUID();
53
- }
54
-
55
- /**
56
- * Render the Applet. The SDK owns this so an Applet never imports `react-dom`
57
- * — one fewer specifier to remember, and the linter can keep the import list
58
- * to three entries.
59
- */
60
- export function mount(element: ReactNode): void {
61
- const existing = document.getElementById("applet-root");
62
- const container =
63
- existing ?? document.body.appendChild(document.createElement("div"));
64
- createRoot(container).render(element);
65
- }
66
-
67
- export type AppletCollections<TTables extends TablesShape> = {
68
- [K in keyof TTables]: Collection<RowOf<TTables[K]> & AppletRow, string>;
69
- };
70
-
71
- export interface AppletClient<TTables extends TablesShape> {
72
- /** One TanStack DB collection per declared table, created on first access. */
73
- readonly tables: AppletCollections<TTables>;
74
- readonly useLiveQuery: typeof useLiveQuery;
75
- /** Connection status, viewer identity, and the mounted generation. */
76
- useApplet(): AppletState;
77
- /** Open the socket by hand; the host's `init` message does this for you. */
78
- connect(init: AppletInitV1): void;
79
- close(): void;
80
- }
81
-
82
- export interface CreateAppletOptions extends AppletTransportOptions {
83
- /**
84
- * Listen for the host's `init` postMessage and connect from it.
85
- * Defaults to true in a browser and false anywhere else.
86
- */
87
- autoConnect?: boolean;
88
- }
89
-
90
- /**
91
- * The host's `init`, with the fields an Applet page needs — or its `refresh`,
92
- * the same shape carrying a fresh viewer credential for a page that is
93
- * already running, so the document need not be rebuilt to hold it.
94
- */
95
- export interface AppletHostInitV1 {
96
- type: "init" | "refresh";
97
- themeTokens: Record<string, string>;
98
- applet: AppletInitV1;
99
- }
100
-
101
- function decodeHostInit(data: unknown): AppletHostInitV1 | undefined {
102
- if (!data || typeof data !== "object") return undefined;
103
- const message = data as Record<string, unknown>;
104
- if (message.schemaVersion !== 1) return undefined;
105
- if (message.type !== "init" && message.type !== "refresh") return undefined;
106
- const applet = message.applet;
107
- const tokens = message.themeTokens;
108
- if (!applet || typeof applet !== "object") return undefined;
109
- if (!tokens || typeof tokens !== "object") return undefined;
110
- const value = applet as Record<string, unknown>;
111
- if (
112
- typeof value.socketUrl !== "string" ||
113
- typeof value.token !== "string" ||
114
- typeof value.generationId !== "string"
115
- ) {
116
- return undefined;
117
- }
118
- const themeTokens: Record<string, string> = {};
119
- for (const [key, entry] of Object.entries(
120
- tokens as Record<string, unknown>,
121
- )) {
122
- if (typeof entry === "string" && /^[a-z][a-z0-9-]{0,63}$/.test(key)) {
123
- themeTokens[key] = entry;
124
- }
125
- }
126
- return {
127
- type: message.type,
128
- themeTokens,
129
- applet: {
130
- socketUrl: value.socketUrl,
131
- token: value.token,
132
- generationId: value.generationId,
133
- ...(value.tokenTransport === "subprotocol-v1"
134
- ? { tokenTransport: "subprotocol-v1" as const }
135
- : {}),
136
- ...(value.timing === true ? { timing: true } : {}),
137
- },
138
- };
139
- }
140
-
141
- /**
142
- * One hop of the open path, posted to the host when its `init` asked for
143
- * timing. The host keeps the log; the page only says when each hop landed,
144
- * in its own clock, so the host can line them up with its own hops.
145
- */
146
- function postTiming(hop: string): void {
147
- if (typeof window === "undefined") return;
148
- window.parent.postMessage(
149
- { schemaVersion: 1, type: "applet/timing", hop, at: performance.now() },
150
- "*",
151
- );
152
- }
153
-
154
- /** Paint the host's semantic tokens onto the page as `--frockbot-*`. */
155
- export function applyThemeTokens(tokens: Record<string, string>): void {
156
- if (typeof document === "undefined") return;
157
- for (const [name, value] of Object.entries(tokens)) {
158
- document.documentElement.style.setProperty(`--frockbot-${name}`, value);
159
- }
160
- }
161
-
162
- /** Subscribe to the host's `init` and `refresh` messages. Returns an unsubscribe function. */
163
- export function listenForAppletInit(
164
- handler: (init: AppletHostInitV1) => void,
165
- ): () => void {
166
- if (typeof window === "undefined") return () => {};
167
- const listener = (event: MessageEvent) => {
168
- if (event.source !== window.parent) return;
169
- const init = decodeHostInit(event.data);
170
- if (init) handler(init);
171
- };
172
- window.addEventListener("message", listener);
173
- return () => window.removeEventListener("message", listener);
174
- }
175
-
176
- export function createApplet<TServer extends { tables: TablesShape }>(
177
- options: CreateAppletOptions = {},
178
- ): AppletClient<TServer["tables"]> {
179
- const { autoConnect, ...transportOptions } = options;
180
- const transport = new AppletTransport({
181
- onTiming: (hop) => {
182
- postTiming(hop);
183
- // `ready` is the collections marked ready; the first paint of what
184
- // they hold is the frame after React commits it.
185
- if (hop === "ready" && typeof requestAnimationFrame === "function") {
186
- requestAnimationFrame(() =>
187
- requestAnimationFrame(() => postTiming("first-render")),
188
- );
189
- }
190
- },
191
- ...transportOptions,
192
- });
193
- const collections = new Map<string, Collection<AppletRow, string>>();
194
-
195
- const tables = new Proxy({} as Record<string, unknown>, {
196
- get(_target, property) {
197
- if (typeof property !== "string") return undefined;
198
- let collection = collections.get(property);
199
- if (!collection) {
200
- collection = createAppletCollection(property, transport);
201
- collections.set(property, collection);
202
- }
203
- return collection;
204
- },
205
- has: () => true,
206
- ownKeys: () => [...collections.keys()],
207
- getOwnPropertyDescriptor: () => ({ enumerable: true, configurable: true }),
208
- }) as AppletCollections<TServer["tables"]>;
209
-
210
- if (autoConnect ?? typeof window !== "undefined") {
211
- let lastInit: AppletInitV1 | undefined;
212
- listenForAppletInit((init) => {
213
- applyThemeTokens(init.themeTokens);
214
- lastInit = init.applet;
215
- if (init.type === "refresh") transport.refresh(init.applet);
216
- else transport.connect(init.applet);
217
- });
218
- window.parent.postMessage(
219
- {
220
- schemaVersion: 1,
221
- type: "applet/ready",
222
- tokenTransport: "subprotocol-v1",
223
- },
224
- "*",
225
- );
226
- // Leave with a close frame rather than a dropped connection: the server
227
- // then sees a 1000, not a 1006 it has to discover on its next write. A
228
- // page restored from the back/forward cache reconnects with the same
229
- // token; a stale one is answered with a reload by the host.
230
- window.addEventListener("pagehide", () => transport.close());
231
- window.addEventListener("pageshow", (event) => {
232
- if (event.persisted && lastInit) transport.connect(lastInit);
233
- });
234
- }
235
-
236
- return {
237
- tables,
238
- useLiveQuery,
239
- useApplet: () =>
240
- useSyncExternalStore(
241
- (listener) => transport.subscribe(listener),
242
- () => transport.state,
243
- () => transport.state,
244
- ),
245
- connect: (init) => transport.connect(init),
246
- close: () => transport.close(),
247
- };
248
- }