@frockbot/applet-sdk 0.7.179 → 0.7.180
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/package.json +1 -1
- package/src/build/plugin.ts +52 -19
- package/src/build/boot.ts +0 -75
- package/src/build/runtime.ts +0 -132
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frockbot/applet-sdk",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.180",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "Authoring SDK for FrockBot Applets: schema-first Durable Object server, TanStack DB client, component kit, linter, and the build pipeline.",
|
package/src/build/plugin.ts
CHANGED
|
@@ -28,11 +28,42 @@ import { convertV4MiniflareOptions, Miniflare } from "miniflare";
|
|
|
28
28
|
import ts from "typescript";
|
|
29
29
|
|
|
30
30
|
import type { AppletDiagnostic } from "../lint/index.js";
|
|
31
|
-
import { bootedWithin, withOneMoreBoot } from "./boot.js";
|
|
32
31
|
import { stableModulePaths } from "./module-paths.js";
|
|
33
|
-
import { APPLET_COMPATIBILITY_DATE } from "./runtime.js";
|
|
34
32
|
import { SDK_PLUGIN_TYPES } from "./paths.js";
|
|
35
33
|
|
|
34
|
+
/** Pinned with the SDK: the runtime a Plugin build is checked against. */
|
|
35
|
+
const PLUGIN_COMPATIBILITY_DATE = "2026-08-27";
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* A workerd boot takes well under a second; this is room for a loaded
|
|
39
|
+
* machine. Without it a runtime that never reported ready would hang the
|
|
40
|
+
* build container's request rather than answer.
|
|
41
|
+
*/
|
|
42
|
+
const BOOT_DEADLINE_MS = 30_000;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Tearing a runtime down takes milliseconds, but Miniflare's dispose first
|
|
46
|
+
* waits out the startup, which can be the very thing that hung.
|
|
47
|
+
*/
|
|
48
|
+
const DISPOSE_DEADLINE_MS = 10_000;
|
|
49
|
+
|
|
50
|
+
/** `work`, or an error naming what did not happen in time. */
|
|
51
|
+
async function within<T>(
|
|
52
|
+
work: Promise<T>,
|
|
53
|
+
ms: number,
|
|
54
|
+
what: string,
|
|
55
|
+
): Promise<T> {
|
|
56
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
57
|
+
const deadline = new Promise<never>((_, reject) => {
|
|
58
|
+
timer = setTimeout(() => reject(new Error(`${what} within ${ms}ms`)), ms);
|
|
59
|
+
});
|
|
60
|
+
try {
|
|
61
|
+
return await Promise.race([work, deadline]);
|
|
62
|
+
} finally {
|
|
63
|
+
clearTimeout(timer);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
36
67
|
export type PluginBuildStage =
|
|
37
68
|
"descriptor" | "typecheck" | "bundle" | "describe";
|
|
38
69
|
|
|
@@ -347,17 +378,11 @@ export default {
|
|
|
347
378
|
`;
|
|
348
379
|
|
|
349
380
|
/**
|
|
350
|
-
* Ask the built module what it exports, by running it.
|
|
351
|
-
*
|
|
352
|
-
*
|
|
381
|
+
* Ask the built module what it exports, by running it. Both the boot and the
|
|
382
|
+
* teardown are bounded, so a build answers rather than hanging on a runtime
|
|
383
|
+
* that never came up.
|
|
353
384
|
*/
|
|
354
|
-
export function describePlugin(
|
|
355
|
-
moduleCode: string,
|
|
356
|
-
): Promise<PluginDescriptionV1> {
|
|
357
|
-
return withOneMoreBoot(() => describeInWorkerd(moduleCode));
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
async function describeInWorkerd(
|
|
385
|
+
export async function describePlugin(
|
|
361
386
|
moduleCode: string,
|
|
362
387
|
): Promise<PluginDescriptionV1> {
|
|
363
388
|
const miniflare = new Miniflare(
|
|
@@ -367,7 +392,7 @@ async function describeInWorkerd(
|
|
|
367
392
|
{ type: "ESModule", path: "/plugin.js", contents: moduleCode },
|
|
368
393
|
],
|
|
369
394
|
modulesRoot: "/",
|
|
370
|
-
compatibilityDate:
|
|
395
|
+
compatibilityDate: PLUGIN_COMPATIBILITY_DATE,
|
|
371
396
|
// Import-time code runs with no way out: every fetch is answered here.
|
|
372
397
|
outboundService: async () =>
|
|
373
398
|
new Response("the build describes a Plugin without a network", {
|
|
@@ -377,10 +402,12 @@ async function describeInWorkerd(
|
|
|
377
402
|
port: 0,
|
|
378
403
|
}),
|
|
379
404
|
);
|
|
380
|
-
let started = false;
|
|
381
405
|
try {
|
|
382
|
-
const url = await
|
|
383
|
-
|
|
406
|
+
const url = await within(
|
|
407
|
+
miniflare.ready,
|
|
408
|
+
BOOT_DEADLINE_MS,
|
|
409
|
+
"The Workers runtime did not start",
|
|
410
|
+
);
|
|
384
411
|
const response = (await miniflare.dispatchFetch(
|
|
385
412
|
new URL(`/describe?${randomUUID()}`, url).toString(),
|
|
386
413
|
)) as unknown as Response;
|
|
@@ -392,9 +419,15 @@ async function describeInWorkerd(
|
|
|
392
419
|
}
|
|
393
420
|
return validateDescription(body.description);
|
|
394
421
|
} finally {
|
|
395
|
-
//
|
|
396
|
-
|
|
397
|
-
|
|
422
|
+
// Awaited on every path, a boot that missed its deadline included: this
|
|
423
|
+
// is what kills workerd. Miniflare's fallback is a process exit hook,
|
|
424
|
+
// `bun test` runs none, and a dispose still pending when the host exits
|
|
425
|
+
// leaves workerd running.
|
|
426
|
+
await within(
|
|
427
|
+
miniflare.dispose(),
|
|
428
|
+
DISPOSE_DEADLINE_MS,
|
|
429
|
+
"The Workers runtime did not stop",
|
|
430
|
+
);
|
|
398
431
|
}
|
|
399
432
|
}
|
|
400
433
|
|
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
|
-
}
|