@frockbot/applet-sdk 0.7.179 → 0.7.181

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/README.md CHANGED
@@ -68,11 +68,11 @@ the type checker; `build` goes on to the module and its manifest.
68
68
  each model provider a `stream`. A Plugin declares at most 64 tools, each
69
69
  name once.
70
70
 
71
- Each describe spawns its own workerd, and `boot.ts` bounds the boot. A
72
- runtime that is not ready within `BOOT_DEADLINE_MS` (30 seconds), or whose
73
- spawn fails outright, is a `RuntimeDidNotStart`. The build lets that runtime
74
- go rather than waiting on it and boots once more; if the second boot does not
75
- come up either, the stage fails with that as its diagnostic.
71
+ Each describe spawns its own workerd, and both ends of its life are bounded
72
+ so a build answers rather than hangs: a runtime not ready within
73
+ `BOOT_DEADLINE_MS` (30 seconds) fails the stage with that as its diagnostic,
74
+ and the teardown, awaited on every path, gets `DISPOSE_DEADLINE_MS`
75
+ (10 seconds).
76
76
 
77
77
  A build answers the module text and its manifest:
78
78
  `{ contract: 1, tools, hooks, services, triggers, views, cards, modelProviders, hashes: { module } }`,
package/package.json CHANGED
@@ -1,39 +1,28 @@
1
1
  {
2
2
  "name": "@frockbot/applet-sdk",
3
- "version": "0.7.179",
3
+ "version": "0.7.181",
4
4
  "private": false,
5
5
  "type": "module",
6
- "description": "Authoring SDK for FrockBot Applets: schema-first Durable Object server, TanStack DB client, component kit, linter, and the build pipeline.",
6
+ "description": "Authoring SDK for FrockBot Plugins: the declarations a Plugin is written against, and the build pipeline the cloud build service runs.",
7
7
  "license": "UNLICENSED",
8
8
  "exports": {
9
- "./lint": "./src/lint/index.ts",
10
9
  "./build/plugin": "./src/build/plugin.ts",
11
10
  "./plugin": "./plugin/index.d.ts",
12
11
  "./package.json": "./package.json"
13
12
  },
14
13
  "files": [
15
14
  "src",
16
- "types",
17
- "template",
18
15
  "plugin",
19
16
  "README.md"
20
17
  ],
21
18
  "scripts": {
22
- "test": "bun test test spike",
19
+ "test": "bun test test",
23
20
  "typecheck": "tsc --noEmit -p tsconfig.json"
24
21
  },
25
22
  "dependencies": {
26
- "@tanstack/db": "0.8.7",
27
- "@tanstack/react-db": "0.3.7",
28
- "@types/react": "19.2.18",
29
- "@types/react-dom": "19.2.7",
30
23
  "esbuild": "0.28.2",
31
- "eslint": "10.10.0",
32
24
  "miniflare": "5.20260828.0-alpha",
33
- "react": "19.2.8",
34
- "react-dom": "19.2.8",
35
- "typescript": "npm:typescript-native-bridge@6.0.3-bridge.16.tsgo.7.0.2",
36
- "typescript-eslint": "8.69.0"
25
+ "typescript": "npm:typescript-native-bridge@6.0.3-bridge.16.tsgo.7.0.2"
37
26
  },
38
27
  "devDependencies": {
39
28
  "@types/bun": "1.4.1",
package/plugin/index.d.ts CHANGED
@@ -3,10 +3,11 @@
3
3
  * against (ADR 0026).
4
4
  *
5
5
  * A Plugin is one ESM module with no imports of its own. It exports `tools`
6
- * and `execute`, and may export `hooks`, `services` and `triggers`. The
7
- * kernel's generated index imports the built module, checks these exports
8
- * against the Plugin's `plugin.json` once at mount, and hands every call a
9
- * narrow `ctx` naming only what that Plugin declared it may do.
6
+ * and `execute`, and may export `hooks`, `services`, `triggers`, `views`,
7
+ * `cards` and `modelProviders` (`PluginModule`). The kernel's generated index
8
+ * imports the built module, checks these exports against the Plugin's
9
+ * `plugin.json` once at mount, and hands every call a narrow `ctx` naming
10
+ * only what that Plugin declared it may do.
10
11
  *
11
12
  * These declarations are types only. They mirror `core/contracts/isolate.ts`
12
13
  * member for member — `BOT_ISOLATE_CONTEXT_KEYS_V1` is the list this file's
@@ -1,28 +1,21 @@
1
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.
2
+ * Where the SDK is on disk, so the Plugin build can resolve
3
+ * `@frockbot/applet-sdk/plugin` to its declarations.
7
4
  */
8
5
 
9
6
  import { readFileSync } from "node:fs";
10
- import { createRequire } from "node:module";
11
7
  import { dirname, join } from "node:path";
12
8
  import { fileURLToPath } from "node:url";
13
9
 
14
- const require = createRequire(import.meta.url);
15
-
16
10
  /**
17
11
  * Found by walking up to this package's own `package.json`, not by counting
18
12
  * directories.
19
13
  *
20
- * The same module runs from three depths: `src/build/paths.ts` under Bun, the
21
- * bundled `dist/cli.mjs` under Node, and the build service's own bundle, which
22
- * is emitted into `dist/` for exactly this reason. A fixed `../../` is right
23
- * for one and silently wrong for the others — it would resolve the SDK's
24
- * entries to a directory that does not exist and every Applet import would
25
- * fail to type-check with no explanation.
14
+ * The same module runs from two depths: `src/build/paths.ts` under Bun, and
15
+ * the build service's own bundle, which is emitted into `dist/` for exactly
16
+ * this reason. A fixed `../../` is right for one and silently wrong for the
17
+ * other — it would resolve the Plugin declarations to a file that does not
18
+ * exist and every Plugin import would fail to type-check with no explanation.
26
19
  */
27
20
  function findSdkRoot(): string {
28
21
  let directory = dirname(fileURLToPath(import.meta.url));
@@ -40,63 +33,12 @@ function findSdkRoot(): string {
40
33
  directory = parent;
41
34
  }
42
35
  throw new Error(
43
- "the Applets SDK cannot find its own package root; reinstall @frockbot/applet-sdk",
36
+ "the Plugin SDK cannot find its own package root; reinstall @frockbot/applet-sdk",
44
37
  );
45
38
  }
46
39
 
47
40
  /** The installed `@frockbot/applet-sdk` directory. */
48
41
  export const SDK_ROOT = findSdkRoot();
49
42
 
50
- export const SDK_ENTRIES = {
51
- "@frockbot/applet-sdk/server": join(SDK_ROOT, "src/server/index.ts"),
52
- "@frockbot/applet-sdk/client": join(SDK_ROOT, "src/client/index.ts"),
53
- "@frockbot/applet-sdk/kit": join(SDK_ROOT, "src/kit/index.tsx"),
54
- "@frockbot/applet-sdk/protocol": join(SDK_ROOT, "src/protocol/index.ts"),
55
- } as const;
56
-
57
- /** The ambient declaration of the one Cloudflare module the SDK names. */
58
- export const SDK_WORKERS_TYPES = join(
59
- SDK_ROOT,
60
- "types/cloudflare-workers.d.ts",
61
- );
62
-
63
43
  /** The Plugin declarations (`@frockbot/applet-sdk/plugin`), types only. */
64
44
  export const SDK_PLUGIN_TYPES = join(SDK_ROOT, "plugin/index.d.ts");
65
-
66
- function packageDirectory(specifier: string): string {
67
- return dirname(require.resolve(`${specifier}/package.json`));
68
- }
69
-
70
- /** Node module directories the bundler searches for React and TanStack DB. */
71
- export function bundlerNodePaths(): string[] {
72
- const paths = new Set<string>([join(SDK_ROOT, "node_modules")]);
73
- for (const specifier of [
74
- "react",
75
- "react-dom",
76
- "@tanstack/db",
77
- "@tanstack/react-db",
78
- ]) {
79
- try {
80
- paths.add(join(packageDirectory(specifier), "..", ".."));
81
- } catch {
82
- // Resolved through the SDK's own node_modules instead.
83
- }
84
- }
85
- return [...paths];
86
- }
87
-
88
- /** `paths` for the type checker: the SDK's entries plus React's declarations. */
89
- export function typeCheckerPaths(): Record<string, string[]> {
90
- const paths: Record<string, string[]> = {};
91
- for (const [specifier, file] of Object.entries(SDK_ENTRIES))
92
- paths[specifier] = [file];
93
- try {
94
- const types = packageDirectory("@types/react");
95
- paths.react = [join(types, "index.d.ts")];
96
- paths["react/jsx-runtime"] = [join(types, "jsx-runtime.d.ts")];
97
- paths["react/*"] = [join(types, "*")];
98
- } catch {
99
- // No React declarations available; the check stage reports the import.
100
- }
101
- return paths;
102
- }
@@ -27,15 +27,54 @@ import { build as esbuild } from "esbuild";
27
27
  import { convertV4MiniflareOptions, Miniflare } from "miniflare";
28
28
  import ts from "typescript";
29
29
 
30
- import type { AppletDiagnostic } from "../lint/index.js";
31
- import { bootedWithin, withOneMoreBoot } from "./boot.js";
32
30
  import { stableModulePaths } from "./module-paths.js";
33
- import { APPLET_COMPATIBILITY_DATE } from "./runtime.js";
34
31
  import { SDK_PLUGIN_TYPES } from "./paths.js";
35
32
 
33
+ /** Pinned with the SDK: the runtime a Plugin build is checked against. */
34
+ const PLUGIN_COMPATIBILITY_DATE = "2026-08-27";
35
+
36
+ /**
37
+ * A workerd boot takes well under a second; this is room for a loaded
38
+ * machine. Without it a runtime that never reported ready would hang the
39
+ * build container's request rather than answer.
40
+ */
41
+ const BOOT_DEADLINE_MS = 30_000;
42
+
43
+ /**
44
+ * Tearing a runtime down takes milliseconds, but Miniflare's dispose first
45
+ * waits out the startup, which can be the very thing that hung.
46
+ */
47
+ const DISPOSE_DEADLINE_MS = 10_000;
48
+
49
+ /** `work`, or an error naming what did not happen in time. */
50
+ async function within<T>(
51
+ work: Promise<T>,
52
+ ms: number,
53
+ what: string,
54
+ ): Promise<T> {
55
+ let timer: ReturnType<typeof setTimeout> | undefined;
56
+ const deadline = new Promise<never>((_, reject) => {
57
+ timer = setTimeout(() => reject(new Error(`${what} within ${ms}ms`)), ms);
58
+ });
59
+ try {
60
+ return await Promise.race([work, deadline]);
61
+ } finally {
62
+ clearTimeout(timer);
63
+ }
64
+ }
65
+
36
66
  export type PluginBuildStage =
37
67
  "descriptor" | "typecheck" | "bundle" | "describe";
38
68
 
69
+ export interface PluginDiagnostic {
70
+ /** Path relative to the Plugin's directory. */
71
+ file: string;
72
+ line: number;
73
+ column: number;
74
+ message: string;
75
+ severity: "error" | "warning";
76
+ }
77
+
39
78
  /** The Plugin's `plugin.json`, as far as the build reads it. */
40
79
  export interface PluginBuildDescriptorV1 {
41
80
  id: string;
@@ -69,11 +108,10 @@ export type PluginBuildOutcome =
69
108
  | {
70
109
  status: "failed";
71
110
  stage: PluginBuildStage;
72
- diagnostics: AppletDiagnostic[];
111
+ diagnostics: PluginDiagnostic[];
73
112
  };
74
113
 
75
114
  const PLUGIN_ID = /^[a-z][a-z0-9-]{0,63}$/;
76
- const TOOL_NAME = /^[a-z][a-z0-9_]{0,63}$/;
77
115
  const MAX_TOOLS = 64;
78
116
 
79
117
  const COMPILER_OPTIONS: ts.CompilerOptions = {
@@ -91,7 +129,7 @@ const COMPILER_OPTIONS: ts.CompilerOptions = {
91
129
  types: [],
92
130
  };
93
131
 
94
- function thrown(error: unknown, file = "plugin.json"): AppletDiagnostic[] {
132
+ function thrown(error: unknown, file = "plugin.json"): PluginDiagnostic[] {
95
133
  return [
96
134
  {
97
135
  file,
@@ -153,7 +191,7 @@ async function pluginSources(directory: string): Promise<string[]> {
153
191
  /** Type-check the Plugin against the SDK's Plugin declarations. */
154
192
  export async function typeCheckPlugin(
155
193
  directory: string,
156
- ): Promise<AppletDiagnostic[]> {
194
+ ): Promise<PluginDiagnostic[]> {
157
195
  const root = resolve(directory);
158
196
  const files = await pluginSources(root);
159
197
  if (!files.some((file) => relative(root, file) === "plugin.ts")) {
@@ -347,17 +385,11 @@ export default {
347
385
  `;
348
386
 
349
387
  /**
350
- * Ask the built module what it exports, by running it. The boot is bounded
351
- * and tried once more (`boot.ts`), so a build answers rather than hanging on
352
- * a runtime that never came up.
388
+ * Ask the built module what it exports, by running it. Both the boot and the
389
+ * teardown are bounded, so a build answers rather than hanging on a runtime
390
+ * that never came up.
353
391
  */
354
- export function describePlugin(
355
- moduleCode: string,
356
- ): Promise<PluginDescriptionV1> {
357
- return withOneMoreBoot(() => describeInWorkerd(moduleCode));
358
- }
359
-
360
- async function describeInWorkerd(
392
+ export async function describePlugin(
361
393
  moduleCode: string,
362
394
  ): Promise<PluginDescriptionV1> {
363
395
  const miniflare = new Miniflare(
@@ -367,7 +399,7 @@ async function describeInWorkerd(
367
399
  { type: "ESModule", path: "/plugin.js", contents: moduleCode },
368
400
  ],
369
401
  modulesRoot: "/",
370
- compatibilityDate: APPLET_COMPATIBILITY_DATE,
402
+ compatibilityDate: PLUGIN_COMPATIBILITY_DATE,
371
403
  // Import-time code runs with no way out: every fetch is answered here.
372
404
  outboundService: async () =>
373
405
  new Response("the build describes a Plugin without a network", {
@@ -377,10 +409,12 @@ async function describeInWorkerd(
377
409
  port: 0,
378
410
  }),
379
411
  );
380
- let started = false;
381
412
  try {
382
- const url = await bootedWithin(miniflare.ready);
383
- started = true;
413
+ const url = await within(
414
+ miniflare.ready,
415
+ BOOT_DEADLINE_MS,
416
+ "The Workers runtime did not start",
417
+ );
384
418
  const response = (await miniflare.dispatchFetch(
385
419
  new URL(`/describe?${randomUUID()}`, url).toString(),
386
420
  )) as unknown as Response;
@@ -392,9 +426,15 @@ async function describeInWorkerd(
392
426
  }
393
427
  return validateDescription(body.description);
394
428
  } finally {
395
- // A runtime that never started is let go of rather than waited on.
396
- if (started) await miniflare.dispose();
397
- else void miniflare.dispose().catch(() => {});
429
+ // Awaited on every path, a boot that missed its deadline included: this
430
+ // is what kills workerd. Miniflare's fallback is a process exit hook,
431
+ // `bun test` runs none, and a dispose still pending when the host exits
432
+ // leaves workerd running.
433
+ await within(
434
+ miniflare.dispose(),
435
+ DISPOSE_DEADLINE_MS,
436
+ "The Workers runtime did not stop",
437
+ );
398
438
  }
399
439
  }
400
440
 
package/src/build/boot.ts DELETED
@@ -1,75 +0,0 @@
1
- // A workerd boot, bounded.
2
- //
3
- // Each build spawns its own workerd through Miniflare, and a spawn
4
- // occasionally never comes up (a bun+workerd spawn race, roughly one boot in
5
- // fifty): it either never reports ready, or it fails outright and leaves the
6
- // runtime's stdio socket with nothing to connect to. A build that awaited
7
- // `ready` unbounded would hang to the test's timeout, or hang the build
8
- // container's request. So a boot is given a deadline, a boot that misses it
9
- // is let go of rather than waited on, both shapes are named
10
- // `RuntimeDidNotStart`, and the caller tries once more before answering with
11
- // that as its diagnostic.
12
-
13
- /** How long a workerd boot is given before the build gives up on it. */
14
- export const BOOT_DEADLINE_MS = 30_000;
15
-
16
- /** A workerd that never came up; the boot, not the code, failed. */
17
- export class RuntimeDidNotStart extends Error {}
18
-
19
- /**
20
- * The same race seen from the other side: the spawn fails outright rather
21
- * than hanging, and the runtime's stdio socket is never there to connect to.
22
- * That is a boot that did not happen, not a fault in the code being built.
23
- */
24
- function spawnFailed(error: unknown): boolean {
25
- const { code, syscall } = (error ?? {}) as {
26
- code?: unknown;
27
- syscall?: unknown;
28
- };
29
- return (
30
- (syscall === "connect" || syscall === "spawn") &&
31
- (code === "ENOENT" || code === "ECONNREFUSED" || code === "EAGAIN")
32
- );
33
- }
34
-
35
- /**
36
- * `ready`, or a `RuntimeDidNotStart` when the runtime never came up — either
37
- * because `ready` did not settle in time or because the spawn failed.
38
- */
39
- export async function bootedWithin<T>(ready: Promise<T>): Promise<T> {
40
- let timer: ReturnType<typeof setTimeout> | undefined;
41
- const deadline = new Promise<never>((_, reject) => {
42
- timer = setTimeout(
43
- () =>
44
- reject(
45
- new RuntimeDidNotStart(
46
- `The Workers runtime did not start within ${BOOT_DEADLINE_MS}ms`,
47
- ),
48
- ),
49
- BOOT_DEADLINE_MS,
50
- );
51
- });
52
- try {
53
- return await Promise.race([ready, deadline]);
54
- } catch (error) {
55
- if (!spawnFailed(error)) throw error;
56
- throw new RuntimeDidNotStart(
57
- `The Workers runtime could not be spawned: ${
58
- error instanceof Error ? error.message : String(error)
59
- }`,
60
- { cause: error },
61
- );
62
- } finally {
63
- clearTimeout(timer);
64
- }
65
- }
66
-
67
- /** Runs `boot` again, once, when the runtime never came up the first time. */
68
- export async function withOneMoreBoot<T>(boot: () => Promise<T>): Promise<T> {
69
- try {
70
- return await boot();
71
- } catch (error) {
72
- if (!(error instanceof RuntimeDidNotStart)) throw error;
73
- return await boot();
74
- }
75
- }
@@ -1,132 +0,0 @@
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. The build uses it to ask the
7
- * mounted class what
8
- * tools it declares rather than guessing from the source.
9
- */
10
-
11
- import { convertV4MiniflareOptions, Miniflare } from "miniflare";
12
-
13
- import { bootedWithin } from "./boot.js";
14
-
15
- /** Pinned with the SDK: the runtime a Plugin build is checked against. */
16
- export const APPLET_COMPATIBILITY_DATE = "2026-08-27";
17
-
18
- /**
19
- * The dev worker. It exists only to route: the DO class is the Applet's own,
20
- * and everything else here is the two seams the kernel provides in production
21
- * — a viewer token on the socket, and a `CAPABILITIES` binding.
22
- */
23
- const DEV_WORKER = `
24
- export { Applet } from "./server.js";
25
-
26
- export default {
27
- async fetch(request, env) {
28
- const url = new URL(request.url);
29
- const stub = env.APPLET.get(env.APPLET.idFromName(env.APPLET_ID));
30
-
31
- if (url.pathname === "/socket") {
32
- if (url.searchParams.get("token") !== env.APPLET_TOKEN) {
33
- return new Response("Forbidden", { status: 403 });
34
- }
35
- url.searchParams.set("viewer", url.searchParams.get("viewer") ?? "dev-viewer");
36
- return stub.fetch(new Request(url, request));
37
- }
38
-
39
- if (url.pathname === "/health") {
40
- return Response.json(await stub.health());
41
- }
42
-
43
- if (url.pathname === "/describe") {
44
- return Response.json(await stub.describe());
45
- }
46
-
47
- if (url.pathname === "/tool" && request.method === "POST") {
48
- const body = await request.json();
49
- try {
50
- return Response.json({ ok: true, result: await stub.invokeTool(body.name, body.input) });
51
- } catch (error) {
52
- return Response.json({ ok: false, error: String(error && error.message || error) });
53
- }
54
- }
55
-
56
- if (url.pathname === "/" || url.pathname === "/index.html") {
57
- return new Response(env.APPLET_UI, {
58
- headers: { "content-type": "text/html; charset=utf-8" },
59
- });
60
- }
61
- return new Response("Not found", { status: 404 });
62
- },
63
- };
64
- `;
65
-
66
- export interface AppletRuntimeOptions {
67
- /** Contents of `dist/server.js`: one ESM file importing only cloudflare:workers. */
68
- serverCode: string;
69
- /** Contents of `dist/ui.html`; omitted when only `health()` is wanted. */
70
- html?: string;
71
- appletId: string;
72
- /** The dev viewer token the socket demands. */
73
- token: string;
74
- /** 0 picks a free port. */
75
- port?: number;
76
- }
77
-
78
- export interface AppletRuntime {
79
- url: URL;
80
- fetch(path: string, init?: RequestInit): Promise<Response>;
81
- dispose(): Promise<void>;
82
- }
83
-
84
- export async function startAppletRuntime(
85
- options: AppletRuntimeOptions,
86
- ): Promise<AppletRuntime> {
87
- // Miniflare 5's own option shape is the wrangler config (`workers[].config`).
88
- // `convertV4MiniflareOptions` is the supported way to keep the flat v4 shape,
89
- // which is the one the Workers docs and the rest of this repo speak.
90
- const miniflare = new Miniflare(
91
- convertV4MiniflareOptions({
92
- modules: [
93
- { type: "ESModule", path: "/index.mjs", contents: DEV_WORKER },
94
- { type: "ESModule", path: "/server.js", contents: options.serverCode },
95
- ],
96
- modulesRoot: "/",
97
- compatibilityDate: APPLET_COMPATIBILITY_DATE,
98
- compatibilityFlags: ["nodejs_compat"],
99
- durableObjects: { APPLET: { className: "Applet", useSQLite: true } },
100
- serviceBindings: {
101
- // The lease-backed proxy is a later slice; models are unavailable.
102
- CAPABILITIES: async () =>
103
- Response.json({ status: "unavailable", reason: "dev" }),
104
- },
105
- bindings: {
106
- APPLET_ID: options.appletId,
107
- APPLET_TOKEN: options.token,
108
- APPLET_UI: options.html ?? "",
109
- },
110
- host: "127.0.0.1",
111
- port: options.port ?? 0,
112
- }),
113
- );
114
-
115
- let url: URL;
116
- try {
117
- url = await bootedWithin(miniflare.ready);
118
- } catch (error) {
119
- // A runtime that never started is let go of rather than waited on.
120
- void miniflare.dispose().catch(() => {});
121
- throw error;
122
- }
123
- return {
124
- url,
125
- fetch: (path, init) =>
126
- miniflare.dispatchFetch(
127
- new URL(path, url).toString(),
128
- init as never,
129
- ) as unknown as Promise<Response>,
130
- dispose: () => miniflare.dispose(),
131
- };
132
- }
package/src/lint/index.ts DELETED
@@ -1,148 +0,0 @@
1
- /**
2
- * `@frockbot/applet-sdk/lint` — the flat config, the rules, and the one call
3
- * the check stage makes.
4
- *
5
- * A diagnostic is the SDK's whole answer to "what did I do wrong": the CLI
6
- * prints `path:line:col message` and nothing else, so what a Bot must remember
7
- * is the message, not a manual.
8
- */
9
-
10
- import { readdir, readFile } from "node:fs/promises";
11
- import { join, relative, resolve } from "node:path";
12
-
13
- import { ESLint, type Linter, type Rule } from "eslint";
14
- import tseslint from "typescript-eslint";
15
-
16
- import { anchoredToToken, appletRules } from "./rules.js";
17
-
18
- export * from "./rules.js";
19
-
20
- export interface AppletDiagnostic {
21
- /** Path relative to the Applet's directory. */
22
- file: string;
23
- line: number;
24
- column: number;
25
- message: string;
26
- severity: "error" | "warning";
27
- }
28
-
29
- /** The one line the CLI prints per diagnostic. */
30
- export function formatDiagnostic(diagnostic: AppletDiagnostic): string {
31
- return `${diagnostic.file}:${diagnostic.line}:${diagnostic.column} ${diagnostic.message}`;
32
- }
33
-
34
- export const appletPlugin = {
35
- rules: appletRules as unknown as Record<string, Rule.RuleModule>,
36
- };
37
-
38
- /** The flat config an Applet is linted with. */
39
- export function appletLintConfig(): Linter.Config[] {
40
- return [
41
- {
42
- files: ["**/*.ts", "**/*.tsx"],
43
- languageOptions: {
44
- parser: tseslint.parser as unknown as Linter.Parser,
45
- parserOptions: {
46
- ecmaVersion: 2023,
47
- sourceType: "module",
48
- ecmaFeatures: { jsx: true },
49
- },
50
- },
51
- plugins: { applet: appletPlugin },
52
- rules: {
53
- "applet/no-raw-colors": "error",
54
- "applet/no-network": "error",
55
- "applet/allowed-imports": "error",
56
- "applet/tables-via-table": "error",
57
- "applet/tools-via-this-tool": "error",
58
- },
59
- },
60
- ];
61
- }
62
-
63
- const CSS_COLOR =
64
- /#[0-9a-fA-F]{3,8}\b|\brgba?\s*\([^)]*\)|\bhsla?\s*\([^)]*\)|\bcolor-mix\s*\(/g;
65
-
66
- /**
67
- * ESLint does not see `.css`, and a stylesheet is the easiest place to smuggle
68
- * a colour past the theme, so the same rule is applied here by hand.
69
- */
70
- export function lintCssText(text: string, file: string): AppletDiagnostic[] {
71
- const diagnostics: AppletDiagnostic[] = [];
72
- const lines = text.split("\n");
73
- lines.forEach((line, index) => {
74
- if (line.trimStart().startsWith("/*")) return;
75
- for (const match of line.matchAll(CSS_COLOR)) {
76
- // Judge the declaration the literal sits in, the same unit the TSX rule
77
- // judges: a literal is fine where its own value names a theme token.
78
- const start = line.lastIndexOf(";", match.index) + 1;
79
- const end = line.indexOf(";", match.index);
80
- const declaration = line.slice(start, end === -1 ? undefined : end);
81
- if (anchoredToToken(declaration)) continue;
82
- diagnostics.push({
83
- file,
84
- line: index + 1,
85
- column: match.index + 1,
86
- message:
87
- `"${match[0]}" is a raw colour; use a --frockbot-* theme token ` +
88
- "(or a var(--frockbot-…, fallback)).",
89
- severity: "error",
90
- });
91
- }
92
- });
93
- return diagnostics;
94
- }
95
-
96
- async function sourceFiles(
97
- directory: string,
98
- extensions: string[],
99
- ): Promise<string[]> {
100
- const found: string[] = [];
101
- const walk = async (current: string): Promise<void> => {
102
- for (const entry of await readdir(current, { withFileTypes: true })) {
103
- if (entry.name === "node_modules" || entry.name === "dist") continue;
104
- if (entry.name.startsWith(".")) continue;
105
- const path = join(current, entry.name);
106
- if (entry.isDirectory()) await walk(path);
107
- else if (extensions.some((extension) => entry.name.endsWith(extension))) {
108
- found.push(path);
109
- }
110
- }
111
- };
112
- await walk(directory);
113
- return found;
114
- }
115
-
116
- /** Lint one Applet directory. Returns diagnostics; never throws on a finding. */
117
- export async function lintApplet(
118
- directory: string,
119
- ): Promise<AppletDiagnostic[]> {
120
- const root = resolve(directory);
121
- const eslint = new ESLint({
122
- cwd: root,
123
- overrideConfigFile: true,
124
- overrideConfig: appletLintConfig(),
125
- errorOnUnmatchedPattern: false,
126
- });
127
- const results = await eslint.lintFiles(
128
- await sourceFiles(root, [".ts", ".tsx"]),
129
- );
130
- const diagnostics: AppletDiagnostic[] = [];
131
- for (const result of results) {
132
- for (const message of result.messages) {
133
- diagnostics.push({
134
- file: relative(root, result.filePath),
135
- line: message.line ?? 1,
136
- column: message.column ?? 1,
137
- message: `${message.message}${message.ruleId ? ` (${message.ruleId})` : ""}`,
138
- severity: message.severity === 2 ? "error" : "warning",
139
- });
140
- }
141
- }
142
- for (const path of await sourceFiles(root, [".css"])) {
143
- diagnostics.push(
144
- ...lintCssText(await readFile(path, "utf8"), relative(root, path)),
145
- );
146
- }
147
- return diagnostics;
148
- }
package/src/lint/rules.ts DELETED
@@ -1,394 +0,0 @@
1
- /**
2
- * The five rules that keep an Applet inside the SDK.
3
- *
4
- * Each one exists because of a way an Applet can look right and be wrong: a
5
- * hard-coded colour that ignores the user's theme, a network call the loader
6
- * would block anyway, an import that will not exist at build time, and state
7
- * or tools declared in a shape the server cannot see. This set grows from
8
- * observed failures — add a rule and a test here, never a paragraph in a
9
- * prompt.
10
- */
11
-
12
- /** A syntax node, walked structurally so no parser type leaks into the rules. */
13
- export interface AstNode {
14
- type: string;
15
- [field: string]: unknown;
16
- }
17
-
18
- export interface RuleContext {
19
- report(descriptor: { node: AstNode; message: string }): void;
20
- }
21
-
22
- export interface AppletRule {
23
- meta: {
24
- type: "problem" | "suggestion";
25
- docs: { description: string };
26
- schema: [];
27
- messages?: Record<string, string>;
28
- };
29
- create(context: RuleContext): Record<string, (node: AstNode) => void>;
30
- }
31
-
32
- function child(node: AstNode | undefined, field: string): AstNode | undefined {
33
- const value = node?.[field];
34
- return value && typeof value === "object" ? (value as AstNode) : undefined;
35
- }
36
-
37
- function name(node: AstNode | undefined): string | undefined {
38
- if (!node) return undefined;
39
- if (node.type === "Identifier" && typeof node.name === "string")
40
- return node.name;
41
- if (node.type === "Literal" && typeof node.value === "string")
42
- return node.value;
43
- return undefined;
44
- }
45
-
46
- function list(node: AstNode | undefined, field: string): AstNode[] {
47
- const value = node?.[field];
48
- return Array.isArray(value) ? (value as AstNode[]) : [];
49
- }
50
-
51
- // ---------------------------------------------------------------------------
52
- // no-raw-colors
53
- // ---------------------------------------------------------------------------
54
-
55
- const FUNCTIONAL_COLOR =
56
- /#[0-9a-fA-F]{3,8}\b|\brgba?\s*\(|\bhsla?\s*\(|\bcolor-mix\s*\(/;
57
- const NAMED_COLORS = new Set([
58
- "aqua",
59
- "black",
60
- "blue",
61
- "brown",
62
- "cyan",
63
- "fuchsia",
64
- "gold",
65
- "gray",
66
- "green",
67
- "grey",
68
- "indigo",
69
- "lime",
70
- "magenta",
71
- "maroon",
72
- "navy",
73
- "olive",
74
- "orange",
75
- "pink",
76
- "purple",
77
- "red",
78
- "silver",
79
- "teal",
80
- "violet",
81
- "white",
82
- "yellow",
83
- ]);
84
- const COLOR_PROPERTIES = new Set([
85
- "color",
86
- "background",
87
- "backgroundColor",
88
- "borderColor",
89
- "borderTopColor",
90
- "borderRightColor",
91
- "borderBottomColor",
92
- "borderLeftColor",
93
- "outlineColor",
94
- "fill",
95
- "stroke",
96
- "caretColor",
97
- "accentColor",
98
- "textDecorationColor",
99
- "boxShadow",
100
- "textShadow",
101
- ]);
102
-
103
- /**
104
- * A colour literal is fine only where the same value is anchored to a theme
105
- * token: as a `var(--frockbot-x, #fallback)` fallback, or as an ingredient of a
106
- * `color-mix()` over one. Anything else is a colour the User's theme cannot
107
- * move.
108
- */
109
- export function anchoredToToken(text: string): boolean {
110
- return /var\(\s*--frockbot-[a-z-]+/.test(text);
111
- }
112
-
113
- export const noRawColors: AppletRule = {
114
- meta: {
115
- type: "problem",
116
- docs: {
117
- description:
118
- "Colours come from the nine --frockbot-* theme tokens, never from a literal",
119
- },
120
- schema: [],
121
- },
122
- create(context) {
123
- const flag = (node: AstNode, text: string) => {
124
- if (anchoredToToken(text)) return;
125
- if (!FUNCTIONAL_COLOR.test(text)) return;
126
- context.report({
127
- node,
128
- message:
129
- "Use a --frockbot-* theme token instead of a colour literal " +
130
- "(the kit's components already do).",
131
- });
132
- };
133
- return {
134
- Literal(node) {
135
- if (typeof node.value === "string") flag(node, node.value);
136
- },
137
- TemplateElement(node) {
138
- const value = child(node, "value");
139
- const raw = value?.raw;
140
- if (typeof raw === "string") flag(node, raw);
141
- },
142
- Property(node) {
143
- const key = name(child(node, "key"));
144
- if (!key || !COLOR_PROPERTIES.has(key)) return;
145
- const value = child(node, "value");
146
- if (value?.type !== "Literal" || typeof value.value !== "string")
147
- return;
148
- const text = value.value.trim().toLowerCase();
149
- if (anchoredToToken(text) || !NAMED_COLORS.has(text)) return;
150
- context.report({
151
- node: value,
152
- message: `"${text}" is a raw colour; use a --frockbot-* theme token.`,
153
- });
154
- },
155
- };
156
- },
157
- };
158
-
159
- // ---------------------------------------------------------------------------
160
- // no-network
161
- // ---------------------------------------------------------------------------
162
-
163
- const FORBIDDEN_CONSTRUCTORS = new Set([
164
- "XMLHttpRequest",
165
- "WebSocket",
166
- "EventSource",
167
- ]);
168
-
169
- export const noNetwork: AppletRule = {
170
- meta: {
171
- type: "problem",
172
- docs: {
173
- description:
174
- "An Applet reaches the outside world through its tools, never directly",
175
- },
176
- schema: [],
177
- },
178
- create(context) {
179
- const complain = (node: AstNode, what: string) =>
180
- context.report({
181
- node,
182
- message:
183
- `${what} is not available to an Applet: the loader runs it with no ` +
184
- "outbound network. Add a tool on the server, or use the Applet socket.",
185
- });
186
- return {
187
- CallExpression(node) {
188
- const callee = child(node, "callee");
189
- if (name(callee) === "fetch") {
190
- complain(node, "fetch()");
191
- return;
192
- }
193
- if (callee?.type !== "MemberExpression") return;
194
- const property = name(child(callee, "property"));
195
- const object = name(child(callee, "object"));
196
- if (
197
- property === "fetch" &&
198
- (object === "window" || object === "globalThis")
199
- ) {
200
- complain(node, "fetch()");
201
- }
202
- if (property === "sendBeacon" && object === "navigator") {
203
- complain(node, "navigator.sendBeacon()");
204
- }
205
- },
206
- NewExpression(node) {
207
- const callee = name(child(node, "callee"));
208
- if (callee && FORBIDDEN_CONSTRUCTORS.has(callee))
209
- complain(node, `new ${callee}`);
210
- },
211
- };
212
- },
213
- };
214
-
215
- // ---------------------------------------------------------------------------
216
- // allowed-imports
217
- // ---------------------------------------------------------------------------
218
-
219
- function importAllowed(specifier: string): boolean {
220
- if (specifier.startsWith(".")) return true;
221
- if (specifier === "react" || specifier.startsWith("react/")) return true;
222
- return (
223
- specifier === "@frockbot/applet-sdk" ||
224
- specifier.startsWith("@frockbot/applet-sdk/")
225
- );
226
- }
227
-
228
- export const allowedImports: AppletRule = {
229
- meta: {
230
- type: "problem",
231
- docs: {
232
- description:
233
- "An Applet bundles from @frockbot/applet-sdk, react, and its own files only",
234
- },
235
- schema: [],
236
- },
237
- create(context) {
238
- const check = (node: AstNode, source: AstNode | undefined) => {
239
- const specifier = source?.type === "Literal" ? source.value : undefined;
240
- if (typeof specifier !== "string" || importAllowed(specifier)) return;
241
- context.report({
242
- node,
243
- message:
244
- `"${specifier}" cannot be imported: an Applet may import ` +
245
- "@frockbot/applet-sdk/*, react, and its own relative files.",
246
- });
247
- };
248
- return {
249
- ImportDeclaration: (node) => check(node, child(node, "source")),
250
- ExportNamedDeclaration: (node) => check(node, child(node, "source")),
251
- ExportAllDeclaration: (node) => check(node, child(node, "source")),
252
- ImportExpression: (node) => check(node, child(node, "source")),
253
- CallExpression(node) {
254
- if (name(child(node, "callee")) !== "require") return;
255
- check(node, list(node, "arguments")[0]);
256
- },
257
- };
258
- },
259
- };
260
-
261
- // ---------------------------------------------------------------------------
262
- // Class-shape rules
263
- // ---------------------------------------------------------------------------
264
-
265
- function isAppletClassBody(node: AstNode): boolean {
266
- const parent = child(node, "parent");
267
- return name(child(parent, "superClass")) === "Applet";
268
- }
269
-
270
- function declaredProperty(node: AstNode, field: string): AstNode | undefined {
271
- for (const member of list(node, "body")) {
272
- if (member.type !== "PropertyDefinition") continue;
273
- if (name(child(member, "key")) === field) return member;
274
- }
275
- return undefined;
276
- }
277
-
278
- export const tablesViaTable: AppletRule = {
279
- meta: {
280
- type: "problem",
281
- docs: {
282
- description: "Tables are declared with table() so the SDK can derive DDL",
283
- },
284
- schema: [],
285
- },
286
- create(context) {
287
- // `tables = tables` naming a `const tables = { … }` is the shape the
288
- // template uses, so the rule follows one level of indirection. The lookup
289
- // happens on `Program:exit` so the declaration may come after the class.
290
- const objectsByName = new Map<string, AstNode>();
291
- const classBodies: AstNode[] = [];
292
- return {
293
- VariableDeclarator(node) {
294
- const identifier = name(child(node, "id"));
295
- const init = child(node, "init");
296
- if (identifier && init?.type === "ObjectExpression") {
297
- objectsByName.set(identifier, init);
298
- }
299
- },
300
- ClassBody(node) {
301
- if (isAppletClassBody(node)) classBodies.push(node);
302
- },
303
- "Program:exit"() {
304
- for (const body of classBodies) {
305
- const property = declaredProperty(body, "tables");
306
- if (!property) continue;
307
- const declared = child(property, "value");
308
- const value =
309
- declared?.type === "Identifier"
310
- ? objectsByName.get(name(declared) ?? "")
311
- : declared;
312
- if (value?.type !== "ObjectExpression") {
313
- context.report({
314
- node: property,
315
- message:
316
- "`tables` must be an object literal of table({ … }) declarations.",
317
- });
318
- continue;
319
- }
320
- for (const entry of list(value, "properties")) {
321
- const declaration = child(entry, "value");
322
- if (
323
- declaration?.type === "CallExpression" &&
324
- name(child(declaration, "callee")) === "table"
325
- ) {
326
- continue;
327
- }
328
- context.report({
329
- node: entry,
330
- message:
331
- "Each table must be declared with table({ … }) from the SDK.",
332
- });
333
- }
334
- }
335
- },
336
- };
337
- },
338
- };
339
-
340
- export const toolsViaThisTool: AppletRule = {
341
- meta: {
342
- type: "problem",
343
- docs: {
344
- description:
345
- "Tools are declared with this.tool() so health() can report them",
346
- },
347
- schema: [],
348
- },
349
- create(context) {
350
- return {
351
- ClassBody(node) {
352
- if (!isAppletClassBody(node)) return;
353
- const property = declaredProperty(node, "tools");
354
- if (!property) return;
355
- const value = child(property, "value");
356
- if (value?.type !== "ObjectExpression") {
357
- context.report({
358
- node: property,
359
- message:
360
- "`tools` must be an object literal of this.tool(…) declarations.",
361
- });
362
- return;
363
- }
364
- for (const entry of list(value, "properties")) {
365
- const declaration = child(entry, "value");
366
- const callee = child(declaration, "callee");
367
- if (
368
- declaration?.type === "CallExpression" &&
369
- callee?.type === "MemberExpression" &&
370
- child(callee, "object")?.type === "ThisExpression" &&
371
- name(child(callee, "property")) === "tool"
372
- ) {
373
- continue;
374
- }
375
- context.report({
376
- node: entry,
377
- message:
378
- "Each tool must be declared with this.tool({ … }, handler).",
379
- });
380
- }
381
- },
382
- };
383
- },
384
- };
385
-
386
- export const appletRules = {
387
- "no-raw-colors": noRawColors,
388
- "no-network": noNetwork,
389
- "allowed-imports": allowedImports,
390
- "tables-via-table": tablesViaTable,
391
- "tools-via-this-tool": toolsViaThisTool,
392
- } satisfies Record<string, AppletRule>;
393
-
394
- export type AppletRuleName = keyof typeof appletRules;
@@ -1,55 +0,0 @@
1
- /**
2
- * The only part of the Cloudflare programming model the SDK names.
3
- *
4
- * The ceiling is deliberate: an Applet is a Durable Object with alarms and
5
- * hibernating sockets, and no adapter hides that. What this file does is keep
6
- * the surface to the handful of members `server/` actually uses, so an Applet
7
- * author never types a binding name and the SDK type-checks without
8
- * `@cloudflare/workers-types` in scope. It lives outside `src/` so a
9
- * consumer that already has the real Workers types cannot see two declarations
10
- * of the same module.
11
- */
12
-
13
- declare module "cloudflare:workers" {
14
- export interface AppletSqlCursor {
15
- toArray(): Array<Record<string, unknown>>;
16
- }
17
-
18
- export interface AppletSqlStorageHandle {
19
- exec(query: string, ...bindings: unknown[]): AppletSqlCursor;
20
- }
21
-
22
- export interface AppletDurableObjectStorage {
23
- readonly sql: AppletSqlStorageHandle;
24
- transactionSync<T>(closure: () => T): T;
25
- deleteAll(): Promise<void>;
26
- }
27
-
28
- export interface AppletHibernatableWebSocket {
29
- send(message: string): void;
30
- close(code?: number, reason?: string): void;
31
- serializeAttachment(value: unknown): void;
32
- deserializeAttachment(): unknown;
33
- }
34
-
35
- export interface AppletDurableObjectState {
36
- readonly id: { toString(): string; readonly name?: string };
37
- readonly storage: AppletDurableObjectStorage;
38
- acceptWebSocket(socket: AppletHibernatableWebSocket, tags?: string[]): void;
39
- getWebSockets(tag?: string): AppletHibernatableWebSocket[];
40
- blockConcurrencyWhile<T>(closure: () => Promise<T>): Promise<T>;
41
- }
42
-
43
- export class DurableObject<Env = unknown> {
44
- constructor(ctx: AppletDurableObjectState, env: Env);
45
- protected readonly ctx: AppletDurableObjectState;
46
- protected readonly env: Env;
47
- }
48
- }
49
-
50
- declare const WebSocketPair: {
51
- new (): {
52
- 0: import("cloudflare:workers").AppletHibernatableWebSocket;
53
- 1: import("cloudflare:workers").AppletHibernatableWebSocket;
54
- };
55
- };