@hadoku/service-kernel 0.0.0-stage → 0.1.4

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.
Files changed (59) hide show
  1. package/LICENSE +21 -0
  2. package/dist/auth/index.d.ts +125 -0
  3. package/dist/auth/index.js +189 -0
  4. package/dist/capacity/capacity.d.ts +33 -0
  5. package/dist/capacity/capacity.js +119 -0
  6. package/dist/capacity/index.d.ts +1 -0
  7. package/dist/capacity/index.js +1 -0
  8. package/dist/erase/anonymise.d.ts +18 -0
  9. package/dist/erase/anonymise.js +77 -0
  10. package/dist/erase/erase.d.ts +59 -0
  11. package/dist/erase/erase.js +72 -0
  12. package/dist/erase/index.d.ts +2 -0
  13. package/dist/erase/index.js +2 -0
  14. package/dist/health/health.d.ts +42 -0
  15. package/dist/health/health.js +52 -0
  16. package/dist/health/index.d.ts +1 -0
  17. package/dist/health/index.js +1 -0
  18. package/dist/http/index.d.ts +123 -0
  19. package/dist/http/index.js +192 -0
  20. package/dist/lifecycle/announce.d.ts +74 -0
  21. package/dist/lifecycle/announce.js +232 -0
  22. package/dist/lifecycle/bootClock.d.ts +7 -0
  23. package/dist/lifecycle/bootClock.js +14 -0
  24. package/dist/lifecycle/busy.d.ts +57 -0
  25. package/dist/lifecycle/busy.js +47 -0
  26. package/dist/lifecycle/flush-sockets.d.ts +41 -0
  27. package/dist/lifecycle/flush-sockets.js +61 -0
  28. package/dist/lifecycle/index.d.ts +4 -0
  29. package/dist/lifecycle/index.js +4 -0
  30. package/dist/limits/index.d.ts +1 -0
  31. package/dist/limits/index.js +1 -0
  32. package/dist/limits/limits.d.ts +76 -0
  33. package/dist/limits/limits.js +103 -0
  34. package/dist/log/index.d.ts +1 -0
  35. package/dist/log/index.js +1 -0
  36. package/dist/log/log.d.ts +7 -0
  37. package/dist/log/log.js +8 -0
  38. package/dist/relays/client-events.d.ts +51 -0
  39. package/dist/relays/client-events.js +53 -0
  40. package/dist/relays/index.d.ts +2 -0
  41. package/dist/relays/index.js +2 -0
  42. package/dist/relays/usage.d.ts +53 -0
  43. package/dist/relays/usage.js +75 -0
  44. package/dist/server/bus.d.ts +27 -0
  45. package/dist/server/bus.js +21 -0
  46. package/dist/server/index.d.ts +2 -0
  47. package/dist/server/index.js +2 -0
  48. package/dist/server/server.d.ts +157 -0
  49. package/dist/server/server.js +244 -0
  50. package/dist/state/index.d.ts +1 -0
  51. package/dist/state/index.js +1 -0
  52. package/dist/state/state-dir.d.ts +39 -0
  53. package/dist/state/state-dir.js +63 -0
  54. package/dist/telemetry/index.d.ts +1 -0
  55. package/dist/telemetry/index.js +1 -0
  56. package/dist/telemetry/telemetry.d.ts +105 -0
  57. package/dist/telemetry/telemetry.js +221 -0
  58. package/package.json +71 -4
  59. package/README.md +0 -3
@@ -0,0 +1,232 @@
1
+ /*
2
+ * Prod posts its release's patch notes to a Discord server's #announcements at boot, through
3
+ * ArchiveBot's signed `/api/messages/send` (contract: docs/requests/ARCHIVEBOT_DISCORD_INTEGRATION_REPLY.md).
4
+ *
5
+ * The release is the newest `vX.Y.Z.md` release notes file in the tree. Every boot sends; the
6
+ * idempotency key `<service>-release-<version>` is deduped by ArchiveBot for 7 days, so a restart
7
+ * posts nothing new.
8
+ *
9
+ * A bug's reporter is named in its bullet as `@name` (their hadoku name; docs/RELEASE.md § "Attribution").
10
+ * Those names ride as `mentions`, and ArchiveBot turns each `@name` in the notes into the person's
11
+ * Discord mention, inline where the bullet credits them. No separate thank-you line.
12
+ *
13
+ * `secret` is the app's webhook secret (empty = skip, never an error); a failed post never blocks
14
+ * boot, and the next boot retries it.
15
+ *
16
+ * `ordering` decides which release files are newest: the kernel does not read a release-notes
17
+ * directory layout itself. The app supplies it (cardtable's adapter injects
18
+ * `scripts/release/build-version.ts`'s `newestReleaseFile`/`releaseFilesNewestFirst`, since the
19
+ * kernel cannot import `scripts/`).
20
+ *
21
+ * Imports only; nothing runs at import.
22
+ */
23
+ import { createHmac } from 'node:crypto';
24
+ import { readdir, readFile } from 'node:fs/promises';
25
+ import { join } from 'node:path';
26
+ /** ArchiveBot's per-embed description cap. */
27
+ const DESCRIPTION_MAX = 4096;
28
+ /** The newest release notes in `dir`, by `ordering`, or null when there are none. */
29
+ export async function latestRelease(dir, ordering) {
30
+ const newest = ordering.newest(await readdir(dir));
31
+ if (newest === null)
32
+ return null;
33
+ return parseNotes(newest.slice(0, -'.md'.length), await readFile(join(dir, newest), 'utf8'));
34
+ }
35
+ /**
36
+ * How many releases, newest first, a boot re-sends as edits. ArchiveBot keeps a post's record for 7
37
+ * days and refuses an edit it has no record of, so older notes cannot be changed this way anyway.
38
+ */
39
+ export const EDITABLE_RELEASES = 3;
40
+ /** The newest `count` release notes in `dir`, newest first, by `ordering`. */
41
+ export async function recentReleases(dir, ordering, count = EDITABLE_RELEASES) {
42
+ const names = ordering.newestFirst(await readdir(dir)).slice(0, count);
43
+ return Promise.all(names.map(async (name) => parseNotes(name.slice(0, -'.md'.length), await readFile(join(dir, name), 'utf8'))));
44
+ }
45
+ /** The `# ` heading is the title; everything after it is the body. */
46
+ export function parseNotes(version, text) {
47
+ const lines = text.split('\n');
48
+ const at = lines.findIndex((line) => line.startsWith('# '));
49
+ if (at === -1)
50
+ return { version, title: version, body: text.trim() };
51
+ return { version, title: lines[at].slice(2).trim(), body: lines.slice(at + 1).join('\n').trim() };
52
+ }
53
+ /**
54
+ * The body cut into descriptions of at most 4096 characters: whole `## ` sections packed in order,
55
+ * and a section that alone is too long cut at line breaks. Never truncated.
56
+ */
57
+ export function splitBody(body, max = DESCRIPTION_MAX) {
58
+ const sections = body.split(/\n(?=## )/);
59
+ const pieces = sections.flatMap((section) => (section.length <= max ? [section] : splitLines(section, max)));
60
+ const out = [];
61
+ for (const piece of pieces) {
62
+ const last = out.length - 1;
63
+ if (last >= 0 && out[last].length + 1 + piece.length <= max)
64
+ out[last] = `${out[last]}\n${piece}`;
65
+ else
66
+ out.push(piece);
67
+ }
68
+ return out.map((d) => d.trim()).filter((d) => d !== '');
69
+ }
70
+ function splitLines(section, max) {
71
+ const out = [];
72
+ let current = '';
73
+ for (const line of section.split('\n')) {
74
+ // A single line past the cap is cut where it stands; nothing in a notes file comes close.
75
+ for (let rest = line;; rest = rest.slice(max)) {
76
+ const chunk = rest.slice(0, max);
77
+ if (current === '')
78
+ current = chunk;
79
+ else if (current.length + 1 + chunk.length <= max)
80
+ current = `${current}\n${chunk}`;
81
+ else {
82
+ out.push(current);
83
+ current = chunk;
84
+ }
85
+ if (rest.length <= max)
86
+ break;
87
+ }
88
+ }
89
+ if (current !== '')
90
+ out.push(current);
91
+ return out;
92
+ }
93
+ /** A line that starts its own block in Markdown: a heading, a list item, a quote, a fence. */
94
+ const BLOCK_START = /^\s*(#|[-*+] |\d+\. |>|```)/;
95
+ /**
96
+ * The body as Discord should get it. Discord breaks the line at every newline it is sent, and a
97
+ * notes file is wrapped at about a hundred columns, so a line that continues the one above is
98
+ * joined to it. Discord also spaces a heading by itself, so a blank line beside one would double
99
+ * the gap: those go. Other blank lines, block starts and anything inside a code fence stay.
100
+ */
101
+ export function forDiscord(body) {
102
+ const out = [];
103
+ let fenced = false;
104
+ for (const line of body.split('\n')) {
105
+ const fence = line.trimStart().startsWith('```');
106
+ const last = out.length - 1;
107
+ if (!fenced && !fence && line.trim() !== '' && last >= 0 && out[last].trim() !== '' && !BLOCK_START.test(line) && !out[last].trimStart().startsWith('#')) {
108
+ out[last] = `${out[last].trimEnd()} ${line.trim()}`;
109
+ }
110
+ else {
111
+ out.push(line);
112
+ }
113
+ if (fence)
114
+ fenced = !fenced;
115
+ }
116
+ const heading = (line) => line !== undefined && line.trimStart().startsWith('#');
117
+ return out.filter((line, i) => line.trim() !== '' || !(heading(out[i - 1]) || heading(out[i + 1]))).join('\n');
118
+ }
119
+ /**
120
+ * Who a release thanks: every `@name` in its notes, the hadoku name of whoever reported a bug the
121
+ * release fixes (docs/RELEASE.md § "Attribution"), each once, in the order the notes first name them.
122
+ * A sentence's closing full stop is not part of a name.
123
+ */
124
+ export function mentionsIn(body) {
125
+ const names = new Map();
126
+ for (const match of body.matchAll(/(?<![\w@/])@([A-Za-z0-9_.-]+)/g)) {
127
+ const name = match[1].replace(/[.-]+$/, '');
128
+ if (name !== '' && !names.has(name.toLowerCase()))
129
+ names.set(name.toLowerCase(), name);
130
+ }
131
+ return [...names.values()];
132
+ }
133
+ export function releaseEmbeds(notes, appUrl) {
134
+ return splitBody(forDiscord(notes.body)).map((description, i) => ({
135
+ ...(i === 0 ? { title: notes.title } : {}),
136
+ description,
137
+ url: appUrl,
138
+ color: 0x2f855a,
139
+ }));
140
+ }
141
+ /** The signed ArchiveBot request for one release's notes: its idempotency key is the release's own. */
142
+ function notesPayload(service, notes, appUrl, now) {
143
+ const mentions = mentionsIn(notes.body);
144
+ return JSON.stringify({
145
+ idempotencyKey: `${service}-release-${notes.version}`,
146
+ timestamp: now.getTime(),
147
+ source: service,
148
+ ...(mentions.length === 0 ? {} : { mentions }),
149
+ embeds: releaseEmbeds(notes, appUrl),
150
+ });
151
+ }
152
+ async function postSigned(options, path, body) {
153
+ const signature = createHmac('sha256', options.secret).update(body).digest('hex');
154
+ return (options.fetch ?? fetch)(`${options.archivebotUrl}${path}`, {
155
+ method: 'POST',
156
+ headers: { 'Content-Type': 'application/json', 'X-Hadoku-Signature': `sha256=${signature}` },
157
+ body,
158
+ signal: AbortSignal.timeout(10_000),
159
+ });
160
+ }
161
+ /**
162
+ * Post the current release's notes, then re-send the newest few as edits, so a corrected notes file
163
+ * (a credit added to a bullet, a typo) changes its post at the next boot. ArchiveBot edits only a post
164
+ * whose shown text differs and never posts from an edit. Resolves on every outcome; the outcome is
165
+ * logged, never thrown.
166
+ */
167
+ export async function announceRelease(options) {
168
+ const { log, service, ordering } = options;
169
+ if (options.secret === '') {
170
+ log({ event: 'release-announce', outcome: 'skipped', reason: `no ${options.secretEnvName}` });
171
+ return;
172
+ }
173
+ const now = () => (options.now ?? (() => new Date()))();
174
+ try {
175
+ const notes = await latestRelease(options.releasesDir, ordering);
176
+ if (notes === null) {
177
+ log({ event: 'release-announce', outcome: 'skipped', reason: `no release notes in ${options.releasesDir}` });
178
+ return;
179
+ }
180
+ const res = await postSigned(options, '/api/messages/send', notesPayload(service, notes, options.appUrl, now()));
181
+ const reply = await res.text();
182
+ if (!res.ok) {
183
+ log({ event: 'release-announce', outcome: 'failed', version: notes.version, status: res.status, reply: reply.slice(0, 500) });
184
+ return;
185
+ }
186
+ const answer = JSON.parse(reply);
187
+ log({
188
+ event: 'release-announce',
189
+ outcome: answer.deduped === true ? 'already-posted' : 'posted',
190
+ version: notes.version,
191
+ // A name ArchiveBot could not match to anyone is posted as plain text: say so, so it can be fixed.
192
+ ...(answer.unresolved !== undefined && answer.unresolved.length > 0 ? { unresolved: answer.unresolved } : {}),
193
+ });
194
+ }
195
+ catch (err) {
196
+ log({ event: 'release-announce', outcome: 'failed', error: err instanceof Error ? err.message : String(err) });
197
+ return;
198
+ }
199
+ let recent;
200
+ try {
201
+ recent = await recentReleases(options.releasesDir, ordering);
202
+ }
203
+ catch (err) {
204
+ log({ event: 'release-edit', outcome: 'failed', error: err instanceof Error ? err.message : String(err) });
205
+ return;
206
+ }
207
+ for (const notes of recent) {
208
+ try {
209
+ const res = await postSigned(options, '/api/messages/edit', notesPayload(service, notes, options.appUrl, now()));
210
+ const reply = await res.text();
211
+ // 404: ArchiveBot holds no post for it (older than its 7-day record): nothing to change.
212
+ if (res.status === 404)
213
+ continue;
214
+ if (!res.ok) {
215
+ log({ event: 'release-edit', outcome: 'failed', version: notes.version, status: res.status, reply: reply.slice(0, 500) });
216
+ continue;
217
+ }
218
+ const answer = JSON.parse(reply);
219
+ if (answer.edited !== true)
220
+ continue;
221
+ log({
222
+ event: 'release-edit',
223
+ outcome: 'edited',
224
+ version: notes.version,
225
+ ...(answer.unresolved !== undefined && answer.unresolved.length > 0 ? { unresolved: answer.unresolved } : {}),
226
+ });
227
+ }
228
+ catch (err) {
229
+ log({ event: 'release-edit', outcome: 'failed', version: notes.version, error: err instanceof Error ? err.message : String(err) });
230
+ }
231
+ }
232
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * The boot's own stopwatch: one `boot-phase` line per step between process start and listen, each
3
+ * with its own `ms` and the `at` since the process began (`performance.now()`'s origin), logged as
4
+ * the step ends. A boot that never listens has printed every phase it finished, so the last line
5
+ * names the step after it as the one it is stuck in.
6
+ */
7
+ export declare function bootClock(log: (entry: Record<string, unknown>) => void): (phase: string) => void;
@@ -0,0 +1,14 @@
1
+ /**
2
+ * The boot's own stopwatch: one `boot-phase` line per step between process start and listen, each
3
+ * with its own `ms` and the `at` since the process began (`performance.now()`'s origin), logged as
4
+ * the step ends. A boot that never listens has printed every phase it finished, so the last line
5
+ * names the step after it as the one it is stuck in.
6
+ */
7
+ export function bootClock(log) {
8
+ let last = 0;
9
+ return (phase) => {
10
+ const now = performance.now();
11
+ log({ evt: 'boot-phase', phase, ms: Math.round(now - last), at: Math.round(now) });
12
+ last = now;
13
+ };
14
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * "Would restarting this server destroy work right now?"
3
+ *
4
+ * hadoku_site's deploy asks this before it stops an app. mgmt-api owns the
5
+ * contract (`services/mgmt-api/src/deploy/wait-for-idle.ts`, and the "Deferred
6
+ * deploys" section of its AGENTS.md); this is one app's answer. A deploy that
7
+ * finds it busy is PARKED and runs when the answer turns idle, instead of
8
+ * SIGKILLing a live run.
9
+ *
10
+ * What counts as "busy work" is entirely the app's: this file knows no run
11
+ * kind, only the report's shape and the plumbing around it. The app supplies
12
+ * a `report` function that returns a {@link BusyReport}; everything below is
13
+ * generic across what that report counts.
14
+ *
15
+ * ## Who may ask — and why this is not a tier
16
+ *
17
+ * `/health` is the one route that legitimately sits above the tier floor. This
18
+ * sits above it too, because the caller is the deploy runner on this host, which
19
+ * holds no session key. It stays inside a member-gate app's rule by answering
20
+ * ONLY a genuinely local caller and returning a bare 404 — not 403, which would
21
+ * confirm the route exists — to everyone else.
22
+ *
23
+ * "Local" cannot be decided by source address alone: cloudflared connects from
24
+ * loopback for EVERY request, edge-routed or direct-to-the-tunnel-hostname, so
25
+ * `remoteAddress` is 127.0.0.1 for the whole internet. What does tell them apart is
26
+ * that anything which came through Cloudflare carries headers Cloudflare itself
27
+ * adds (`cf-ray`, `cf-connecting-ip`, …) and a caller cannot strip headers added
28
+ * after it sent the request. So: loopback socket AND none of those headers.
29
+ *
30
+ * ## No names
31
+ *
32
+ * A run's `label` is opaque text, never a display name, so an admin deploy log
33
+ * never carries one — that discipline is the app's `report` function's.
34
+ *
35
+ * ## Fail SAFE on our own error
36
+ *
37
+ * mgmt-api treats an unanswerable endpoint as "not busy" and deploys immediately,
38
+ * which is right for a dead service and wrong for a live one whose count failed to
39
+ * compute. If `report` throws, this answers BUSY with one `unknown` run: the
40
+ * deferral is bounded and an operator can force past it, whereas a wrongly-idle
41
+ * answer could end a live run.
42
+ */
43
+ import type { Request, RequestHandler } from 'express';
44
+ export interface BusyRun {
45
+ id: string;
46
+ /** The app's own label for what kind of run this is. */
47
+ kind: string;
48
+ label: string;
49
+ }
50
+ export interface BusyReport {
51
+ busy: boolean;
52
+ count: number;
53
+ runs: BusyRun[];
54
+ }
55
+ export declare function isLocalProbe(req: Pick<Request, 'socket' | 'headers'>): boolean;
56
+ /** `report` computes the app's own `BusyReport`; this adds the local-only gate and fail-safe. */
57
+ export declare function busyHandler(report: () => BusyReport): RequestHandler;
@@ -0,0 +1,47 @@
1
+ const LOOPBACK = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1']);
2
+ /**
3
+ * Headers that mean "this request came through Cloudflare or the edge". Cloudflare
4
+ * adds the first group to every proxied request, tunnel-hostname traffic included;
5
+ * edge-router stamps the last. A local caller sends none of them.
6
+ */
7
+ const PROXIED_HEADERS = [
8
+ 'cf-ray',
9
+ 'cf-connecting-ip',
10
+ 'cf-visitor',
11
+ 'cf-ipcountry',
12
+ 'x-forwarded-for',
13
+ 'x-forwarded-host',
14
+ 'x-forwarded-proto',
15
+ 'x-real-ip',
16
+ 'forwarded',
17
+ 'x-edge-auth',
18
+ ];
19
+ export function isLocalProbe(req) {
20
+ const addr = req.socket?.remoteAddress;
21
+ if (addr === undefined || !LOOPBACK.has(addr))
22
+ return false;
23
+ return !PROXIED_HEADERS.some((h) => req.headers[h] !== undefined);
24
+ }
25
+ /** `report` computes the app's own `BusyReport`; this adds the local-only gate and fail-safe. */
26
+ export function busyHandler(report) {
27
+ return (req, res) => {
28
+ if (!isLocalProbe(req)) {
29
+ // Bare 404: not 403, which would confirm there is something here.
30
+ res.status(404).end();
31
+ return;
32
+ }
33
+ let out;
34
+ try {
35
+ out = report();
36
+ }
37
+ catch {
38
+ out = {
39
+ busy: true,
40
+ count: 1,
41
+ runs: [{ id: 'unknown', kind: 'unknown', label: 'could not determine what is running' }],
42
+ };
43
+ }
44
+ res.setHeader('cache-control', 'no-store');
45
+ res.json(out);
46
+ };
47
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Let what was just emitted reach the wire before the sockets are closed.
3
+ *
4
+ * `io.close()` closes every engine.io connection with `close(true)`, which
5
+ * DISCARDS its write buffer. A packet stays in that buffer when the
6
+ * connection's transport is not writable at the moment of the emit: a
7
+ * websocket still waiting on its previous send's callback, or a polling client
8
+ * between two GETs. A restart notice emitted just before the close would then
9
+ * be thrown away and the screen read "Reconnecting…" after all.
10
+ *
11
+ * engine.io hands a buffer to the transport synchronously when it can
12
+ * (`Socket.sendPacket` → `flush`, which emits `flush`), so a connection that
13
+ * did not flush during `send` still holds the packet, and gives it up on its
14
+ * next `drain`. Only those are waited for: a transport that is merely busy
15
+ * after flushing (a websocket awaiting its callback, a poll just answered)
16
+ * already has the packet, and its close comes after it.
17
+ */
18
+ /** The part of an engine.io `Socket` (a socket.io socket's `conn`) this needs. */
19
+ export interface Flushable {
20
+ once(event: 'drain' | 'close', listener: () => void): unknown;
21
+ on(event: 'flush', listener: () => void): unknown;
22
+ off(event: 'flush', listener: () => void): unknown;
23
+ }
24
+ /**
25
+ * Run `send`, then resolve once every connection that could not write it at
26
+ * once has written it (or closed), or after `ms`.
27
+ */
28
+ export declare function sendThenFlush(conns: Iterable<Flushable>, send: () => void, ms: number): Promise<void>;
29
+ /** The part of a node `http.Server` {@link closeConnections} needs. */
30
+ export interface Closable {
31
+ closeIdleConnections(): void;
32
+ closeAllConnections(): void;
33
+ }
34
+ /**
35
+ * Let a closing server's connections go: the idle ones at once, and every one after `graceMs`, so
36
+ * a request already in flight is answered. `server.close` (which `io.close` calls) only stops new
37
+ * connections and waits for the open ones, and a keep-alive connection through cloudflared that a
38
+ * page keeps busy never goes idle: one restart was still answering requests a second in, and pm2
39
+ * SIGKILLed it at its `kill_timeout` before `shutdown-done`.
40
+ */
41
+ export declare function closeConnections(server: Closable, graceMs: number): void;
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Let what was just emitted reach the wire before the sockets are closed.
3
+ *
4
+ * `io.close()` closes every engine.io connection with `close(true)`, which
5
+ * DISCARDS its write buffer. A packet stays in that buffer when the
6
+ * connection's transport is not writable at the moment of the emit: a
7
+ * websocket still waiting on its previous send's callback, or a polling client
8
+ * between two GETs. A restart notice emitted just before the close would then
9
+ * be thrown away and the screen read "Reconnecting…" after all.
10
+ *
11
+ * engine.io hands a buffer to the transport synchronously when it can
12
+ * (`Socket.sendPacket` → `flush`, which emits `flush`), so a connection that
13
+ * did not flush during `send` still holds the packet, and gives it up on its
14
+ * next `drain`. Only those are waited for: a transport that is merely busy
15
+ * after flushing (a websocket awaiting its callback, a poll just answered)
16
+ * already has the packet, and its close comes after it.
17
+ */
18
+ /**
19
+ * Run `send`, then resolve once every connection that could not write it at
20
+ * once has written it (or closed), or after `ms`.
21
+ */
22
+ export function sendThenFlush(conns, send, ms) {
23
+ const watched = [...conns].map((conn) => {
24
+ const seen = { conn, flushed: false, mark: () => (seen.flushed = true) };
25
+ conn.on('flush', seen.mark);
26
+ return seen;
27
+ });
28
+ try {
29
+ send();
30
+ }
31
+ finally {
32
+ for (const w of watched)
33
+ w.conn.off('flush', w.mark);
34
+ }
35
+ const waits = watched
36
+ .filter((w) => !w.flushed)
37
+ .map(({ conn }) => new Promise((done) => {
38
+ conn.once('drain', done);
39
+ conn.once('close', done);
40
+ }));
41
+ if (waits.length === 0)
42
+ return Promise.resolve();
43
+ return new Promise((done) => {
44
+ const bound = setTimeout(done, ms);
45
+ void Promise.all(waits).then(() => {
46
+ clearTimeout(bound);
47
+ done();
48
+ });
49
+ });
50
+ }
51
+ /**
52
+ * Let a closing server's connections go: the idle ones at once, and every one after `graceMs`, so
53
+ * a request already in flight is answered. `server.close` (which `io.close` calls) only stops new
54
+ * connections and waits for the open ones, and a keep-alive connection through cloudflared that a
55
+ * page keeps busy never goes idle: one restart was still answering requests a second in, and pm2
56
+ * SIGKILLed it at its `kill_timeout` before `shutdown-done`.
57
+ */
58
+ export function closeConnections(server, graceMs) {
59
+ server.closeIdleConnections();
60
+ setTimeout(() => server.closeAllConnections(), graceMs).unref();
61
+ }
@@ -0,0 +1,4 @@
1
+ export { busyHandler, isLocalProbe, type BusyReport, type BusyRun } from './busy.js';
2
+ export { bootClock } from './bootClock.js';
3
+ export { closeConnections, sendThenFlush, type Closable, type Flushable } from './flush-sockets.js';
4
+ export { announceRelease, EDITABLE_RELEASES, forDiscord, latestRelease, mentionsIn, parseNotes, recentReleases, releaseEmbeds, splitBody, type AnnounceOptions, type Embed, type ReleaseNotes, type ReleaseOrdering, } from './announce.js';
@@ -0,0 +1,4 @@
1
+ export { busyHandler, isLocalProbe } from "./busy.js";
2
+ export { bootClock } from "./bootClock.js";
3
+ export { closeConnections, sendThenFlush } from "./flush-sockets.js";
4
+ export { announceRelease, EDITABLE_RELEASES, forDiscord, latestRelease, mentionsIn, parseNotes, recentReleases, releaseEmbeds, splitBody, } from "./announce.js";
@@ -0,0 +1 @@
1
+ export { bodyErrors, costlyActLimit, makeCallerId, parseBody, perCallerLimit, readBody, type BodySchema, } from './limits.js';
@@ -0,0 +1 @@
1
+ export { bodyErrors, costlyActLimit, makeCallerId, parseBody, perCallerLimit, readBody, } from "./limits.js";
@@ -0,0 +1,76 @@
1
+ /**
2
+ * What every HTTP route reads off a request before its own work: who is asking, and what the
3
+ * body says. Knows no game's identity scheme — a caller's id comes from an injected deriver
4
+ * (`makeCallerId`), never a direct import, so a second game can bring its own.
5
+ */
6
+ import type { ParticipantId } from '@wolffm/rooms-protocol';
7
+ import { type RateLimitConfig } from '@wolffm/rooms-server';
8
+ import type { ErrorRequestHandler, Request, Response } from 'express';
9
+ import { callerContext } from '../telemetry/telemetry.js';
10
+ /**
11
+ * A request's caller id, from an injected deriver over the request's headers (the app's own
12
+ * identity scheme — platform, but a parameter here rather than an import). A request the
13
+ * deriver can't place gets a fresh ephemeral fallback, which matches no seat and no member —
14
+ * the right answer for someone the edge could not name.
15
+ */
16
+ export declare function makeCallerId(deriveId: (headers: Request['headers'], fallback: string) => ParticipantId): (req: Request) => ParticipantId;
17
+ /**
18
+ * A per-caller token bucket for a costly step: each pass reports `costly-act` and each refusal
19
+ * `rate-limited`, both named by `act`, so telemetry shows who is spending the expensive steps and
20
+ * who hit the ceiling (the telemetry sink throttles the posts). `headers` are the caller's own,
21
+ * for the report. `null` is no limit — an e2e stack that disables rate limits for one test identity
22
+ * so it can make every room and start every match of a run.
23
+ */
24
+ export declare function costlyActLimit(cfg: RateLimitConfig | null, opts: {
25
+ act: string;
26
+ now?: () => number;
27
+ onRefused?: () => void;
28
+ }): (caller: ParticipantId, headers: Parameters<typeof callerContext>[0]) => boolean;
29
+ /**
30
+ * {@link costlyActLimit} for a route: false once the 429 (with `Retry-After`) is sent. A route
31
+ * asks AFTER reading its body, so only a request that would do the work spends a token.
32
+ * `callerId` is the request's caller id — see {@link makeCallerId}.
33
+ */
34
+ export declare function perCallerLimit(cfg: RateLimitConfig | null, refusal: string, opts: {
35
+ act: string;
36
+ callerId: (req: Request) => ParticipantId;
37
+ now?: () => number;
38
+ onRefused?: () => void;
39
+ }): (req: Request, res: Response) => boolean;
40
+ /** A request-body schema, as this module reads one — a minimal structural shape, never `@hadoku/shared` itself. */
41
+ export interface BodySchema<T> {
42
+ safeParse(body: unknown): {
43
+ success: true;
44
+ data: T;
45
+ } | {
46
+ success: false;
47
+ error: {
48
+ issues: readonly {
49
+ code: string;
50
+ path: readonly PropertyKey[];
51
+ message: string;
52
+ }[];
53
+ };
54
+ };
55
+ }
56
+ /**
57
+ * A request body by its schema, or null once the 400 is sent. The
58
+ * answer is the schema's first refusal, a field nobody named before anything
59
+ * else, in the `{ error }` body every route answers with.
60
+ */
61
+ export declare function parseBody<T>(schema: BodySchema<T>, req: Request, res: Response): T | null;
62
+ /** A body by its schema, or the refusal `parseBody` would answer with. */
63
+ export declare function readBody<T>(schema: BodySchema<T>, body: unknown): {
64
+ ok: true;
65
+ value: T;
66
+ } | {
67
+ ok: false;
68
+ error: string;
69
+ };
70
+ /**
71
+ * The error handler mounted after a router: chiefly `express.json()`'s parse
72
+ * failure, raised on the api router before the router runs. Answered without
73
+ * the parser's message, which quotes the body, and reported by name only.
74
+ * Without `onError`, any other error goes on to the next handler.
75
+ */
76
+ export declare function bodyErrors(onError?: (name: string) => void): ErrorRequestHandler;
@@ -0,0 +1,103 @@
1
+ /**
2
+ * What every HTTP route reads off a request before its own work: who is asking, and what the
3
+ * body says. Knows no game's identity scheme — a caller's id comes from an injected deriver
4
+ * (`makeCallerId`), never a direct import, so a second game can bring its own.
5
+ */
6
+ import { randomUUID } from 'node:crypto';
7
+ import { MultiRateLimiter } from '@wolffm/rooms-server';
8
+ import { callerContext, report } from "../telemetry/telemetry.js";
9
+ /**
10
+ * A request's caller id, from an injected deriver over the request's headers (the app's own
11
+ * identity scheme — platform, but a parameter here rather than an import). A request the
12
+ * deriver can't place gets a fresh ephemeral fallback, which matches no seat and no member —
13
+ * the right answer for someone the edge could not name.
14
+ */
15
+ export function makeCallerId(deriveId) {
16
+ return (req) => deriveId(req.headers, `http-${randomUUID()}`);
17
+ }
18
+ /**
19
+ * A per-caller token bucket for a costly step: each pass reports `costly-act` and each refusal
20
+ * `rate-limited`, both named by `act`, so telemetry shows who is spending the expensive steps and
21
+ * who hit the ceiling (the telemetry sink throttles the posts). `headers` are the caller's own,
22
+ * for the report. `null` is no limit — an e2e stack that disables rate limits for one test identity
23
+ * so it can make every room and start every match of a run.
24
+ */
25
+ export function costlyActLimit(cfg, opts) {
26
+ if (cfg === null)
27
+ return () => true;
28
+ const limiter = new MultiRateLimiter(cfg);
29
+ const now = opts.now ?? Date.now;
30
+ return (caller, headers) => {
31
+ if (limiter.allow(caller, 1, now())) {
32
+ report('costly-act', { act: opts.act, ...callerContext(headers) });
33
+ return true;
34
+ }
35
+ report('rate-limited', { act: opts.act, ...callerContext(headers) });
36
+ opts.onRefused?.();
37
+ return false;
38
+ };
39
+ }
40
+ /**
41
+ * {@link costlyActLimit} for a route: false once the 429 (with `Retry-After`) is sent. A route
42
+ * asks AFTER reading its body, so only a request that would do the work spends a token.
43
+ * `callerId` is the request's caller id — see {@link makeCallerId}.
44
+ */
45
+ export function perCallerLimit(cfg, refusal, opts) {
46
+ if (cfg === null)
47
+ return () => true;
48
+ const allow = costlyActLimit(cfg, opts);
49
+ const retryAfter = String(Math.ceil(cfg.refillIntervalMs / 1000));
50
+ return (req, res) => {
51
+ if (allow(opts.callerId(req), req.headers))
52
+ return true;
53
+ res.setHeader('Retry-After', retryAfter);
54
+ res.status(429).json({ error: refusal });
55
+ return false;
56
+ };
57
+ }
58
+ /**
59
+ * A request body by its schema, or null once the 400 is sent. The
60
+ * answer is the schema's first refusal, a field nobody named before anything
61
+ * else, in the `{ error }` body every route answers with.
62
+ */
63
+ export function parseBody(schema, req, res) {
64
+ const parsed = readBody(schema, req.body);
65
+ if (parsed.ok)
66
+ return parsed.value;
67
+ res.status(400).json({ error: parsed.error });
68
+ return null;
69
+ }
70
+ /** A body by its schema, or the refusal `parseBody` would answer with. */
71
+ export function readBody(schema, body) {
72
+ const parsed = schema.safeParse(body);
73
+ if (parsed.success)
74
+ return { ok: true, value: parsed.data };
75
+ const { issues } = parsed.error;
76
+ const issue = issues.find((i) => i.code === 'unrecognized_keys' && i.path.length === 0) ?? issues[0];
77
+ return { ok: false, error: issue?.message ?? 'the request body could not be read' };
78
+ }
79
+ /**
80
+ * The error handler mounted after a router: chiefly `express.json()`'s parse
81
+ * failure, raised on the api router before the router runs. Answered without
82
+ * the parser's message, which quotes the body, and reported by name only.
83
+ * Without `onError`, any other error goes on to the next handler.
84
+ */
85
+ export function bodyErrors(onError) {
86
+ return (err, _req, res, next) => {
87
+ if (res.headersSent)
88
+ return next(err);
89
+ const e = err;
90
+ if (e.type === 'entity.parse.failed') {
91
+ res.status(400).json({ error: 'body is not valid JSON' });
92
+ return;
93
+ }
94
+ if (typeof e.status === 'number' && e.status >= 400 && e.status < 500) {
95
+ res.status(400).json({ error: 'the request body could not be read' });
96
+ return;
97
+ }
98
+ if (onError === undefined)
99
+ return next(err);
100
+ onError(typeof e.name === 'string' ? e.name : 'unknown');
101
+ res.status(500).json({ error: 'internal error' });
102
+ };
103
+ }
@@ -0,0 +1 @@
1
+ export { jsonLogger, type JsonLog } from './log.js';
@@ -0,0 +1 @@
1
+ export { jsonLogger } from "./log.js";