@worker-protocol/hono 0.1.0

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/dist/mount.js ADDED
@@ -0,0 +1,432 @@
1
+ import { OpenAPIHono } from "@hono/zod-openapi";
2
+ import { EDITION, nudge } from "@worker-protocol/schemas";
3
+ import { actions as actionsSurface, IN_MEMORY, jsonSchema } from "./actions.js";
4
+ import { byCode } from "./codes.js";
5
+ import { collection, serializeSince } from "./collection.js";
6
+ import { metrics as metricsSurface } from "./metrics.js";
7
+ import { performAction, pollHealth, readActivity, readAlerts, readDescriptor, readMetric, readTasks, SURFACES, takeNudge, } from "./surfaces.js";
8
+ import { tasks as taskSurface } from "./tasks.js";
9
+ /**
10
+ * Write the builder `mount()` takes, and have a mistake reported where you made it.
11
+ *
12
+ * It returns its argument and does nothing at all at runtime. What it does is at the type level: a
13
+ * builder declared apart from the `mount()` call has nothing to check its object literal against,
14
+ * so the first complaint arrives at `mount()`, about a type nested six levels deep, naming a
15
+ * property three files away. Through this, a missing `since` is reported on `since`. An `async`
16
+ * builder is the same call, because `Worker | Promise<Worker>` is written here once instead of at
17
+ * every Worker that happens to await something.
18
+ *
19
+ * ```ts
20
+ * export const fleetWorker = defineWorker<Env>((env) => ({ id: "…", health: () => … }))
21
+ * export default { fetch: mount(fleetWorker).fetch }
22
+ * ```
23
+ *
24
+ * **No rule id can be cited for this line, and that is worth saying rather than dressing up.**
25
+ * `packages/README.md` asks that of everything in this package, and an identity function that
26
+ * exists to please a compiler answers nothing. It stays because the alternative — knowing that
27
+ * `WorkerBuilder` exists before you can annotate with it — is friction paid by every author, and
28
+ * `defineConfig`, `defineComponent` and `defineStore` have made this shape one a reader does not
29
+ * have to be taught. The type above is exported too, for a builder that was already written.
30
+ */
31
+ export const defineWorker = (build) => build;
32
+ const JSON_UTF8 = { "content-type": "application/json; charset=utf-8" };
33
+ /** ENDP-25, ENDP-26: the envelope, with the status and class the code fixes and nothing chosen. */
34
+ const envelope = ({ code, message }) => {
35
+ const row = byCode.get(code);
36
+ return new Response(JSON.stringify({ code, message, class: row?.class ?? "reject" }), {
37
+ status: row?.status ?? 400,
38
+ headers: JSON_UTF8,
39
+ });
40
+ };
41
+ const refused = (answer) => "code" in answer;
42
+ /** What a write answered, as a response: a refusal enveloped, a body as JSON, or no body at all. */
43
+ const reply = (c, answer) => {
44
+ if (refused(answer))
45
+ return envelope(answer);
46
+ if (answer.body === null)
47
+ return c.body(null, answer.status);
48
+ return c.json(answer.body, answer.status);
49
+ };
50
+ /** What a read answered: a refusal enveloped, or the page as `200`. */
51
+ const page = (c, answer) => refused(answer) ? envelope(answer) : c.json(answer, 200);
52
+ const query = (c) => new URL(c.req.url).searchParams;
53
+ const bearer = (c) => {
54
+ const presented = c.req.header("authorization");
55
+ return presented?.startsWith("Bearer ") ? presented.slice("Bearer ".length) : undefined;
56
+ };
57
+ /** A parameter the route's declaration refuses is a parameter fault, and nothing was done. */
58
+ const defaultHook = (result) => result.success
59
+ ? undefined
60
+ : envelope({ code: "invalid_parameter", message: "A parameter is missing or malformed." });
61
+ // ENDP-24: an unrecognized filter parameter is `400` and is never ignored. A filter dropped in
62
+ // silence answers with MORE than the caller asked for, in a shape it will happily parse. The
63
+ // surfaces with parameters of their own check them where they know what they mean; the rest take
64
+ // none, so anything at all is a parameter they cannot know.
65
+ const noParameters = async (c, next) => {
66
+ const [first] = Object.keys(c.req.query());
67
+ if (first !== undefined) {
68
+ return envelope({
69
+ code: "unknown_filter",
70
+ message: `This address takes no parameter named ${first}.`,
71
+ });
72
+ }
73
+ await next();
74
+ };
75
+ /**
76
+ * One Worker's surfaces.
77
+ *
78
+ * **Nothing here holds state across requests any more, and that is deliberate.** Everything a rule
79
+ * needs to outlive one call — which today is ENDP-16's recorded outcomes and nothing else — is a
80
+ * store the Worker names, because `mount()` may answer a different `Worker` object on every
81
+ * request and a copy held here would start again with each one. That was the shape of three
82
+ * separate bugs before it was a principle.
83
+ */
84
+ function surfacesOf(worker) {
85
+ return {
86
+ tasks: worker.tasks ? taskSurface(worker.tasks.raises, worker.tasks) : undefined,
87
+ actions: worker.actions ? actionsSurface(worker.actions) : undefined,
88
+ metrics: worker.metrics ? metricsSurface(worker.metrics) : undefined,
89
+ };
90
+ }
91
+ /**
92
+ * Two ways a Worker fails ENDP-16 without a single request going wrong.
93
+ *
94
+ * Neither is a conformance failure anything could find: a verifier sees two `200`s and has no way
95
+ * to know the Action ran twice. So they are found here, and said where somebody can act on them —
96
+ * thrown before a request exists where that is possible, and written to the log where it is not.
97
+ */
98
+ const complainer = () => {
99
+ let complained = false;
100
+ return (fault) => {
101
+ if (fault === null || complained)
102
+ return;
103
+ complained = true;
104
+ console.error(fault);
105
+ };
106
+ };
107
+ /**
108
+ * A Worker that declares an idempotency key and names nowhere to record the outcome.
109
+ *
110
+ * It cannot keep the promise the key is for: every repeat performs the Action again, and answers
111
+ * as though it had not, which is the whole of what ENDP-16 exists to prevent.
112
+ */
113
+ function unrecorded(worker) {
114
+ if (worker.actions === undefined || worker.actions.outcomes !== undefined)
115
+ return null;
116
+ const keyed = Object.entries(worker.actions.accepts)
117
+ .filter(([, action]) => action.idempotency !== undefined)
118
+ .map(([name]) => name);
119
+ if (keyed.length === 0)
120
+ return null;
121
+ return (`ENDP-16: ${keyed.join(", ")} declare${keyed.length === 1 ? "s" : ""} an idempotency key and ` +
122
+ "this Worker names nowhere to record an outcome. Set `actions.outcomes`: `memoryOutcomes()` " +
123
+ "in a single long-lived process, or a store over a durable object, a KV namespace or a " +
124
+ "table anywhere that runs more than one. A Map per isolate performs the Action twice under " +
125
+ "one key while the caller believes it is protected, and both calls answer 200.");
126
+ }
127
+ /**
128
+ * A Worker that builds its store INSIDE the function answering it, so there is a new one per
129
+ * request and each forgets what the last recorded — the same failure, reached by obeying the rule
130
+ * above. Only a memory store is compared: a Worker that builds a thin adapter per request over a
131
+ * durable backend is correct, and comparing identity alone would fail it.
132
+ */
133
+ function forgetful(first, now) {
134
+ if (first === undefined || now === undefined || first === now)
135
+ return null;
136
+ if (first[IN_MEMORY] !== true || now[IN_MEMORY] !== true)
137
+ return null;
138
+ return ("ENDP-16: this Worker answered a second `memoryOutcomes()`, which means one is built on every " +
139
+ "request and forgets what the last recorded. Build it once, outside the function that answers " +
140
+ "the Worker, and close over it — or name a store that outlives the process.");
141
+ }
142
+ /** Every value of a record, mapped. Three Capabilities declare a map of Zod objects to convert. */
143
+ const mapValues = (record, each) => Object.fromEntries(Object.entries(record).map(([key, value]) => [key, each(value)]));
144
+ /** DESC-1. The Descriptor, derived from what the Worker implements and nothing else. */
145
+ function descriptorOf(worker, edition) {
146
+ const capabilities = {};
147
+ // The Capabilities whose entry is the shared one and an address: what a Worker declares about
148
+ // them is that it implements them. The rest add something of their own below.
149
+ for (const name of ["health", "alerts", "activity", "nudges"]) {
150
+ if (worker[name])
151
+ capabilities[name] = { version: 1, address: `../${name}` };
152
+ }
153
+ if (worker.metrics) {
154
+ const { read, pageSize, ...declared } = worker.metrics;
155
+ capabilities.metrics = { version: 1, address: "../metrics", ...declared };
156
+ }
157
+ if (worker.actions) {
158
+ // ACT-2, ACT-3: the Descriptor carries JSON Schema, generated from the Zod object the Worker
159
+ // declared — one declaration, so the form a console renders and the validation a request meets
160
+ // are the same document and cannot drift.
161
+ const declared = {};
162
+ for (const [name, action] of Object.entries(worker.actions.accepts)) {
163
+ declared[name] = {
164
+ input: jsonSchema(action.input),
165
+ ...(action.result === undefined ? {} : { result: jsonSchema(action.result) }),
166
+ completesWithinCall: action.completesWithinCall ?? true,
167
+ ...(action.idempotency === undefined ? {} : { idempotency: action.idempotency }),
168
+ // ACT-15: where the Worker exposes its settings, the reading address is this app's to fix,
169
+ // because this app serves it — written into the declaration so the two cannot disagree.
170
+ ...(name === "configure" && worker.actions.settings ? { readAddress: "../settings" } : {}),
171
+ };
172
+ }
173
+ capabilities.actions = { version: 1, address: "../actions", accepts: declared };
174
+ }
175
+ // EVT-12, TASK-32, TASK-31: the Descriptor carries JSON Schema, generated from the Zod object the
176
+ // Worker declared — the same move ACT-2 makes, so a Worker writes one declaration and never two.
177
+ if (worker.events) {
178
+ capabilities.events = {
179
+ version: 1,
180
+ ...worker.events,
181
+ publishes: mapValues(worker.events.publishes, (declared) => ({
182
+ ...declared,
183
+ data: jsonSchema(declared.data),
184
+ })),
185
+ };
186
+ }
187
+ if (worker.tasks) {
188
+ capabilities.tasks = {
189
+ version: 1,
190
+ address: "../tasks",
191
+ raises: mapValues(worker.tasks.raises, (declared) => ({
192
+ payload: jsonSchema(declared.payload),
193
+ answeredBy: declared.answeredBy,
194
+ })),
195
+ };
196
+ }
197
+ const document = { id: worker.id, edition };
198
+ // TASK-31: at the root, beside the id, and omitted by a Worker with no Skill — which is what
199
+ // DESC-2 lets a Worker do with anything it does not implement. A Skill that states no
200
+ // requirement travels as `{}`: the claim, and nothing about what it needs.
201
+ if (worker.skills !== undefined) {
202
+ document.skills = mapValues(worker.skills, (skill) => ({
203
+ ...(skill.payload === undefined ? {} : { payload: jsonSchema(skill.payload) }),
204
+ ...(skill.produces === undefined ? {} : { produces: jsonSchema(skill.produces) }),
205
+ }));
206
+ }
207
+ document.capabilities = capabilities;
208
+ return JSON.stringify(document);
209
+ }
210
+ export function mount(source) {
211
+ /**
212
+ * What the runtime gave this request, where it gave one.
213
+ *
214
+ * Hono's accessor THROWS rather than answering `undefined` when there is none, and there is none
215
+ * on Node, on Bun, and on any `app.fetch(request, env)` called with two arguments — which is to
216
+ * say in most of this repository's own tests. A Worker that never uses it must not fail because
217
+ * of which runtime it woke up in.
218
+ */
219
+ const executionCtx = (c) => {
220
+ try {
221
+ return c.executionCtx;
222
+ }
223
+ catch {
224
+ return {};
225
+ }
226
+ };
227
+ /** The Worker for the request in hand. A static one is answered without being asked again. */
228
+ const resolve = typeof source === "function"
229
+ ? (c) => source(c.env, executionCtx(c))
230
+ : () => source;
231
+ /**
232
+ * ENDP-16's check, run once against the first Worker this app sees.
233
+ *
234
+ * Once because the fault it looks for is a mistake in how the Worker was WRITTEN, not in any one
235
+ * request: an author who named no store for a keyed Action named none on the first request
236
+ * either. Re-checking every request would buy nothing and would repeat the same message forever.
237
+ * A Worker handed in whole is checked before a request has arrived at all, which is where it
238
+ * does the most good; a builder cannot be, so the first one it answers is the first chance.
239
+ */
240
+ let checked = false;
241
+ let firstStore;
242
+ // Once per app and not once per process: two Workers mounted in one process are two Workers,
243
+ // and a fault in the second is not said by the first having said its own.
244
+ const warn = complainer();
245
+ const audited = async (c) => {
246
+ const worker = await resolve(c);
247
+ if (!checked) {
248
+ checked = true;
249
+ warn(unrecorded(worker));
250
+ firstStore = worker.actions?.outcomes;
251
+ }
252
+ else {
253
+ warn(forgetful(firstStore, worker.actions?.outcomes));
254
+ }
255
+ return worker;
256
+ };
257
+ // A Worker handed in whole is a shape this app can read before a request exists, so the fault is
258
+ // refused where somebody is still looking at it. A Worker answered per request has no such
259
+ // moment — so the same fault is said loudly on the first one, and not thrown: ENDP-11 forbids a
260
+ // `5xx` for a condition that will not change, and this one never will.
261
+ if (typeof source !== "function") {
262
+ checked = true;
263
+ const fault = unrecorded(source);
264
+ if (fault !== null)
265
+ throw new Error(fault);
266
+ firstStore = source.actions?.outcomes;
267
+ }
268
+ /**
269
+ * Which Capabilities this app registers a route for, or `null` when it cannot know yet.
270
+ *
271
+ * A Worker handed in whole is a shape this app can read at mount, so it registers exactly the
272
+ * addresses that Worker declares and its own OpenAPI document describes that Worker. A Worker
273
+ * answered per request is not knowable before one arrives, so every route is registered and the
274
+ * handler answers `404` for a Capability the resolved Worker does not declare. The document then
275
+ * describes the protocol's whole surface rather than this Worker's — which costs nothing on the
276
+ * wire, because ENDP-1 has every reader start from the Descriptor and nothing there is a path
277
+ * anybody assembles.
278
+ */
279
+ const declared = typeof source === "function"
280
+ ? null
281
+ : new Set(SURFACES.map((surface) => surface.capability)
282
+ .filter((name) => name !== "descriptor")
283
+ .filter((name) => source[name] !== undefined));
284
+ // DESC-23. Read from the static Worker where there is one, so the header is right before any
285
+ // request has arrived; a resolved Worker that declares another edition overrides it below.
286
+ const staticEdition = typeof source === "function" ? EDITION : (source.edition ?? EDITION);
287
+ const app = new OpenAPIHono({ defaultHook });
288
+ // ENDP-5, outermost, so that a refusal from any guard below carries the headers too. A caller
289
+ // reading a version it did not expect re-reads the Descriptor whatever the status was.
290
+ app.use(async (c, next) => {
291
+ await next();
292
+ if (!c.res.headers.has("worker-protocol-edition")) {
293
+ c.res.headers.set("worker-protocol-edition", staticEdition);
294
+ }
295
+ c.res.headers.set("worker-protocol-capability-version", "1");
296
+ });
297
+ // REG-7 and its converse: an address this Worker genuinely does not serve is `404`, and an
298
+ // address it does serve is never `404` in place of `401` — the guard below is registered on
299
+ // every path this app serves, so a credential is looked at exactly where a route answers.
300
+ app.notFound(() => envelope({ code: "not_found", message: "No such address." }));
301
+ const guard = async (c, next) => {
302
+ const worker = await audited(c);
303
+ // REG-21: the Worker accepts the credential recorded for it on every address this protocol
304
+ // defines, the Descriptor's route included. What makes one good is the Worker's (REG-3).
305
+ // REG-32: the refusal distinguishes nothing — a refusal that explains itself is an oracle.
306
+ const verdict = (await worker.authenticate?.(bearer(c))) ?? "accepted";
307
+ if (verdict !== "accepted")
308
+ return envelope({ code: verdict, message: "No." });
309
+ // ENDP-6: a caller may state the Capability version it expects, and a Worker that cannot
310
+ // answer that version refuses the request WHOLE rather than substituting its own. On every
311
+ // route, reads and writes alike, because ENDP-6 says `on a request` and a write is a request.
312
+ const asked = c.req.header("worker-protocol-capability-version");
313
+ if (asked !== undefined && asked !== "1") {
314
+ return envelope({ code: "unsupported_version", message: "This Worker answers version 1." });
315
+ }
316
+ c.res.headers.set("worker-protocol-edition", worker.edition ?? EDITION);
317
+ await next();
318
+ };
319
+ /**
320
+ * One surface, at the path this app serves it.
321
+ *
322
+ * **Every route is registered whether or not this Worker declares its Capability**, and the
323
+ * handler answers `404` when it does not — because with a Worker resolved per request there is
324
+ * no Worker at mount time to ask. That is the same answer an undeclared address gives today:
325
+ * DESC-2 admits any combination of Capabilities including none, and nothing a Descriptor does
326
+ * not declare is an address any reader of this protocol calls (ENDP-1).
327
+ */
328
+ const serve = (capability, path, route, handler, ownParameters = false) => {
329
+ if (capability !== null && declared !== null && !declared.has(capability))
330
+ return;
331
+ app.use(path, guard);
332
+ if (!ownParameters)
333
+ app.use(path, noParameters);
334
+ app.openapi({ ...route, path }, (async (c) => {
335
+ const worker = await audited(c);
336
+ return handler(c, worker);
337
+ }));
338
+ };
339
+ const undeclared = (capability) => envelope({ code: "not_found", message: `This Worker declares no \`${capability}\`.` });
340
+ /**
341
+ * DESC-1's document, built once where it can be.
342
+ *
343
+ * A Worker handed in whole is one object that nothing here can change, so building its Descriptor
344
+ * a second time could only produce the same bytes. A Worker answered per request is the case
345
+ * `WorkerSource` argues for: what it DECLARES may legitimately differ between two requests
346
+ * because its configuration may have, so that one is built each time and a cache would serve a
347
+ * document the Worker has stopped meaning. Nothing in `spec/` bears on this either way — REG-8
348
+ * is about two CALLERS seeing one document and says nothing about two moments.
349
+ */
350
+ const staticDescriptor = typeof source === "function" ? null : descriptorOf(source, source.edition ?? EDITION);
351
+ serve(null, readDescriptor.path, readDescriptor, async (c, worker) => c.body(staticDescriptor ?? descriptorOf(worker, worker.edition ?? EDITION), 200, JSON_UTF8));
352
+ // HLTH-5: `200` whatever it reports. The status is read from the body.
353
+ serve("health", "/health", pollHealth, async (c, worker) => worker.health ? c.json(await worker.health(), 200) : undeclared("health"));
354
+ serve("metrics", "/metrics", readMetric, async (c, worker) => {
355
+ const read = surfacesOf(worker).metrics;
356
+ if (!read)
357
+ return undeclared("metrics");
358
+ return page(c, await read(query(c)));
359
+ }, true);
360
+ // ACT-5: the Action is named in the query and the body is the input, raw.
361
+ serve("actions", "/actions", performAction, async (c, worker) => {
362
+ const perform = surfacesOf(worker).actions;
363
+ if (!perform)
364
+ return undeclared("actions");
365
+ return reply(c, await perform(query(c).get("action") ?? "", await c.req.text(), c.req.header("idempotency-key"), bearer(c)));
366
+ }, true);
367
+ // ACT-15: where the Worker exposes its settings, this app serves the reading address, which is
368
+ // why the declaration above points at it and the two cannot disagree.
369
+ app.use("/settings", guard);
370
+ app.use("/settings", noParameters);
371
+ app.get("/settings", async (c) => {
372
+ const worker = await audited(c);
373
+ const settings = worker.actions?.settings;
374
+ if (!settings || !worker.actions?.accepts.configure)
375
+ return undeclared("configure");
376
+ return c.json((await settings()));
377
+ });
378
+ // ALRT-2, ENDP-20: the Alerts whose conditions hold, in the page envelope this app builds.
379
+ serve("alerts", "/alerts", readAlerts, async (c, worker) => worker.alerts
380
+ ? page(c, collection(await worker.alerts(), query(c), serializeSince))
381
+ : undeclared("alerts"), true);
382
+ // ACTV-2, ENDP-20, ENDP-23: what this Worker holds, serialized and ordered here so that a Worker
383
+ // writes neither the instant's format nor the order. By id, which the Worker mints and nothing
384
+ // else here reorders — the same order the Tasks surface declares, for the same reason.
385
+ serve("activity", "/activity", readActivity, async (c, worker) => worker.activity
386
+ ? page(c, collection(await worker.activity(), query(c), serializeSince))
387
+ : undeclared("activity"), true);
388
+ /**
389
+ * NDG-2, NDG-3: told there is work of a Task type, and answered `204`.
390
+ *
391
+ * Every refusal here is written out rather than left to the route's validator, because the body is
392
+ * the one in this protocol whose shape is fixed by the protocol: a Worker that declares `nudges`
393
+ * owes exactly these answers, and a library's own `400` for a body it could not parse is not one
394
+ * of them (ENDP-25).
395
+ */
396
+ serve("nudges", "/nudges", takeNudge, async (c, worker) => {
397
+ if (!worker.nudges)
398
+ return undeclared("nudges");
399
+ let body;
400
+ try {
401
+ body = JSON.parse(await c.req.text());
402
+ }
403
+ catch {
404
+ return envelope({ code: "malformed_request", message: "The body did not parse as JSON." });
405
+ }
406
+ const told = nudge.safeParse(body);
407
+ if (!told.success) {
408
+ return envelope({
409
+ code: "schema_mismatch",
410
+ message: "A nudge carries one Task type and nothing else.",
411
+ });
412
+ }
413
+ // NDG-3: a type this Worker declares no Skill for is `404`, and nothing happened. Taking one
414
+ // would tell an owner it had been told, and the owner would stop nudging whoever could help.
415
+ if (worker.skills?.[told.data.type] === undefined) {
416
+ return envelope({
417
+ code: "not_found",
418
+ message: `This Worker declares no Skill for ${told.data.type}.`,
419
+ });
420
+ }
421
+ await worker.nudges(told.data.type);
422
+ return c.body(null, 204);
423
+ });
424
+ // TASK-5, TASK-6: the Tasks whose conditions hold, and only those the credential covers.
425
+ serve("tasks", "/tasks", readTasks, async (c, worker) => {
426
+ const tasks = surfacesOf(worker).tasks;
427
+ if (!tasks)
428
+ return undeclared("tasks");
429
+ return page(c, await tasks.read(query(c), bearer(c)));
430
+ }, true);
431
+ return app;
432
+ }