@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 +5 -5
- package/package.json +4 -15
- package/plugin/index.d.ts +5 -4
- package/src/build/paths.ts +8 -66
- package/src/build/plugin.ts +64 -24
- package/src/build/boot.ts +0 -75
- package/src/build/runtime.ts +0 -132
- package/src/lint/index.ts +0 -148
- package/src/lint/rules.ts +0 -394
- package/types/cloudflare-workers.d.ts +0 -55
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
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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.
|
|
3
|
+
"version": "0.7.181",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
|
-
"description": "Authoring SDK for FrockBot
|
|
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
|
|
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
|
-
"
|
|
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
|
|
7
|
-
* kernel's generated index
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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
|
package/src/build/paths.ts
CHANGED
|
@@ -1,28 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Where the SDK is on disk,
|
|
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
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
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
|
|
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
|
-
}
|
package/src/build/plugin.ts
CHANGED
|
@@ -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:
|
|
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"):
|
|
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<
|
|
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.
|
|
351
|
-
*
|
|
352
|
-
*
|
|
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:
|
|
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
|
|
383
|
-
|
|
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
|
-
//
|
|
396
|
-
|
|
397
|
-
|
|
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
|
-
}
|
package/src/build/runtime.ts
DELETED
|
@@ -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
|
-
};
|