@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.
@@ -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";
@@ -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,