@frockbot/applet-sdk 0.7.245 → 0.7.247
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/module/index.d.ts +57 -0
- package/package.json +3 -1
- package/src/build/paths.ts +3 -0
- package/src/build/plugin.ts +208 -5
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@frockbot/applet-sdk/module`: what a Plugin's device module is written
|
|
3
|
+
* against (ADR 0037).
|
|
4
|
+
*
|
|
5
|
+
* A module is `modules/<id>.ts` in the Plugin's source. The desktop runs it in
|
|
6
|
+
* its own Deno process, which can read only the paths its descriptor's `read`
|
|
7
|
+
* names and reach only the addresses its `net` names. It may use Node's
|
|
8
|
+
* built-in modules (`node:fs`, `node:sqlite`, …), `fetch` and `WebSocket` like
|
|
9
|
+
* any other program, within those limits.
|
|
10
|
+
*
|
|
11
|
+
* A module exports its `calls`, one function for each call the descriptor
|
|
12
|
+
* declares, and may export `start`, which runs for as long as the app does and
|
|
13
|
+
* is where a module holds a connection and emits events.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** One event the module sends to the cloud, to fire the Plugin's triggers. */
|
|
17
|
+
export interface ModuleEmitOptions {
|
|
18
|
+
/**
|
|
19
|
+
* The source's own id for this occurrence. A replay under the same key is
|
|
20
|
+
* the same event, so a reconnect never fires a Routine twice.
|
|
21
|
+
*/
|
|
22
|
+
key: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** What the host hands a module. */
|
|
26
|
+
export interface ModuleContext {
|
|
27
|
+
/** Sends one event to the cloud. `event` must be one the descriptor names. */
|
|
28
|
+
emit(
|
|
29
|
+
event: string,
|
|
30
|
+
payload: unknown,
|
|
31
|
+
options: ModuleEmitOptions,
|
|
32
|
+
): Promise<void>;
|
|
33
|
+
/** The last key the cloud acknowledged for `event`, to catch up from. */
|
|
34
|
+
lastKey(event: string): Promise<string | undefined>;
|
|
35
|
+
/** A line the Bot reads back with `plugin_module_reports`. */
|
|
36
|
+
log(level: "log" | "error", text: string): void;
|
|
37
|
+
/** A small key-value store on this device, for the module alone. */
|
|
38
|
+
store: {
|
|
39
|
+
get(key: string): Promise<unknown>;
|
|
40
|
+
set(key: string, value: unknown): Promise<void>;
|
|
41
|
+
delete(key: string): Promise<void>;
|
|
42
|
+
};
|
|
43
|
+
/** Runs an AppleScript against an application the descriptor names. */
|
|
44
|
+
appleEvents: {
|
|
45
|
+
run(bundleId: string, script: string): Promise<string>;
|
|
46
|
+
};
|
|
47
|
+
/** Aborts when the host stops the module. */
|
|
48
|
+
signal: AbortSignal;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** One call the Plugin's cloud code may make. Its answer must be JSON. */
|
|
52
|
+
export type ModuleCall = (input: unknown, context: ModuleContext) => unknown;
|
|
53
|
+
|
|
54
|
+
export type ModuleCalls = Record<string, ModuleCall>;
|
|
55
|
+
|
|
56
|
+
/** Runs for as long as the app does; resolve or throw to stop. */
|
|
57
|
+
export type ModuleStart = (context: ModuleContext) => Promise<void> | void;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frockbot/applet-sdk",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.247",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "Authoring SDK for FrockBot Plugins: the declarations a Plugin is written against, and the build pipeline the cloud build service runs.",
|
|
@@ -8,11 +8,13 @@
|
|
|
8
8
|
"exports": {
|
|
9
9
|
"./build/plugin": "./src/build/plugin.ts",
|
|
10
10
|
"./plugin": "./plugin/index.d.ts",
|
|
11
|
+
"./module": "./module/index.d.ts",
|
|
11
12
|
"./package.json": "./package.json"
|
|
12
13
|
},
|
|
13
14
|
"files": [
|
|
14
15
|
"src",
|
|
15
16
|
"plugin",
|
|
17
|
+
"module",
|
|
16
18
|
"README.md"
|
|
17
19
|
],
|
|
18
20
|
"scripts": {
|
package/src/build/paths.ts
CHANGED
|
@@ -42,3 +42,6 @@ export const SDK_ROOT = findSdkRoot();
|
|
|
42
42
|
|
|
43
43
|
/** The Plugin declarations (`@frockbot/applet-sdk/plugin`), types only. */
|
|
44
44
|
export const SDK_PLUGIN_TYPES = join(SDK_ROOT, "plugin/index.d.ts");
|
|
45
|
+
|
|
46
|
+
/** The device module declarations (`@frockbot/applet-sdk/module`), types only. */
|
|
47
|
+
export const SDK_MODULE_TYPES = join(SDK_ROOT, "module/index.d.ts");
|
package/src/build/plugin.ts
CHANGED
|
@@ -28,7 +28,7 @@ import { convertV4MiniflareOptions, Miniflare } from "miniflare";
|
|
|
28
28
|
import ts from "typescript";
|
|
29
29
|
|
|
30
30
|
import { stableModulePaths } from "./module-paths.js";
|
|
31
|
-
import { SDK_PLUGIN_TYPES } from "./paths.js";
|
|
31
|
+
import { SDK_MODULE_TYPES, SDK_PLUGIN_TYPES, SDK_ROOT } from "./paths.js";
|
|
32
32
|
|
|
33
33
|
/** Pinned with the SDK: the runtime a Plugin build is checked against. */
|
|
34
34
|
const PLUGIN_COMPATIBILITY_DATE = "2026-08-27";
|
|
@@ -78,6 +78,15 @@ export interface PluginDiagnostic {
|
|
|
78
78
|
/** The Plugin's `plugin.json`, as far as the build reads it. */
|
|
79
79
|
export interface PluginBuildDescriptorV1 {
|
|
80
80
|
id: string;
|
|
81
|
+
/** The device modules it declares, by id (ADR 0037). */
|
|
82
|
+
modules: string[];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** One built device module: its id, what it exports as calls, and its code. */
|
|
86
|
+
export interface PluginBuiltModuleV1 {
|
|
87
|
+
id: string;
|
|
88
|
+
calls: string[];
|
|
89
|
+
code: string;
|
|
81
90
|
}
|
|
82
91
|
|
|
83
92
|
/** What the built module exports, read by running it. */
|
|
@@ -99,12 +108,19 @@ export interface PluginDescriptionV1 {
|
|
|
99
108
|
|
|
100
109
|
export interface PluginBuildManifestV1 extends PluginDescriptionV1 {
|
|
101
110
|
contract: 1;
|
|
111
|
+
/** Each device module, with the calls it exports and its hash. */
|
|
112
|
+
modules: { id: string; calls: string[]; hash: string }[];
|
|
102
113
|
hashes: { module: string };
|
|
103
114
|
}
|
|
104
115
|
|
|
105
116
|
export type PluginBuildOutcome =
|
|
106
117
|
| { status: "checked" }
|
|
107
|
-
| {
|
|
118
|
+
| {
|
|
119
|
+
status: "built";
|
|
120
|
+
manifest: PluginBuildManifestV1;
|
|
121
|
+
module: string;
|
|
122
|
+
modules: { id: string; code: string }[];
|
|
123
|
+
}
|
|
108
124
|
| {
|
|
109
125
|
status: "failed";
|
|
110
126
|
stage: PluginBuildStage;
|
|
@@ -112,7 +128,9 @@ export type PluginBuildOutcome =
|
|
|
112
128
|
};
|
|
113
129
|
|
|
114
130
|
const PLUGIN_ID = /^[a-z][a-z0-9-]{0,63}$/;
|
|
131
|
+
const MODULE_ID = /^[a-z][a-z0-9-]{0,31}$/;
|
|
115
132
|
const MAX_TOOLS = 64;
|
|
133
|
+
const MODULES_DIRECTORY = "modules";
|
|
116
134
|
|
|
117
135
|
const COMPILER_OPTIONS: ts.CompilerOptions = {
|
|
118
136
|
target: ts.ScriptTarget.ES2022,
|
|
@@ -129,6 +147,16 @@ const COMPILER_OPTIONS: ts.CompilerOptions = {
|
|
|
129
147
|
types: [],
|
|
130
148
|
};
|
|
131
149
|
|
|
150
|
+
/**
|
|
151
|
+
* A device module runs in Deno on the desktop, not in a Worker: it gets Node's
|
|
152
|
+
* built-in modules as well as the web platform, and its own declarations.
|
|
153
|
+
*/
|
|
154
|
+
const MODULE_COMPILER_OPTIONS: ts.CompilerOptions = {
|
|
155
|
+
...COMPILER_OPTIONS,
|
|
156
|
+
types: ["node"],
|
|
157
|
+
typeRoots: [join(SDK_ROOT, "node_modules/@types")],
|
|
158
|
+
};
|
|
159
|
+
|
|
132
160
|
function thrown(error: unknown, file = "plugin.json"): PluginDiagnostic[] {
|
|
133
161
|
return [
|
|
134
162
|
{
|
|
@@ -169,14 +197,30 @@ export async function readPluginDescriptor(
|
|
|
169
197
|
if (typeof id !== "string" || !PLUGIN_ID.test(id)) {
|
|
170
198
|
throw new Error('plugin.json "id" must match /^[a-z][a-z0-9-]{0,63}$/');
|
|
171
199
|
}
|
|
172
|
-
|
|
200
|
+
// Only the ids: the app decodes the rest of the declaration.
|
|
201
|
+
const declared = (parsed as { device?: { modules?: unknown } }).device
|
|
202
|
+
?.modules;
|
|
203
|
+
const modules = (Array.isArray(declared) ? declared : []).map((module) => {
|
|
204
|
+
const moduleId = (module as { id?: unknown } | null)?.id;
|
|
205
|
+
if (typeof moduleId !== "string" || !MODULE_ID.test(moduleId)) {
|
|
206
|
+
throw new Error(
|
|
207
|
+
'plugin.json "device.modules" ids must match /^[a-z][a-z0-9-]{0,31}$/',
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
return moduleId;
|
|
211
|
+
});
|
|
212
|
+
return { id, modules };
|
|
173
213
|
}
|
|
174
214
|
|
|
175
|
-
async function pluginSources(
|
|
215
|
+
async function pluginSources(
|
|
216
|
+
directory: string,
|
|
217
|
+
skip: readonly string[] = [],
|
|
218
|
+
): Promise<string[]> {
|
|
176
219
|
const found: string[] = [];
|
|
177
220
|
const walk = async (current: string): Promise<void> => {
|
|
178
221
|
for (const entry of await readdir(current, { withFileTypes: true })) {
|
|
179
222
|
if (entry.name === "node_modules" || entry.name.startsWith(".")) continue;
|
|
223
|
+
if (current === directory && skip.includes(entry.name)) continue;
|
|
180
224
|
const path = join(current, entry.name);
|
|
181
225
|
if (entry.isDirectory()) await walk(path);
|
|
182
226
|
else if (/\.ts$/.test(entry.name) && !entry.name.endsWith(".d.ts")) {
|
|
@@ -193,7 +237,8 @@ export async function typeCheckPlugin(
|
|
|
193
237
|
directory: string,
|
|
194
238
|
): Promise<PluginDiagnostic[]> {
|
|
195
239
|
const root = resolve(directory);
|
|
196
|
-
|
|
240
|
+
// A device module runs in Deno, not in the Worker, and is checked as such.
|
|
241
|
+
const files = await pluginSources(root, [MODULES_DIRECTORY]);
|
|
197
242
|
if (!files.some((file) => relative(root, file) === "plugin.ts")) {
|
|
198
243
|
return [
|
|
199
244
|
{
|
|
@@ -209,6 +254,13 @@ export async function typeCheckPlugin(
|
|
|
209
254
|
...COMPILER_OPTIONS,
|
|
210
255
|
paths: { "@frockbot/applet-sdk/plugin": [SDK_PLUGIN_TYPES] },
|
|
211
256
|
});
|
|
257
|
+
return programDiagnostics(program, root);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function programDiagnostics(
|
|
261
|
+
program: ts.Program,
|
|
262
|
+
root: string,
|
|
263
|
+
): PluginDiagnostic[] {
|
|
212
264
|
return ts
|
|
213
265
|
.getPreEmitDiagnostics(program)
|
|
214
266
|
.filter(
|
|
@@ -245,6 +297,126 @@ export async function typeCheckPlugin(
|
|
|
245
297
|
});
|
|
246
298
|
}
|
|
247
299
|
|
|
300
|
+
/**
|
|
301
|
+
* Type-check the Plugin's device modules and read the calls each exports.
|
|
302
|
+
*
|
|
303
|
+
* One program over `modules/`, with Node's declarations and the module SDK's.
|
|
304
|
+
* The calls are read from the checker, so `calls` may be built however the
|
|
305
|
+
* module likes as long as its type names them.
|
|
306
|
+
*/
|
|
307
|
+
export async function typeCheckModules(
|
|
308
|
+
directory: string,
|
|
309
|
+
moduleIds: readonly string[],
|
|
310
|
+
): Promise<
|
|
311
|
+
| { status: "ok"; calls: Map<string, string[]> }
|
|
312
|
+
| { status: "failed"; diagnostics: PluginDiagnostic[] }
|
|
313
|
+
> {
|
|
314
|
+
const root = resolve(directory);
|
|
315
|
+
const modulesRoot = join(root, MODULES_DIRECTORY);
|
|
316
|
+
const entries = moduleIds.map((id) => join(modulesRoot, `${id}.ts`));
|
|
317
|
+
let files: string[] = [];
|
|
318
|
+
try {
|
|
319
|
+
files = await pluginSources(modulesRoot);
|
|
320
|
+
} catch {
|
|
321
|
+
// No modules directory: every declared entry is missing, said below.
|
|
322
|
+
}
|
|
323
|
+
const missing = entries.filter((entry) => !files.includes(entry));
|
|
324
|
+
if (missing.length > 0) {
|
|
325
|
+
return {
|
|
326
|
+
status: "failed",
|
|
327
|
+
diagnostics: missing.map((entry) => ({
|
|
328
|
+
file: relative(root, entry),
|
|
329
|
+
line: 1,
|
|
330
|
+
column: 1,
|
|
331
|
+
message: `plugin.json declares a device module whose source ${relative(root, entry)} does not exist`,
|
|
332
|
+
severity: "error" as const,
|
|
333
|
+
})),
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
const program = ts.createProgram(files, {
|
|
337
|
+
...MODULE_COMPILER_OPTIONS,
|
|
338
|
+
paths: { "@frockbot/applet-sdk/module": [SDK_MODULE_TYPES] },
|
|
339
|
+
});
|
|
340
|
+
const diagnostics = programDiagnostics(program, root);
|
|
341
|
+
if (diagnostics.some((diagnostic) => diagnostic.severity === "error")) {
|
|
342
|
+
return { status: "failed", diagnostics };
|
|
343
|
+
}
|
|
344
|
+
const checker = program.getTypeChecker();
|
|
345
|
+
const calls = new Map<string, string[]>();
|
|
346
|
+
const problems: PluginDiagnostic[] = [];
|
|
347
|
+
for (const [index, entry] of entries.entries()) {
|
|
348
|
+
const source = program.getSourceFile(entry);
|
|
349
|
+
const symbol = source && checker.getSymbolAtLocation(source);
|
|
350
|
+
const exported = symbol
|
|
351
|
+
? checker
|
|
352
|
+
.getExportsOfModule(symbol)
|
|
353
|
+
.find((candidate) => candidate.name === "calls")
|
|
354
|
+
: undefined;
|
|
355
|
+
if (!source || !exported) {
|
|
356
|
+
problems.push({
|
|
357
|
+
file: relative(root, entry),
|
|
358
|
+
line: 1,
|
|
359
|
+
column: 1,
|
|
360
|
+
message: 'a device module must export "calls"',
|
|
361
|
+
severity: "error",
|
|
362
|
+
});
|
|
363
|
+
continue;
|
|
364
|
+
}
|
|
365
|
+
const type = checker.getTypeOfSymbolAtLocation(exported, source);
|
|
366
|
+
calls.set(
|
|
367
|
+
moduleIds[index]!,
|
|
368
|
+
checker.getPropertiesOfType(type).map((property) => property.name),
|
|
369
|
+
);
|
|
370
|
+
}
|
|
371
|
+
return problems.length > 0
|
|
372
|
+
? { status: "failed", diagnostics: problems }
|
|
373
|
+
: { status: "ok", calls };
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* One ES module per device module, for Deno. Node's built-ins stay imports,
|
|
378
|
+
* resolved by the runtime; everything else is inlined, so the module needs
|
|
379
|
+
* nothing fetched at start.
|
|
380
|
+
*/
|
|
381
|
+
export async function bundleModule(
|
|
382
|
+
directory: string,
|
|
383
|
+
moduleId: string,
|
|
384
|
+
): Promise<string> {
|
|
385
|
+
const result = await esbuild({
|
|
386
|
+
entryPoints: [join(directory, MODULES_DIRECTORY, `${moduleId}.ts`)],
|
|
387
|
+
bundle: true,
|
|
388
|
+
write: false,
|
|
389
|
+
format: "esm",
|
|
390
|
+
platform: "node",
|
|
391
|
+
target: "es2022",
|
|
392
|
+
minify: false,
|
|
393
|
+
legalComments: "none",
|
|
394
|
+
external: ["node:*"],
|
|
395
|
+
plugins: [
|
|
396
|
+
{
|
|
397
|
+
name: "module-sdk-is-types-only",
|
|
398
|
+
setup(build) {
|
|
399
|
+
build.onResolve(
|
|
400
|
+
{ filter: /^@frockbot\/applet-sdk\/module$/ },
|
|
401
|
+
() => ({
|
|
402
|
+
errors: [
|
|
403
|
+
{
|
|
404
|
+
text: '"@frockbot/applet-sdk/module" is types only; import it with `import type`.',
|
|
405
|
+
},
|
|
406
|
+
],
|
|
407
|
+
}),
|
|
408
|
+
);
|
|
409
|
+
},
|
|
410
|
+
},
|
|
411
|
+
],
|
|
412
|
+
metafile: true,
|
|
413
|
+
logLevel: "silent",
|
|
414
|
+
});
|
|
415
|
+
const file = result.outputFiles?.[0];
|
|
416
|
+
if (!file) throw new Error("The bundler produced no output");
|
|
417
|
+
return stableModulePaths(file.text, result.metafile, directory);
|
|
418
|
+
}
|
|
419
|
+
|
|
248
420
|
/**
|
|
249
421
|
* One ESM module, every import inlined. `@frockbot/applet-sdk/plugin` is
|
|
250
422
|
* types only, so a value import of it is the one specifier that can never
|
|
@@ -498,6 +670,18 @@ export async function runPluginBuildV1(
|
|
|
498
670
|
if (types.some((diagnostic) => diagnostic.severity === "error")) {
|
|
499
671
|
return { status: "failed", stage: "typecheck", diagnostics: types };
|
|
500
672
|
}
|
|
673
|
+
let moduleCalls = new Map<string, string[]>();
|
|
674
|
+
if (descriptor.modules.length > 0) {
|
|
675
|
+
const checked = await typeCheckModules(directory, descriptor.modules);
|
|
676
|
+
if (checked.status === "failed") {
|
|
677
|
+
return {
|
|
678
|
+
status: "failed",
|
|
679
|
+
stage: "typecheck",
|
|
680
|
+
diagnostics: checked.diagnostics,
|
|
681
|
+
};
|
|
682
|
+
}
|
|
683
|
+
moduleCalls = checked.calls;
|
|
684
|
+
}
|
|
501
685
|
if (options.mode === "check") return { status: "checked" };
|
|
502
686
|
|
|
503
687
|
let moduleCode: string;
|
|
@@ -511,6 +695,19 @@ export async function runPluginBuildV1(
|
|
|
511
695
|
};
|
|
512
696
|
}
|
|
513
697
|
|
|
698
|
+
const modules: { id: string; code: string }[] = [];
|
|
699
|
+
for (const id of descriptor.modules) {
|
|
700
|
+
try {
|
|
701
|
+
modules.push({ id, code: await bundleModule(directory, id) });
|
|
702
|
+
} catch (error) {
|
|
703
|
+
return {
|
|
704
|
+
status: "failed",
|
|
705
|
+
stage: "bundle",
|
|
706
|
+
diagnostics: thrown(error, `${MODULES_DIRECTORY}/${id}.ts`),
|
|
707
|
+
};
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
|
|
514
711
|
let description: PluginDescriptionV1;
|
|
515
712
|
try {
|
|
516
713
|
description = await describePlugin(moduleCode);
|
|
@@ -526,8 +723,14 @@ export async function runPluginBuildV1(
|
|
|
526
723
|
manifest: {
|
|
527
724
|
contract: 1,
|
|
528
725
|
...description,
|
|
726
|
+
modules: modules.map((module) => ({
|
|
727
|
+
id: module.id,
|
|
728
|
+
calls: moduleCalls.get(module.id) ?? [],
|
|
729
|
+
hash: sha256(module.code),
|
|
730
|
+
})),
|
|
529
731
|
hashes: { module: sha256(moduleCode) },
|
|
530
732
|
},
|
|
531
733
|
module: moduleCode,
|
|
734
|
+
modules,
|
|
532
735
|
};
|
|
533
736
|
}
|