@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/LICENSE +201 -0
- package/NOTICE +9 -0
- package/dist/actions.d.ts +200 -0
- package/dist/actions.js +203 -0
- package/dist/buckets.d.ts +36 -0
- package/dist/buckets.js +133 -0
- package/dist/codes.d.ts +201 -0
- package/dist/codes.js +129 -0
- package/dist/collection.d.ts +44 -0
- package/dist/collection.js +62 -0
- package/dist/index.d.ts +26 -0
- package/dist/index.js +22 -0
- package/dist/metrics.d.ts +56 -0
- package/dist/metrics.js +122 -0
- package/dist/mount.d.ts +92 -0
- package/dist/mount.js +432 -0
- package/dist/surfaces.d.ts +483 -0
- package/dist/surfaces.js +424 -0
- package/dist/tasks.d.ts +78 -0
- package/dist/tasks.js +45 -0
- package/dist/worker.d.ts +169 -0
- package/dist/worker.js +1 -0
- package/package.json +51 -0
|
@@ -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 {};
|
package/dist/metrics.js
ADDED
|
@@ -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
|
+
}
|
package/dist/mount.d.ts
ADDED
|
@@ -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;
|