@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.
@@ -0,0 +1,56 @@
1
+ /**
2
+ * The `metrics` surface: every rule in `spec/metrics.md` that is not a number.
3
+ *
4
+ * What a Worker knows is how much of something happened. What it has had to write until now is the
5
+ * parameter validation, the half-open interval, the boundaries cut in a declared zone, the ISO
6
+ * week, the ordering with its tiebreak and the page envelope — all of it fixed by MET-8 through
7
+ * MET-20 and identical in every Worker. So this asks a Worker for values over buckets it has
8
+ * already cut, and answers the rest itself.
9
+ */
10
+ import { type metricDeclaration, type metricPage } from "@worker-protocol/schemas";
11
+ import type * as z from "zod";
12
+ import { type Granularity } from "./buckets.ts";
13
+ import type { Refusal } from "./worker.ts";
14
+ export type MetricDeclarations = Record<string, z.infer<typeof metricDeclaration>>;
15
+ /** One period of one metric, already cut in the zone the entry declares (MET-12, MET-20). */
16
+ export type Bucket = {
17
+ start: Date;
18
+ end: Date;
19
+ };
20
+ /** What a Worker is asked for: values, over buckets it did not have to compute. */
21
+ export type MetricQuery = {
22
+ /** MET-8. One of the metrics the entry declares. */
23
+ metric: string;
24
+ /** MET-3. One of the granularities that metric declares. */
25
+ granularity: Granularity;
26
+ /** MET-11, MET-12. The whole buckets the interval covers, ascending. */
27
+ buckets: Bucket[];
28
+ /** MET-16, MET-17. The dimensions this read fixes, each a value the declaration admits. */
29
+ fixed: Record<string, string>;
30
+ /** MET-19. The dimensions to break down by, each one that declared its set of values. */
31
+ by: string[];
32
+ };
33
+ /**
34
+ * One value. MET-15 draws the distinction that matters: a bucket a Worker accumulated nothing in
35
+ * is ABSENT from the answer, and one it no longer holds carries `null`. Returning neither is the
36
+ * first case, and it is the common one.
37
+ */
38
+ export type MetricSample = {
39
+ /** The `start` of one of the buckets that were asked for. */
40
+ start: Date;
41
+ value: number | null;
42
+ /** MET-19. One value per dimension named in `by`, and absent where nothing was broken down. */
43
+ dimensions?: Record<string, string>;
44
+ };
45
+ export type MetricFacts = {
46
+ timeZone: string;
47
+ /** MET-21. Every metric this Worker publishes, keyed by name. */
48
+ publishes: MetricDeclarations;
49
+ /** The Worker's own facts. Everything around this call is the specification's. */
50
+ read: (query: MetricQuery) => MetricSample[] | Refusal | Promise<MetricSample[] | Refusal>;
51
+ /** ENDP-19 (recommended). The most buckets one page carries. */
52
+ pageSize?: number;
53
+ };
54
+ type MetricPage = z.infer<typeof metricPage>;
55
+ export declare function metrics(facts: MetricFacts): (query: URLSearchParams) => Promise<Refusal | MetricPage>;
56
+ export {};
@@ -0,0 +1,122 @@
1
+ /**
2
+ * The `metrics` surface: every rule in `spec/metrics.md` that is not a number.
3
+ *
4
+ * What a Worker knows is how much of something happened. What it has had to write until now is the
5
+ * parameter validation, the half-open interval, the boundaries cut in a declared zone, the ISO
6
+ * week, the ordering with its tiebreak and the page envelope — all of it fixed by MET-8 through
7
+ * MET-20 and identical in every Worker. So this asks a Worker for values over buckets it has
8
+ * already cut, and answers the rest itself.
9
+ */
10
+ import { INSTANT } from "@worker-protocol/schemas";
11
+ import { bucketsIn, rfc3339 } from "./buckets.js";
12
+ const refuse = (code, message) => ({ code, message });
13
+ /** MET-5, MET-8, MET-11, MET-19, ENDP-20 — what this read defines, and what a dimension may not be. */
14
+ const OWN_PARAMETERS = new Set(["metric", "granularity", "from", "to", "by", "cursor"]);
15
+ export function metrics(facts) {
16
+ const cap = facts.pageSize ?? 500;
17
+ return async function read(query) {
18
+ // MET-8: a read names one metric. MET-9: one the entry does not declare is a resource that
19
+ // does not exist, which is MET-10's division — the name is wrong, not the parameter.
20
+ const name = query.get("metric");
21
+ if (name === null)
22
+ return refuse("invalid_parameter", "A read names one metric.");
23
+ const declaration = facts.publishes[name];
24
+ if (declaration === undefined) {
25
+ return refuse("not_found", `No metric named ${name} is declared.`);
26
+ }
27
+ const { dimensions, granularities } = declaration;
28
+ // ENDP-24: an unrecognized filter is `400` and is never ignored. A dimension is spelled into a
29
+ // parameter of its own name (MET-16), so what is left after those is a name nobody declared.
30
+ for (const key of query.keys()) {
31
+ if (OWN_PARAMETERS.has(key) || key in dimensions)
32
+ continue;
33
+ return refuse("unknown_filter", `This metric declares no dimension named ${key}.`);
34
+ }
35
+ // MET-8, MET-10: named where the metric declares more than one, omittable where it declares
36
+ // exactly one, and `400` for one it does not accumulate by.
37
+ const asked = query.get("granularity");
38
+ if (asked === null && granularities.length > 1) {
39
+ return refuse("invalid_parameter", "This metric declares more than one granularity.");
40
+ }
41
+ const granularity = (asked ?? granularities[0]);
42
+ if (!granularities.includes(granularity)) {
43
+ return refuse("invalid_parameter", `This metric does not accumulate by ${granularity}.`);
44
+ }
45
+ // MET-11: RFC 3339 instants carrying an offset, half-open, so two adjacent reads add up.
46
+ const instant = (raw) => {
47
+ if (raw === null)
48
+ return null;
49
+ return INSTANT.test(raw) ? Date.parse(raw) : Number.NaN;
50
+ };
51
+ const from = instant(query.get("from"));
52
+ const to = instant(query.get("to"));
53
+ if (Number.isNaN(from) || Number.isNaN(to)) {
54
+ return refuse("invalid_parameter", "`from` and `to` are RFC 3339 instants.");
55
+ }
56
+ const now = Date.now();
57
+ const end = to ?? now;
58
+ // Absent, `from` is the start of the current bucket: the question a console asks by default is
59
+ // about the period in progress, not about all of history.
60
+ const start = from ?? bucketsIn(end - 1, end, granularity, facts.timeZone)[0]?.start ?? end;
61
+ // MET-19: only a dimension that declared its set of values may be grouped by. Over a free one
62
+ // nothing bounds the number of series, so it is filtered and never grouped.
63
+ const by = query.getAll("by");
64
+ for (const dimension of by) {
65
+ const declared = dimensions[dimension];
66
+ if (declared === undefined) {
67
+ return refuse("unknown_filter", `This metric declares no dimension named ${dimension}.`);
68
+ }
69
+ if (declared.values === undefined) {
70
+ return refuse("invalid_parameter", `${dimension} declares no set of values to group by.`);
71
+ }
72
+ }
73
+ // MET-16, MET-17: a value outside a declared set is refused. Where no set is declared the
74
+ // Worker cannot tell a typo from a value it has not seen, so any string is accepted.
75
+ const fixed = {};
76
+ for (const [dimension, declared] of Object.entries(dimensions)) {
77
+ const value = query.get(dimension);
78
+ if (value === null)
79
+ continue;
80
+ if (declared.values !== undefined && !declared.values.includes(value)) {
81
+ return refuse("invalid_parameter", `${dimension} does not take the value ${value}.`);
82
+ }
83
+ fixed[dimension] = value;
84
+ }
85
+ const cut = bucketsIn(start, end, granularity, facts.timeZone);
86
+ const samples = await facts.read({
87
+ metric: name,
88
+ granularity,
89
+ buckets: cut.map((bucket) => ({ start: new Date(bucket.start), end: new Date(bucket.end) })),
90
+ fixed,
91
+ by,
92
+ });
93
+ if (!Array.isArray(samples))
94
+ return samples;
95
+ const endOf = new Map(cut.map((bucket) => [bucket.start, bucket.end]));
96
+ // MET-14: ascending by start, and within one start by the values broken down by — an order
97
+ // with ties in it is not one a cursor could resume from. MET-13: the end is carried rather
98
+ // than derived, because a day across a transition is 23 or 25 hours.
99
+ const ordered = samples
100
+ .map((sample) => ({ sample, at: sample.start.getTime() }))
101
+ .filter(({ at }) => endOf.has(at))
102
+ .sort((a, b) => a.at - b.at ||
103
+ by
104
+ .map((d) => a.sample.dimensions?.[d] ?? "")
105
+ .join()
106
+ .localeCompare(by.map((d) => b.sample.dimensions?.[d] ?? "").join()));
107
+ const from_ = Number(query.get("cursor") ?? "0");
108
+ if (!Number.isInteger(from_) || from_ < 0) {
109
+ return refuse("invalid_parameter", "That cursor was not produced by this Worker.");
110
+ }
111
+ const items = ordered.slice(from_, from_ + cap).map(({ sample, at }) => ({
112
+ start: rfc3339(at),
113
+ end: rfc3339(endOf.get(at)),
114
+ value: sample.value,
115
+ ...(by.length > 0 && sample.dimensions !== undefined
116
+ ? { dimensions: sample.dimensions }
117
+ : {}),
118
+ }));
119
+ const next = from_ + cap;
120
+ return next < ordered.length ? { items, nextCursor: String(next) } : { items };
121
+ };
122
+ }
@@ -0,0 +1,92 @@
1
+ import { OpenAPIHono } from "@hono/zod-openapi";
2
+ import type { Worker } from "./worker.ts";
3
+ /**
4
+ * Everything a conformant Worker owes and nobody should write twice, as one Hono app.
5
+ *
6
+ * `mount(worker)` takes what only the Worker knows — see `worker.ts` — and returns an app that
7
+ * serves the Descriptor at the one route this protocol fixes (DESC-3) and every declared
8
+ * Capability at an address of its own. Mount it wherever the Worker lives: at the root, or under a
9
+ * path beside an application that already owns the root.
10
+ *
11
+ * **It takes a Worker, or a function that answers one from an environment.** The second form is
12
+ * the one most Workers on this network need and the reason is not a detail of any one platform: a
13
+ * Cloudflare Worker, a Vercel edge function and a Deno Deploy handler are all handed their
14
+ * bindings, secrets and execution context *per request*, so a Worker built once at module scope
15
+ * cannot reach a database, a queue or a durable object at all. Only a long-lived Node or Bun
16
+ * process has an ambient environment, and that is the narrower case.
17
+ *
18
+ * ```ts
19
+ * // Cloudflare, Vercel edge, Deno Deploy — env arrives with the request
20
+ * export default { fetch: mount<Env>((env) => workerFor(env)).fetch }
21
+ *
22
+ * // Node, Bun, Deno with an ambient environment
23
+ * export const app = mount(worker)
24
+ * ```
25
+ *
26
+ * What it carries, so that a Worker author does not: ENDP-5's two headers on every response;
27
+ * REG-3 and REG-21; ENDP-6's whole refusal of a version it cannot answer; ENDP-24; ENDP-25 and
28
+ * ENDP-26's envelope; ENDP-19 through ENDP-23's page and cursor; DESC-12's addresses; TASK-5's
29
+ * filter and TASK-28's page; MET-7 through MET-20, the parameters and the boundaries; ACT-6 through
30
+ * ACT-12 and ENDP-15 through ENDP-18; and ACT-2 and ACT-3's JSON Schema, generated from the Zod
31
+ * object a Worker declared so the document a console renders a form from and the object a request
32
+ * is validated against are one declaration.
33
+ *
34
+ * What it does not carry is anything the Worker is authoritative over: whether a credential is
35
+ * good, which conditions hold, what a number is, what performing an Action does.
36
+ */
37
+ /** What a runtime hands a `fetch` handler beside the environment. Structural, so every one fits. */
38
+ export type ExecutionCtx = {
39
+ waitUntil?: (promise: Promise<unknown>) => void;
40
+ };
41
+ /**
42
+ * A Worker, or how to answer one from the environment of the request in hand.
43
+ *
44
+ * **It may be async, and that is how a configurable Worker declares what its configuration says.**
45
+ * A time zone kept in a database, a set of metrics that depends on what an operator switched on, a
46
+ * broker read from a settings row: all of it is awaited here, once, and the object answered is an
47
+ * ordinary Worker. No field of that object needs to be a function of its own, which is why none is.
48
+ *
49
+ * ```ts
50
+ * export default {
51
+ * fetch: mount<Env>(async (env) => {
52
+ * const settings = await configuration(env); // cached; see below
53
+ * return { id: "…", metrics: { timeZone: settings.zone, publishes: published(settings), read } };
54
+ * }).fetch,
55
+ * }
56
+ * ```
57
+ *
58
+ * Two things follow and both are worth knowing before writing that. **It runs on every request**,
59
+ * the Tower's polls included, so a Worker that reads a store here caches the result — in module
60
+ * scope, which on an isolate runtime is a cache and not durable state, and is exactly the right
61
+ * place for one. And **what it DECLARES may not differ between two callers**, because REG-8
62
+ * requires the Descriptor to be the same document for every one of them: configuration that
63
+ * changes gives every caller a new Descriptor, which is ordinary, and configuration read per
64
+ * caller is the thing REG-8 forbids.
65
+ */
66
+ export type WorkerSource<E = unknown> = Worker | WorkerBuilder<E>;
67
+ /** What `mount()` takes when a Worker is answered from the environment of each request. */
68
+ export type WorkerBuilder<E = unknown> = (env: E, ctx: ExecutionCtx) => Worker | Promise<Worker>;
69
+ /**
70
+ * Write the builder `mount()` takes, and have a mistake reported where you made it.
71
+ *
72
+ * It returns its argument and does nothing at all at runtime. What it does is at the type level: a
73
+ * builder declared apart from the `mount()` call has nothing to check its object literal against,
74
+ * so the first complaint arrives at `mount()`, about a type nested six levels deep, naming a
75
+ * property three files away. Through this, a missing `since` is reported on `since`. An `async`
76
+ * builder is the same call, because `Worker | Promise<Worker>` is written here once instead of at
77
+ * every Worker that happens to await something.
78
+ *
79
+ * ```ts
80
+ * export const fleetWorker = defineWorker<Env>((env) => ({ id: "…", health: () => … }))
81
+ * export default { fetch: mount(fleetWorker).fetch }
82
+ * ```
83
+ *
84
+ * **No rule id can be cited for this line, and that is worth saying rather than dressing up.**
85
+ * `packages/README.md` asks that of everything in this package, and an identity function that
86
+ * exists to please a compiler answers nothing. It stays because the alternative — knowing that
87
+ * `WorkerBuilder` exists before you can annotate with it — is friction paid by every author, and
88
+ * `defineConfig`, `defineComponent` and `defineStore` have made this shape one a reader does not
89
+ * have to be taught. The type above is exported too, for a builder that was already written.
90
+ */
91
+ export declare const defineWorker: <E = unknown>(build: WorkerBuilder<E>) => WorkerBuilder<E>;
92
+ export declare function mount<E = unknown>(source: WorkerSource<E>): OpenAPIHono;