@zerotal/core 1.13.2 → 1.13.3
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/CHANGELOG.md +64 -0
- package/api-surface.md +181 -0
- package/package.json +3 -1
- package/src/application/Application.ts +15 -2
- package/src/command/CommandRunner.ts +13 -0
- package/src/command/builtin/GateCommands.ts +171 -0
- package/src/command/builtin/index.ts +1 -0
- package/src/doctor/AppDoctor.ts +2 -0
- package/src/gate/Gate.ts +130 -0
- package/src/gate/GateMiddleware.ts +260 -0
- package/src/gate/doctor.ts +72 -0
- package/src/gate/index.ts +13 -0
- package/src/gate/state.ts +127 -0
- package/src/heartbeat/Heartbeat.ts +192 -0
- package/src/heartbeat/doctor.ts +94 -0
- package/src/heartbeat/index.ts +11 -0
- package/src/router/Router.ts +29 -1
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which background processes are actually running.
|
|
3
|
+
*
|
|
4
|
+
* An app can say what it *registered* — three schedules, a queue worker — and
|
|
5
|
+
* until now nothing could say whether any of it ever *ran*. That gap has a shape:
|
|
6
|
+
* a team shipped to production with no worker process, and every scheduled task
|
|
7
|
+
* silently did not execute for weeks. No inventory hold was released, no payment
|
|
8
|
+
* reminder was sent, and nothing logged, because from the web process's point of
|
|
9
|
+
* view nothing was wrong. They found it by going looking.
|
|
10
|
+
*
|
|
11
|
+
* The identical failure is a dead queue worker: jobs accumulate, nothing errors,
|
|
12
|
+
* and you learn about it when a customer asks where their email went.
|
|
13
|
+
*
|
|
14
|
+
* ## Where the beat is kept
|
|
15
|
+
*
|
|
16
|
+
* In the **cache**, deliberately, rather than in a new store. The reader is a
|
|
17
|
+
* different process from the writer and often a different machine, so this needs
|
|
18
|
+
* shared state — and the app has already chosen where its shared state lives.
|
|
19
|
+
* `sqlite` (the default) is shared across processes on one box; `redis` is shared
|
|
20
|
+
* across machines; `memory` is shared with nobody.
|
|
21
|
+
*
|
|
22
|
+
* That last case is why {@link Heartbeat.lastSeen} distinguishes "nobody has
|
|
23
|
+
* checked in" from "I cannot tell". A doctor check that reported a missing worker
|
|
24
|
+
* every time an app used the memory driver would be wrong more often than it was
|
|
25
|
+
* right, and a check that is usually wrong is one people learn to skip.
|
|
26
|
+
*
|
|
27
|
+
* @module
|
|
28
|
+
*/
|
|
29
|
+
import { currentApp } from "../application/currentApp.ts";
|
|
30
|
+
import type { ContainerBindings } from "../container/types.ts";
|
|
31
|
+
|
|
32
|
+
/** How long a beat stays readable. Comfortably longer than any sane interval. */
|
|
33
|
+
const BEAT_TTL_SECONDS = 7 * 24 * 60 * 60;
|
|
34
|
+
|
|
35
|
+
/** Cache key prefix. Namespaced so it cannot collide with an app's own keys. */
|
|
36
|
+
const PREFIX = "zerotal:heartbeat:";
|
|
37
|
+
|
|
38
|
+
/** What a beat records. */
|
|
39
|
+
export interface Beat {
|
|
40
|
+
/** ISO timestamp of the last check-in. */
|
|
41
|
+
at: string;
|
|
42
|
+
/** Process id, so a report can distinguish two workers from one restarting. */
|
|
43
|
+
pid?: number | undefined;
|
|
44
|
+
/** Free-form detail — the queue name, the schedule count. */
|
|
45
|
+
detail?: string | undefined;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* What {@link Heartbeat.lastSeen} found.
|
|
50
|
+
*
|
|
51
|
+
* `unknown` is a distinct answer from `null`, and keeping them apart is the whole
|
|
52
|
+
* reason this type exists: one means the worker is not running, the other means
|
|
53
|
+
* this process has no way to see a worker that might be.
|
|
54
|
+
*/
|
|
55
|
+
export type BeatLookup =
|
|
56
|
+
| { status: "seen"; beat: Beat; ageSeconds: number }
|
|
57
|
+
| { status: "never" }
|
|
58
|
+
| { status: "unknown"; reason: string };
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The slice of the cache this needs.
|
|
62
|
+
*
|
|
63
|
+
* Structural rather than an import of `CacheManager`: `@zerotal/core` does not
|
|
64
|
+
* depend on `@zerotal/cache`, and it should not start doing so to record a
|
|
65
|
+
* timestamp. Anything with these two methods will do.
|
|
66
|
+
*/
|
|
67
|
+
interface BeatStore {
|
|
68
|
+
get<T>(key: string): Promise<T | null>;
|
|
69
|
+
set(key: string, value: unknown, ttl?: number): Promise<void>;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Resolve the cache, or `null` when there is none to resolve. */
|
|
73
|
+
function _cache(): BeatStore | null {
|
|
74
|
+
try {
|
|
75
|
+
// `cache` is declared on `ContainerBindings` by `@zerotal/cache`, which core
|
|
76
|
+
// cannot see — so the key is not `keyof ContainerBindings` from here. The cast
|
|
77
|
+
// is the same one `Application._runConventions` already makes for resolving a
|
|
78
|
+
// token another package registered, and it is why `BeatStore` is structural:
|
|
79
|
+
// nothing about this reaches into the cache package.
|
|
80
|
+
return (currentApp().container.tryMake("cache" as keyof ContainerBindings) ??
|
|
81
|
+
null) as BeatStore | null;
|
|
82
|
+
} catch {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The configured cache driver name, or `undefined` when it cannot be read. */
|
|
88
|
+
function _driver(): string | undefined {
|
|
89
|
+
try {
|
|
90
|
+
const config = currentApp().container.tryMake("config") as
|
|
91
|
+
{ get<T>(k: string, d: T): T } | undefined;
|
|
92
|
+
return config?.get<string>("cache.driver", "");
|
|
93
|
+
} catch {
|
|
94
|
+
return undefined;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Record and read background-process check-ins.
|
|
100
|
+
*
|
|
101
|
+
* @example
|
|
102
|
+
* ```ts
|
|
103
|
+
* // in a worker loop
|
|
104
|
+
* await Heartbeat.beat("queue:default", { detail: "4 jobs" });
|
|
105
|
+
*
|
|
106
|
+
* // in a doctor check, or an ops page
|
|
107
|
+
* const seen = await Heartbeat.lastSeen("queue:default");
|
|
108
|
+
* if (seen.status === "never") warnNobodyIsWorking();
|
|
109
|
+
* ```
|
|
110
|
+
*/
|
|
111
|
+
export const Heartbeat = {
|
|
112
|
+
/**
|
|
113
|
+
* Record that this process is alive and doing `name`.
|
|
114
|
+
*
|
|
115
|
+
* Never throws. A heartbeat that can take a worker down is worse than no
|
|
116
|
+
* heartbeat: the whole point is to observe the worker, not to become a reason
|
|
117
|
+
* it stops.
|
|
118
|
+
*
|
|
119
|
+
* @param name - Stable identifier, e.g. `"scheduler"` or `"queue:default"`.
|
|
120
|
+
*/
|
|
121
|
+
async beat(name: string, extra: Omit<Beat, "at"> = {}): Promise<void> {
|
|
122
|
+
const cache = _cache();
|
|
123
|
+
if (!cache) return;
|
|
124
|
+
try {
|
|
125
|
+
await cache.set(
|
|
126
|
+
`${PREFIX}${name}`,
|
|
127
|
+
{ at: new Date().toISOString(), pid: process.pid, ...extra } satisfies Beat,
|
|
128
|
+
BEAT_TTL_SECONDS,
|
|
129
|
+
);
|
|
130
|
+
} catch {
|
|
131
|
+
// Observability is not worth an outage.
|
|
132
|
+
}
|
|
133
|
+
},
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Beat now, and keep beating until the returned function is called.
|
|
137
|
+
*
|
|
138
|
+
* A worker registers its schedules once and then sits in an event loop, so a
|
|
139
|
+
* single beat at start-up would be indistinguishable from a worker that started
|
|
140
|
+
* and died — which is the failure most worth catching. The interval is what
|
|
141
|
+
* makes the check mean "is running" rather than "was once started".
|
|
142
|
+
*
|
|
143
|
+
* The timer is `unref`'d, so it never keeps a process alive on its own. A
|
|
144
|
+
* heartbeat that stopped `zt worker` from exiting would be a bug reported as a
|
|
145
|
+
* hang.
|
|
146
|
+
*
|
|
147
|
+
* @param name - Stable identifier.
|
|
148
|
+
* @param everyMs - Beat interval. Default 60s.
|
|
149
|
+
* @returns A function that stops the beating.
|
|
150
|
+
*/
|
|
151
|
+
start(name: string, extra: Omit<Beat, "at"> = {}, everyMs = 60_000): () => void {
|
|
152
|
+
void Heartbeat.beat(name, extra);
|
|
153
|
+
const timer = setInterval(() => void Heartbeat.beat(name, extra), everyMs);
|
|
154
|
+
(timer as unknown as { unref?: () => void }).unref?.();
|
|
155
|
+
return () => clearInterval(timer);
|
|
156
|
+
},
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* When `name` last checked in.
|
|
160
|
+
*
|
|
161
|
+
* @returns `seen` with the age, `never` when nothing has checked in, or
|
|
162
|
+
* `unknown` when this process cannot tell — which is not the same finding and
|
|
163
|
+
* must not be reported as one.
|
|
164
|
+
*/
|
|
165
|
+
async lastSeen(name: string): Promise<BeatLookup> {
|
|
166
|
+
const driver = _driver();
|
|
167
|
+
if (driver === "memory") {
|
|
168
|
+
return {
|
|
169
|
+
status: "unknown",
|
|
170
|
+
reason:
|
|
171
|
+
"the cache driver is `memory`, which is private to each process — a worker's " +
|
|
172
|
+
"check-in cannot be seen from here. Use `sqlite` (shared on one box) or `redis` " +
|
|
173
|
+
"(shared across machines) for this to mean anything.",
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const cache = _cache();
|
|
178
|
+
if (!cache) return { status: "unknown", reason: "no cache is configured" };
|
|
179
|
+
|
|
180
|
+
try {
|
|
181
|
+
const beat = await cache.get<Beat>(`${PREFIX}${name}`);
|
|
182
|
+
if (!beat?.at) return { status: "never" };
|
|
183
|
+
const ageSeconds = Math.max(0, Math.round((Date.now() - Date.parse(beat.at)) / 1000));
|
|
184
|
+
return { status: "seen", beat, ageSeconds };
|
|
185
|
+
} catch (error) {
|
|
186
|
+
return {
|
|
187
|
+
status: "unknown",
|
|
188
|
+
reason: `the cache could not be read (${(error as Error).message})`,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
},
|
|
192
|
+
};
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turning a check-in into a doctor finding, once, for every worker kind.
|
|
3
|
+
*
|
|
4
|
+
* Scheduler and queue have the same question — *is anything actually running
|
|
5
|
+
* this?* — and answering it twice is how two packages come to disagree about
|
|
6
|
+
* what "stale" means.
|
|
7
|
+
*
|
|
8
|
+
* @module
|
|
9
|
+
*/
|
|
10
|
+
import type { DoctorCheck, DoctorCheckResult } from "../doctor/AppDoctor.ts";
|
|
11
|
+
import { Heartbeat, type BeatLookup } from "./Heartbeat.ts";
|
|
12
|
+
|
|
13
|
+
/** How a lookup reads in a report. */
|
|
14
|
+
export function describeBeat(lookup: BeatLookup): string {
|
|
15
|
+
if (lookup.status === "seen") {
|
|
16
|
+
const minutes = Math.round(lookup.ageSeconds / 60);
|
|
17
|
+
if (lookup.ageSeconds < 90) return "checked in just now";
|
|
18
|
+
if (minutes < 90) return `last checked in ${minutes} minutes ago`;
|
|
19
|
+
return `last checked in ${Math.round(minutes / 60)} hours ago`;
|
|
20
|
+
}
|
|
21
|
+
return lookup.status === "never" ? "has never checked in" : lookup.reason;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** What {@link workerLivenessCheck} needs to know about one worker kind. */
|
|
25
|
+
export interface WorkerLivenessOptions {
|
|
26
|
+
/** Doctor check id, e.g. `"scheduler-running"`. */
|
|
27
|
+
id: string;
|
|
28
|
+
/** Label, e.g. `"Scheduler"`. */
|
|
29
|
+
label: string;
|
|
30
|
+
/** Heartbeat name, e.g. `"scheduler"`. */
|
|
31
|
+
name: string;
|
|
32
|
+
/**
|
|
33
|
+
* Whether this app has anything for the worker to do.
|
|
34
|
+
*
|
|
35
|
+
* Checked first and separately, because "no worker is running" is only a
|
|
36
|
+
* finding when there is work registered. An app with no schedules and no
|
|
37
|
+
* worker is correctly configured, and saying otherwise is how a doctor becomes
|
|
38
|
+
* a thing people scroll past.
|
|
39
|
+
*/
|
|
40
|
+
hasWork(): Promise<{ has: boolean; summary: string }> | { has: boolean; summary: string };
|
|
41
|
+
/** Seconds after which a check-in is stale. */
|
|
42
|
+
staleAfter: number;
|
|
43
|
+
/** What to run to start it, named in the fix. */
|
|
44
|
+
command: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Build a check that reports work registered with nothing running it.
|
|
49
|
+
*
|
|
50
|
+
* The severity ladder is deliberate. Nothing registered is silent. Registered and
|
|
51
|
+
* never seen is a failure — that is the reported production case, where a site
|
|
52
|
+
* ran for weeks with no scheduled task executing. Registered and *stale* is a
|
|
53
|
+
* warning, because a worker that checked in an hour ago and not since may be
|
|
54
|
+
* mid-restart. And "cannot tell" is reported as ok with the reason attached,
|
|
55
|
+
* never as a finding, because a check that cries wolf on the memory cache driver
|
|
56
|
+
* is one people learn to skip.
|
|
57
|
+
*/
|
|
58
|
+
export function workerLivenessCheck(options: WorkerLivenessOptions): DoctorCheck {
|
|
59
|
+
return {
|
|
60
|
+
id: options.id,
|
|
61
|
+
label: options.label,
|
|
62
|
+
async run(): Promise<DoctorCheckResult> {
|
|
63
|
+
const work = await options.hasWork();
|
|
64
|
+
if (!work.has) return { status: "ok", message: "nothing registered, nothing to run" };
|
|
65
|
+
|
|
66
|
+
const seen = await Heartbeat.lastSeen(options.name);
|
|
67
|
+
|
|
68
|
+
if (seen.status === "unknown") {
|
|
69
|
+
return { status: "ok", message: `${work.summary} — cannot verify: ${seen.reason}` };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (seen.status === "never") {
|
|
73
|
+
return {
|
|
74
|
+
status: "fail",
|
|
75
|
+
message:
|
|
76
|
+
`${work.summary}, and no worker has ever checked in. Nothing is running them. ` +
|
|
77
|
+
`This is silent by nature — the web process has no way to notice, and the work ` +
|
|
78
|
+
`simply does not happen.`,
|
|
79
|
+
fix: `Start the worker process: \`${options.command}\`. It is a second process; the web server does not run this.`,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (seen.ageSeconds > options.staleAfter) {
|
|
84
|
+
return {
|
|
85
|
+
status: "warn",
|
|
86
|
+
message: `${work.summary}, and the worker ${describeBeat(seen)}.`,
|
|
87
|
+
fix: `Check the worker process is alive and restart it if not: \`${options.command}\`.`,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return { status: "ok", message: `${work.summary}, worker ${describeBeat(seen)}` };
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Background-process check-ins, published as `@zerotal/core/heartbeat`.
|
|
3
|
+
*
|
|
4
|
+
* See {@link Heartbeat} for why the beat lives in the cache and why "I cannot
|
|
5
|
+
* tell" is a distinct answer from "nobody is running".
|
|
6
|
+
*
|
|
7
|
+
* @packageDocumentation
|
|
8
|
+
*/
|
|
9
|
+
export { Heartbeat } from "./Heartbeat.ts";
|
|
10
|
+
export type { Beat, BeatLookup } from "./Heartbeat.ts";
|
|
11
|
+
export { workerLivenessCheck, describeBeat } from "./doctor.ts";
|
package/src/router/Router.ts
CHANGED
|
@@ -31,6 +31,7 @@ import type { ProviderHooks } from "./RouteHandler.ts";
|
|
|
31
31
|
import { tryCurrentApp } from "../application/currentApp.ts";
|
|
32
32
|
import { frameworkLog } from "../logger/frameworkLog.ts";
|
|
33
33
|
import { staticSecurityHeaders } from "../middleware/SecureHeadersMiddleware.ts";
|
|
34
|
+
import { _gateResponse } from "../gate/GateMiddleware.ts";
|
|
34
35
|
import {
|
|
35
36
|
markdownExtractTitle,
|
|
36
37
|
markdownPage,
|
|
@@ -82,6 +83,33 @@ type AnyRouteHandler = RouteHandler<any>;
|
|
|
82
83
|
|
|
83
84
|
type RouteHandlerFn = (req: Request, server?: unknown) => Response | Promise<Response>;
|
|
84
85
|
|
|
86
|
+
/**
|
|
87
|
+
* Apply the site gate to a raw route.
|
|
88
|
+
*
|
|
89
|
+
* Same reasoning as `_withSecurityDefaults` directly below: `Router.raw()` exists
|
|
90
|
+
* to skip *request handling* — CSRF on a transport endpoint, session resolution
|
|
91
|
+
* on a relay — not to opt a route out of whether the site is open at all.
|
|
92
|
+
*
|
|
93
|
+
* A gate that covered only the pipeline would be the worst kind, because it
|
|
94
|
+
* gates the homepage and therefore looks like it works. This framework's own
|
|
95
|
+
* documentation site serves every `/docs/*` page from a raw route: with the gate
|
|
96
|
+
* on, the front page said "coming soon" and every page of content stayed public.
|
|
97
|
+
*
|
|
98
|
+
* No staff bypass here — a raw route has no session resolved, so the token
|
|
99
|
+
* cookie is the only way through. That is the safe direction for a check whose
|
|
100
|
+
* failure mode is letting somebody in.
|
|
101
|
+
*/
|
|
102
|
+
function _withGate(handler: RouteHandlerFn): RouteHandlerFn {
|
|
103
|
+
return async (req: Request, ...rest: unknown[]) => {
|
|
104
|
+
const gated = _gateResponse(req, false);
|
|
105
|
+
if (gated) return gated;
|
|
106
|
+
return (handler as (req: Request, ...rest: unknown[]) => Response | Promise<Response>)(
|
|
107
|
+
req,
|
|
108
|
+
...rest,
|
|
109
|
+
);
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
85
113
|
/**
|
|
86
114
|
* Wrap a raw handler so its response carries the security headers, without
|
|
87
115
|
* touching any the handler set for itself.
|
|
@@ -1130,7 +1158,7 @@ export class Router {
|
|
|
1130
1158
|
const method = key.slice(0, spaceIndex) as HttpMethod;
|
|
1131
1159
|
const path = key.slice(spaceIndex + 1);
|
|
1132
1160
|
const rawMap = (compiled[path] ??= {}) as Record<string, RouteHandlerFn>;
|
|
1133
|
-
rawMap[method] = _withSecurityDefaults(handler, rawDefaults);
|
|
1161
|
+
rawMap[method] = _withGate(_withSecurityDefaults(handler, rawDefaults));
|
|
1134
1162
|
// And `HEAD`, for the same reason the pipeline derives it — a raw route is
|
|
1135
1163
|
// still a route, and `curl -I` against one answered 404 while the `GET`
|
|
1136
1164
|
// beside it answered 200. This site's own `/docs/*` and `/blog` are raw,
|