@frockbot/applet-sdk 0.7.244 → 0.7.246

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.
@@ -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.244",
3
+ "version": "0.7.246",
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": {
@@ -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");
@@ -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
- | { status: "built"; manifest: PluginBuildManifestV1; module: string }
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
- return { id };
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(directory: string): Promise<string[]> {
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
- const files = await pluginSources(root);
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
  }