@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.
package/src/cli/new.ts ADDED
@@ -0,0 +1,74 @@
1
+ /** `applet new` — the template, with the name filled in. */
2
+
3
+ import { mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises";
4
+ import { join } from "node:path";
5
+
6
+ import { SDK_ROOT } from "./paths.js";
7
+
8
+ const TEMPLATE_ROOT = join(SDK_ROOT, "template");
9
+
10
+ /** `Weekly Plan` -> `weekly-plan`. */
11
+ export function appletIdFrom(name: string): string {
12
+ const id = name
13
+ .trim()
14
+ .toLowerCase()
15
+ .replace(/[^a-z0-9]+/g, "-")
16
+ .replace(/^-+|-+$/g, "")
17
+ .slice(0, 32)
18
+ .replace(/-+$/g, "");
19
+ if (!/^[a-z][a-z0-9-]{0,31}$/.test(id)) {
20
+ throw new Error(`"${name}" does not yield a usable Applet id`);
21
+ }
22
+ return id;
23
+ }
24
+
25
+ function fill(text: string, id: string, displayName: string): string {
26
+ return text
27
+ .replaceAll("__APPLET_ID__", id)
28
+ .replaceAll("__APPLET_NAME__", displayName);
29
+ }
30
+
31
+ async function exists(path: string): Promise<boolean> {
32
+ try {
33
+ await stat(path);
34
+ return true;
35
+ } catch {
36
+ return false;
37
+ }
38
+ }
39
+
40
+ export interface NewAppletResult {
41
+ id: string;
42
+ displayName: string;
43
+ directory: string;
44
+ files: string[];
45
+ }
46
+
47
+ export async function newApplet(options: {
48
+ name: string;
49
+ /** Where the Applet directory is created; the durable root in production. */
50
+ parent: string;
51
+ }): Promise<NewAppletResult> {
52
+ const id = appletIdFrom(options.name);
53
+ const directory = join(options.parent, id);
54
+ if (await exists(directory)) {
55
+ throw new Error(`${directory} already exists`);
56
+ }
57
+ const files: string[] = [];
58
+ const copy = async (from: string, to: string): Promise<void> => {
59
+ await mkdir(to, { recursive: true });
60
+ for (const entry of await readdir(from, { withFileTypes: true })) {
61
+ const source = join(from, entry.name);
62
+ const target = join(to, entry.name);
63
+ if (entry.isDirectory()) {
64
+ await copy(source, target);
65
+ continue;
66
+ }
67
+ const text = await readFile(source, "utf8");
68
+ await writeFile(target, fill(text, id, options.name), "utf8");
69
+ files.push(entry.name);
70
+ }
71
+ };
72
+ await copy(TEMPLATE_ROOT, directory);
73
+ return { id, displayName: options.name, directory, files };
74
+ }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Where the SDK is on disk, and how an Applet's imports resolve to it.
3
+ *
4
+ * An Applet lives at a durable root with no `node_modules` of its own — it is
5
+ * synchronised source, not an npm project — so every specifier it may write is
6
+ * mapped here, once, and the same map feeds the type checker and the bundler.
7
+ */
8
+
9
+ import { readFileSync } from "node:fs";
10
+ import { createRequire } from "node:module";
11
+ import { dirname, join } from "node:path";
12
+ import { fileURLToPath } from "node:url";
13
+
14
+ const require = createRequire(import.meta.url);
15
+
16
+ /**
17
+ * Found by walking up to this package's own `package.json`, not by counting
18
+ * directories.
19
+ *
20
+ * The same module runs from two depths: `src/cli/paths.ts` under Bun, and the
21
+ * bundled `dist/cli.mjs` under Node on the Computer, which has no Bun. A fixed
22
+ * `../../` is right for one and silently wrong for the other — it would resolve
23
+ * the SDK's entries to a directory that does not exist and every Applet import
24
+ * would fail to type-check with no explanation.
25
+ */
26
+ function findSdkRoot(): string {
27
+ let directory = dirname(fileURLToPath(import.meta.url));
28
+ for (let depth = 0; depth < 6; depth += 1) {
29
+ try {
30
+ const manifest = JSON.parse(
31
+ readFileSync(join(directory, "package.json"), "utf8"),
32
+ ) as { name?: unknown };
33
+ if (manifest.name === "@frockbot/applet-sdk") return `${directory}/`;
34
+ } catch {
35
+ // Not this directory; keep walking.
36
+ }
37
+ const parent = dirname(directory);
38
+ if (parent === directory) break;
39
+ directory = parent;
40
+ }
41
+ throw new Error(
42
+ "the Applets SDK cannot find its own package root; reinstall @frockbot/applet-sdk",
43
+ );
44
+ }
45
+
46
+ /** The installed `@frockbot/applet-sdk` directory. */
47
+ export const SDK_ROOT = findSdkRoot();
48
+
49
+ export const SDK_ENTRIES = {
50
+ "@frockbot/applet-sdk/server": join(SDK_ROOT, "src/server/index.ts"),
51
+ "@frockbot/applet-sdk/client": join(SDK_ROOT, "src/client/index.ts"),
52
+ "@frockbot/applet-sdk/kit": join(SDK_ROOT, "src/kit/index.tsx"),
53
+ "@frockbot/applet-sdk/protocol": join(SDK_ROOT, "src/protocol/index.ts"),
54
+ } as const;
55
+
56
+ /** The ambient declaration of the one Cloudflare module the SDK names. */
57
+ export const SDK_WORKERS_TYPES = join(
58
+ SDK_ROOT,
59
+ "types/cloudflare-workers.d.ts",
60
+ );
61
+
62
+ function packageDirectory(specifier: string): string {
63
+ return dirname(require.resolve(`${specifier}/package.json`));
64
+ }
65
+
66
+ /** Node module directories the bundler searches for React and TanStack DB. */
67
+ export function bundlerNodePaths(): string[] {
68
+ const paths = new Set<string>([join(SDK_ROOT, "node_modules")]);
69
+ for (const specifier of [
70
+ "react",
71
+ "react-dom",
72
+ "@tanstack/db",
73
+ "@tanstack/react-db",
74
+ ]) {
75
+ try {
76
+ paths.add(join(packageDirectory(specifier), "..", ".."));
77
+ } catch {
78
+ // Resolved through the SDK's own node_modules instead.
79
+ }
80
+ }
81
+ return [...paths];
82
+ }
83
+
84
+ /** `paths` for the type checker: the SDK's entries plus React's declarations. */
85
+ export function typeCheckerPaths(): Record<string, string[]> {
86
+ const paths: Record<string, string[]> = {};
87
+ for (const [specifier, file] of Object.entries(SDK_ENTRIES))
88
+ paths[specifier] = [file];
89
+ try {
90
+ const types = packageDirectory("@types/react");
91
+ paths.react = [join(types, "index.d.ts")];
92
+ paths["react/jsx-runtime"] = [join(types, "jsx-runtime.d.ts")];
93
+ paths["react/*"] = [join(types, "*")];
94
+ } catch {
95
+ // No React declarations available; `applet check` reports the import.
96
+ }
97
+ return paths;
98
+ }
@@ -0,0 +1,123 @@
1
+ /**
2
+ * The built Applet, running for real, in a Node process.
3
+ *
4
+ * Miniflare gives the built `dist/server.js` the one thing no fake can: a
5
+ * SQLite-backed Durable Object with hibernating WebSockets, which is exactly
6
+ * what the loader gives it in production. `applet dev` serves the page from
7
+ * it, and `applet build` uses the same runtime to ask the mounted class what
8
+ * tools it declares rather than guessing from the source.
9
+ */
10
+
11
+ import { convertV4MiniflareOptions, Miniflare } from "miniflare";
12
+
13
+ /** Pinned with the SDK: the runtime an Applet is checked against. */
14
+ export const APPLET_COMPATIBILITY_DATE = "2026-08-27";
15
+
16
+ /**
17
+ * The dev worker. It exists only to route: the DO class is the Applet's own,
18
+ * and everything else here is the two seams the kernel provides in production
19
+ * — a viewer token on the socket, and a `CAPABILITIES` binding.
20
+ */
21
+ const DEV_WORKER = `
22
+ export { Applet } from "./server.js";
23
+
24
+ export default {
25
+ async fetch(request, env) {
26
+ const url = new URL(request.url);
27
+ const stub = env.APPLET.get(env.APPLET.idFromName(env.APPLET_ID));
28
+
29
+ if (url.pathname === "/socket") {
30
+ if (url.searchParams.get("token") !== env.APPLET_TOKEN) {
31
+ return new Response("Forbidden", { status: 403 });
32
+ }
33
+ url.searchParams.set("viewer", url.searchParams.get("viewer") ?? "dev-viewer");
34
+ return stub.fetch(new Request(url, request));
35
+ }
36
+
37
+ if (url.pathname === "/health") {
38
+ return Response.json(await stub.health());
39
+ }
40
+
41
+ if (url.pathname === "/describe") {
42
+ return Response.json(await stub.describe());
43
+ }
44
+
45
+ if (url.pathname === "/tool" && request.method === "POST") {
46
+ const body = await request.json();
47
+ try {
48
+ return Response.json({ ok: true, result: await stub.invokeTool(body.name, body.input) });
49
+ } catch (error) {
50
+ return Response.json({ ok: false, error: String(error && error.message || error) });
51
+ }
52
+ }
53
+
54
+ if (url.pathname === "/" || url.pathname === "/index.html") {
55
+ return new Response(env.APPLET_UI, {
56
+ headers: { "content-type": "text/html; charset=utf-8" },
57
+ });
58
+ }
59
+ return new Response("Not found", { status: 404 });
60
+ },
61
+ };
62
+ `;
63
+
64
+ export interface AppletRuntimeOptions {
65
+ /** Contents of `dist/server.js`: one ESM file importing only cloudflare:workers. */
66
+ serverCode: string;
67
+ /** Contents of `dist/ui.html`; omitted when only `health()` is wanted. */
68
+ html?: string;
69
+ appletId: string;
70
+ /** The dev viewer token the socket demands. */
71
+ token: string;
72
+ /** 0 picks a free port. */
73
+ port?: number;
74
+ }
75
+
76
+ export interface AppletRuntime {
77
+ url: URL;
78
+ fetch(path: string, init?: RequestInit): Promise<Response>;
79
+ dispose(): Promise<void>;
80
+ }
81
+
82
+ export async function startAppletRuntime(
83
+ options: AppletRuntimeOptions,
84
+ ): Promise<AppletRuntime> {
85
+ // Miniflare 5's own option shape is the wrangler config (`workers[].config`).
86
+ // `convertV4MiniflareOptions` is the supported way to keep the flat v4 shape,
87
+ // which is the one the Workers docs and the rest of this repo speak.
88
+ const miniflare = new Miniflare(
89
+ convertV4MiniflareOptions({
90
+ modules: [
91
+ { type: "ESModule", path: "/index.mjs", contents: DEV_WORKER },
92
+ { type: "ESModule", path: "/server.js", contents: options.serverCode },
93
+ ],
94
+ modulesRoot: "/",
95
+ compatibilityDate: APPLET_COMPATIBILITY_DATE,
96
+ compatibilityFlags: ["nodejs_compat"],
97
+ durableObjects: { APPLET: { className: "Applet", useSQLite: true } },
98
+ serviceBindings: {
99
+ // The lease-backed proxy is a later slice; models are unavailable.
100
+ CAPABILITIES: async () =>
101
+ Response.json({ status: "unavailable", reason: "dev" }),
102
+ },
103
+ bindings: {
104
+ APPLET_ID: options.appletId,
105
+ APPLET_TOKEN: options.token,
106
+ APPLET_UI: options.html ?? "",
107
+ },
108
+ host: "127.0.0.1",
109
+ port: options.port ?? 0,
110
+ }),
111
+ );
112
+
113
+ const url = await miniflare.ready;
114
+ return {
115
+ url,
116
+ fetch: (path, init) =>
117
+ miniflare.dispatchFetch(
118
+ new URL(path, url).toString(),
119
+ init as never,
120
+ ) as unknown as Promise<Response>,
121
+ dispose: () => miniflare.dispose(),
122
+ };
123
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * The TanStack DB adapter: one collection per declared table, synced from the
3
+ * Applet socket and mutated back over it.
4
+ *
5
+ * `sync` applies `snapshot` and `changes`; `onInsert`/`onUpdate`/`onDelete`
6
+ * send one `mutate` frame and return its promise, so an `ack` confirms the
7
+ * optimistic write and a `reject` rolls it back.
8
+ */
9
+
10
+ import { createCollection, type Collection } from "@tanstack/db";
11
+
12
+ import type { AppletMutationV1 } from "../protocol/index.js";
13
+ import type { AppletTransport } from "./transport.js";
14
+
15
+ export type AppletRow = Record<string, unknown> & { id: string };
16
+
17
+ export function createAppletCollection(
18
+ name: string,
19
+ transport: AppletTransport,
20
+ ): Collection<AppletRow, string> {
21
+ return createCollection<AppletRow, string>({
22
+ id: `applet:${name}`,
23
+ getKey: (row) => row.id,
24
+ startSync: true,
25
+ sync: {
26
+ // The server always sends the whole row on update.
27
+ rowUpdateMode: "full",
28
+ sync: ({ begin, write, commit, markReady, truncate }) =>
29
+ transport.registerTable(name, {
30
+ begin: () => begin(),
31
+ write: (message) => {
32
+ if (message.type === "delete") {
33
+ write({ type: "delete", key: message.key! });
34
+ return;
35
+ }
36
+ write({ type: message.type, value: message.value as AppletRow });
37
+ },
38
+ commit: () => {
39
+ commit();
40
+ },
41
+ markReady,
42
+ truncate,
43
+ }),
44
+ },
45
+ onInsert: ({ transaction }) =>
46
+ transport.mutate(
47
+ transaction.mutations.map((mutation): AppletMutationV1 => ({
48
+ table: name,
49
+ op: "insert",
50
+ key: String(mutation.key),
51
+ value: mutation.modified as Record<string, unknown>,
52
+ })),
53
+ ),
54
+ onUpdate: ({ transaction }) =>
55
+ transport.mutate(
56
+ transaction.mutations.map((mutation): AppletMutationV1 => ({
57
+ table: name,
58
+ op: "update",
59
+ key: String(mutation.key),
60
+ value: mutation.changes as Record<string, unknown>,
61
+ })),
62
+ ),
63
+ onDelete: ({ transaction }) =>
64
+ transport.mutate(
65
+ transaction.mutations.map((mutation): AppletMutationV1 => ({
66
+ table: name,
67
+ op: "delete",
68
+ key: String(mutation.key),
69
+ })),
70
+ ),
71
+ });
72
+ }
@@ -0,0 +1,203 @@
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 `applet dev` and 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
+ /** The host's `init`, with the fields an Applet page needs. */
91
+ export interface AppletHostInitV1 {
92
+ themeTokens: Record<string, string>;
93
+ applet: AppletInitV1;
94
+ }
95
+
96
+ function decodeHostInit(data: unknown): AppletHostInitV1 | undefined {
97
+ if (!data || typeof data !== "object") return undefined;
98
+ const message = data as Record<string, unknown>;
99
+ if (message.schemaVersion !== 1 || message.type !== "init") return undefined;
100
+ const applet = message.applet;
101
+ const tokens = message.themeTokens;
102
+ if (!applet || typeof applet !== "object") return undefined;
103
+ if (!tokens || typeof tokens !== "object") return undefined;
104
+ const value = applet as Record<string, unknown>;
105
+ if (
106
+ typeof value.socketUrl !== "string" ||
107
+ typeof value.token !== "string" ||
108
+ typeof value.generationId !== "string"
109
+ ) {
110
+ return undefined;
111
+ }
112
+ const themeTokens: Record<string, string> = {};
113
+ for (const [key, entry] of Object.entries(
114
+ tokens as Record<string, unknown>,
115
+ )) {
116
+ if (typeof entry === "string" && /^[a-z][a-z0-9-]{0,63}$/.test(key)) {
117
+ themeTokens[key] = entry;
118
+ }
119
+ }
120
+ return {
121
+ themeTokens,
122
+ applet: {
123
+ socketUrl: value.socketUrl,
124
+ token: value.token,
125
+ generationId: value.generationId,
126
+ },
127
+ };
128
+ }
129
+
130
+ /** Paint the host's semantic tokens onto the page as `--frockbot-*`. */
131
+ export function applyThemeTokens(tokens: Record<string, string>): void {
132
+ if (typeof document === "undefined") return;
133
+ for (const [name, value] of Object.entries(tokens)) {
134
+ document.documentElement.style.setProperty(`--frockbot-${name}`, value);
135
+ }
136
+ }
137
+
138
+ /** Subscribe to the host's `init` message. Returns an unsubscribe function. */
139
+ export function listenForAppletInit(
140
+ handler: (init: AppletHostInitV1) => void,
141
+ ): () => void {
142
+ if (typeof window === "undefined") return () => {};
143
+ const listener = (event: MessageEvent) => {
144
+ if (event.source !== window.parent) return;
145
+ const init = decodeHostInit(event.data);
146
+ if (init) handler(init);
147
+ };
148
+ window.addEventListener("message", listener);
149
+ return () => window.removeEventListener("message", listener);
150
+ }
151
+
152
+ export function createApplet<TServer extends { tables: TablesShape }>(
153
+ options: CreateAppletOptions = {},
154
+ ): AppletClient<TServer["tables"]> {
155
+ const { autoConnect, ...transportOptions } = options;
156
+ const transport = new AppletTransport(transportOptions);
157
+ const collections = new Map<string, Collection<AppletRow, string>>();
158
+
159
+ const tables = new Proxy({} as Record<string, unknown>, {
160
+ get(_target, property) {
161
+ if (typeof property !== "string") return undefined;
162
+ let collection = collections.get(property);
163
+ if (!collection) {
164
+ collection = createAppletCollection(property, transport);
165
+ collections.set(property, collection);
166
+ }
167
+ return collection;
168
+ },
169
+ has: () => true,
170
+ ownKeys: () => [...collections.keys()],
171
+ getOwnPropertyDescriptor: () => ({ enumerable: true, configurable: true }),
172
+ }) as AppletCollections<TServer["tables"]>;
173
+
174
+ if (autoConnect ?? typeof window !== "undefined") {
175
+ let lastInit: AppletInitV1 | undefined;
176
+ listenForAppletInit((init) => {
177
+ applyThemeTokens(init.themeTokens);
178
+ lastInit = init.applet;
179
+ transport.connect(init.applet);
180
+ });
181
+ // Leave with a close frame rather than a dropped connection: the server
182
+ // then sees a 1000, not a 1006 it has to discover on its next write. A
183
+ // page restored from the back/forward cache reconnects with the same
184
+ // token; a stale one is answered with a reload by the host.
185
+ window.addEventListener("pagehide", () => transport.close());
186
+ window.addEventListener("pageshow", (event) => {
187
+ if (event.persisted && lastInit) transport.connect(lastInit);
188
+ });
189
+ }
190
+
191
+ return {
192
+ tables,
193
+ useLiveQuery,
194
+ useApplet: () =>
195
+ useSyncExternalStore(
196
+ (listener) => transport.subscribe(listener),
197
+ () => transport.state,
198
+ () => transport.state,
199
+ ),
200
+ connect: (init) => transport.connect(init),
201
+ close: () => transport.close(),
202
+ };
203
+ }