@ultimat3/core 25.2.0 → 26.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/README.md CHANGED
@@ -55,6 +55,7 @@ Zero dependencies, zero `@ultimat3/*` imports.
55
55
  | `Clock` — the only source of "now" | `clock.ts` |
56
56
  | UUIDv7, nanoid, branded ids | `ids.ts` |
57
57
  | structured JSON logging + redaction; `setLogSink(sink)` — the test seam that sends every default-writer line to a sink instead of the process's streams (a test preload drops them; a test asserting on the process logger collects them) | `logger.ts` |
58
+ | `addLogSink(sink)` — the supported tee: every default-writer line, after redaction, to `sink` **beside** the streams (or a `setLogSink` seam), never instead; returns the unsubscribe. A sink that throws is skipped and reported once (`log.sink_failed`); a line a sink logs is not teed back | `log-tee.ts` |
58
59
  | OTel-shaped spans, always on, no-op by default | `telemetry.ts` |
59
60
  | the sampling decision, and `OTEL_TRACES_SAMPLER*` | `sampler.ts` |
60
61
  | OTLP/HTTP JSON: endpoint, headers, value encoding | `otlp.ts` |
@@ -70,7 +71,7 @@ Zero dependencies, zero `@ultimat3/*` imports.
70
71
  | graceful drain, `/healthz`, `/readyz` | `lifecycle.ts` |
71
72
  | what a health endpoint tells whom — `healthBody(report, role, detailed)`, `healthPeerListed(peers, address)`, `DEFAULT_HEALTH_DETAIL_PEERS`; the one rule `@ultimat3/http` and the sync node's own listener both call | `health-disclosure.ts` |
72
73
  | the readiness grace between `/readyz` → 503 and the listener closing (`drain.readinessGraceMs`) | `lifecycle-grace.ts` |
73
- | the drain budget's default and domain (`drain.deadlineMs`, 25 s, 1–3600000 ms) — `DRAIN_DEADLINE_DEFAULT_MS`, `DRAIN_DEADLINE_MAX_MS` | `drain-deadline.ts` |
74
+ | the drain budget's default and domain (`drain.deadlineMs`, 25 s, 1–3600000 ms; the worker's optional `drain.workerDeadlineMs`, 1–86400000 ms) — `DRAIN_DEADLINE_DEFAULT_MS`, `DRAIN_DEADLINE_MAX_MS`, `WORKER_DRAIN_DEADLINE_MAX_MS` | `drain-deadline.ts` |
74
75
  | `jobs.concurrency`'s default and domain — one slot count for every queue a worker serves, or a table per queue (`{ banks: 4, 'banks-long': 2 }`; a queue the table does not name runs at the default). `JOBS_CONCURRENCY_DEFAULT` (8), `type JobsConcurrency` | `config-jobs.ts` |
75
76
  | SIGTERM/SIGINT → the one drain; on Windows also SIGHUP (console close) and SIGBREAK (Ctrl-Break) — `drainSignals(platform)` | `lifecycle-signals.ts` |
76
77
  | is this directory inside a `bun build --compile` binary? `isCompiledBundle(import.meta.dir)` — `/$bunfs/` and Windows' `B:\~BUN\` | `bunfs.ts` |
@@ -485,6 +486,10 @@ match with `sealAll()`; uniqueness cannot be held across keys.
485
486
  once has to keep it: a discarded one is a hook per `start()`, each retaining the resource it
486
487
  was going to drain, and the next drain runs every one of them against a torn-down copy.
487
488
  `shutdownHookCount()` is the test-only probe that makes the leak assertable.
489
+ - `isRetiring()` is true from the moment a worker's retire begins (SIGUSR2 in production, `x dev`'s
490
+ restart) for the rest of the process — the retire finishes every held job BEFORE the drain, so
491
+ `isDraining()` is still false the whole time. Read it where the app reports its own state (a
492
+ heartbeat). `markRetiring()` is the retire's own call; there is no un-retire.
488
493
  - `registerReadinessCheck(name, check)` is what makes `/readyz` mean **usable** rather than
489
494
  **bound**. `ReadinessCheck` is `() => boolean` and must stay synchronous — a probe that awaits its
490
495
  dependency turns a slow dependency into a wedged endpoint and then a restart loop; keep a boolean
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/core",
3
- "version": "25.2.0",
3
+ "version": "26.1.0",
4
4
  "description": "Ultimate's foundation: errors, context, env, config, clock, ids, logging, telemetry, lifecycle",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -37,6 +37,6 @@
37
37
  "test": "bun test"
38
38
  },
39
39
  "dependencies": {
40
- "@ultimat3/schema": "25.2.0"
40
+ "@ultimat3/schema": "26.1.0"
41
41
  }
42
42
  }
@@ -47,7 +47,12 @@ export function configDefaults(name: string): Omit<AppConfig, Sectioned> {
47
47
  notify: { inboxReadRetentionMs: undefined, inboxUnreadRetentionMs: undefined },
48
48
  ai: { mcp: { expose: true } },
49
49
  // Read from the process env when the config is DEFINED — the same env the drain will run in.
50
- drain: { readinessGraceMs: defaultReadinessGraceMs(), deadlineMs: DRAIN_DEADLINE_DEFAULT_MS },
50
+ drain: {
51
+ readinessGraceMs: defaultReadinessGraceMs(),
52
+ deadlineMs: DRAIN_DEADLINE_DEFAULT_MS,
53
+ // Unset: the worker drains on `deadlineMs`, like every role.
54
+ workerDeadlineMs: undefined,
55
+ },
51
56
  health: { readiness: 'dependencies' },
52
57
  };
53
58
  }
@@ -40,9 +40,17 @@ export interface DrainConfig {
40
40
  * The drain budget: how long a SIGTERM'd process has to finish what it holds — in-flight requests,
41
41
  * a running job — after the grace, before the lifecycle abandons the rest. Applied to EVERY role,
42
42
  * so this is the knob that gives a long job room to finish on a deploy. Default 25000. A whole
43
- * number, 1–3600000. The ONE drain budget, the web role's included.
43
+ * number, 1–3600000. The ONE drain budget, the web role's included — except the worker's when
44
+ * `workerDeadlineMs` is set.
44
45
  */
45
46
  readonly deadlineMs: number;
47
+ /**
48
+ * `ROLE=worker`'s drain budget, in place of `deadlineMs`, for a job that must finish rather than
49
+ * be cut off and replayed and may run past the hour `deadlineMs` is capped at. It also bounds a
50
+ * retire (SIGUSR2) that a SIGTERM lands in. Unset (the default): the worker drains on
51
+ * `deadlineMs`. A whole number, 1–86400000. The chart's worker grace period is derived from it.
52
+ */
53
+ readonly workerDeadlineMs: number | undefined;
46
54
  }
47
55
 
48
56
  /** Why a value is not a readiness mode, or `undefined` when it is one. */
@@ -12,6 +12,7 @@ import type {
12
12
  PwaSchemeColors,
13
13
  PwaScreenshot,
14
14
  PwaShortcut,
15
+ PwaVapidConfig,
15
16
  } from './config-pwa';
16
17
  import { ConfigInvalidError } from './errors';
17
18
  import { isJsonObject } from './json-object';
@@ -42,6 +43,7 @@ export const SECTION_KEYS: Readonly<Record<string, readonly string[]>> = Object.
42
43
  'offline',
43
44
  'backgroundSync',
44
45
  'push',
46
+ 'vapid',
45
47
  'name',
46
48
  'colors',
47
49
  'id',
@@ -50,6 +52,7 @@ export const SECTION_KEYS: Readonly<Record<string, readonly string[]>> = Object.
50
52
  'shortcuts',
51
53
  'screenshots',
52
54
  ]),
55
+ 'pwa.vapid': keysOf<PwaVapidConfig>()(['subject', 'pushHosts']),
53
56
  'pwa.colors': keysOf<PwaColors>()(['light', 'dark']),
54
57
  'pwa.colors.light': keysOf<PwaSchemeColors>()(['themeColor', 'backgroundColor']),
55
58
  'pwa.colors.dark': keysOf<PwaSchemeColors>()(['themeColor', 'backgroundColor']),
package/src/config-pwa.ts CHANGED
@@ -45,11 +45,39 @@ export interface PwaOfflineConfig {
45
45
  readonly personalPages: 'never' | 'last-member';
46
46
  }
47
47
 
48
+ /**
49
+ * Web Push's one piece of configuration that is not a secret. The KEYS are not here: both halves
50
+ * of the VAPID pair are environment variables (`ULTIMATE_VAPID_PUBLIC_KEY`,
51
+ * `ULTIMATE_VAPID_PRIVATE_KEY`, sealed together by `x vapid create`), because a public key in
52
+ * committed config and a private key per deploy are two places that can disagree — and the
53
+ * disagreement is a 403 from every push service on every send.
54
+ */
55
+ export interface PwaVapidConfig {
56
+ /**
57
+ * `mailto:` or an `https:` URL: who a push service writes to about this server (RFC 8292 §2.1).
58
+ * Every service requires it; Apple's refuses a token without one.
59
+ */
60
+ readonly subject: string;
61
+ /**
62
+ * Push services this app sends to BEYOND the built-in ones (`@ultimat3/pwa`'s
63
+ * `PUSH_SERVICE_HOSTS`: FCM, Mozilla, Apple, WNS). Host names, each admitting its subdomains —
64
+ * a self-hosted autopush, a test's stub. An endpoint on any other host is refused when it is
65
+ * stored and never dialled (`X_PWA_PUSH_HOST_UNLISTED`): it came from a request body.
66
+ */
67
+ readonly pushHosts?: readonly string[];
68
+ }
69
+
48
70
  export interface PwaConfig {
49
71
  readonly enabled: boolean;
50
72
  readonly offline: PwaOfflineConfig;
51
73
  readonly backgroundSync: boolean;
74
+ /**
75
+ * Web Push: the `push` and `notificationclick` handlers in `sw.js`, the subscription runtime the
76
+ * boot installs, and `<meta name="x-push-key">` on every document. Requires `vapid`.
77
+ */
52
78
  readonly push: boolean;
79
+ /** Required once `push` is true, refused while it is false (a key with no reader). */
80
+ readonly vapid?: PwaVapidConfig;
53
81
  /**
54
82
  * The install title, and the one manifest member nothing can derive. `AppConfig.name` is a slug
55
83
  * (`^[a-z][a-z0-9-]{1,63}$`) and an install prompt shows a person a title, so `ledger-demo` is
@@ -217,6 +245,92 @@ export function pwaIssues(pwa: PwaConfig, issues: string[]): boolean {
217
245
  return issues.length > before;
218
246
  }
219
247
 
248
+ /** Appended only when the push half of the block is what failed. */
249
+ export const PWA_PUSH_FIX =
250
+ "set pwa.vapid: { subject: 'mailto:ops@example.com' } beside pwa.push: true in app.config.ts — push services write to that address about this server — then x vapid create for the key pair; or set pwa.push: false and delete pwa.vapid";
251
+
252
+ /**
253
+ * One `pwa.vapid.pushHosts` entry: a bare DNS name with a dot — no scheme, port, path or wildcard
254
+ * (a listed name admits its subdomains), never `localhost` or an IP literal, which would hand the
255
+ * push sender back the hole the list closes. Checked at boot, so `@ultimat3/pwa`'s list holds only
256
+ * names that passed it.
257
+ */
258
+ export function pushHostShape(entry: unknown): string | undefined {
259
+ if (typeof entry !== 'string') return 'is not a string';
260
+ if (
261
+ !/^(?=.{1,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z][a-z0-9-]{0,61}[a-z0-9]$/i.test(
262
+ entry,
263
+ )
264
+ ) {
265
+ return 'is not a host name like push.example.com (no scheme, port, path or wildcard)';
266
+ }
267
+ if (/\.localhost$/i.test(entry)) return 'names this machine';
268
+ return undefined;
269
+ }
270
+
271
+ /** `mailto:someone` or an absolute `https:` URL — RFC 8292's two forms, nothing else. */
272
+ const isVapidSubject = (value: unknown): boolean => {
273
+ if (typeof value !== 'string') return false;
274
+ if (/^mailto:[^\s@]+@[^\s@]+$/.test(value)) return true;
275
+ try {
276
+ return new URL(value).protocol === 'https:';
277
+ } catch {
278
+ return false;
279
+ }
280
+ };
281
+
282
+ /**
283
+ * The push rules, apart from `pwaIssues` because their remedy is another sentence. Push asks for
284
+ * nothing unless the app is installable, exactly like the rest of the block — `enabled: false`
285
+ * emits no worker for a push handler to live in.
286
+ */
287
+ export function pwaPushIssues(pwa: PwaConfig, issues: string[]): boolean {
288
+ if (!pwa.enabled) return false;
289
+ const before = issues.length;
290
+ const vapid: unknown = pwa.vapid;
291
+ if (!pwa.push) {
292
+ if (vapid !== undefined) {
293
+ issues.push('pwa.vapid is set while pwa.push is false, so nothing reads it');
294
+ }
295
+ return issues.length > before;
296
+ }
297
+ // `push: true` with no `vapid` at all booted on 26.0.0, where the key wired nothing — so in 26.x
298
+ // it still boots, with push left unwired (`pushWired`) and the boot saying so. 27.0.0 refuses it.
299
+ if (vapid === undefined) return false;
300
+ const subject: unknown =
301
+ vapid !== null && typeof vapid === 'object' ? (vapid as { subject?: unknown }).subject : vapid;
302
+ if (!isVapidSubject(subject)) {
303
+ issues.push(
304
+ `pwa.vapid.subject is required when pwa.push is true and must be a mailto: address or an https: URL, and is ${describeValue(subject)}`,
305
+ );
306
+ }
307
+ const hosts: unknown =
308
+ vapid !== null && typeof vapid === 'object'
309
+ ? (vapid as { pushHosts?: unknown }).pushHosts
310
+ : undefined;
311
+ if (hosts !== undefined) {
312
+ if (!Array.isArray(hosts)) {
313
+ issues.push(`pwa.vapid.pushHosts must be a list of host names, not ${describeValue(hosts)}`);
314
+ } else {
315
+ for (const host of hosts as readonly unknown[]) {
316
+ const problem = pushHostShape(host);
317
+ if (problem !== undefined) {
318
+ issues.push(`pwa.vapid.pushHosts contains ${describeValue(host)}, which ${problem}`);
319
+ }
320
+ }
321
+ }
322
+ }
323
+ return issues.length > before;
324
+ }
325
+
326
+ /**
327
+ * Whether this config wires Web Push: an installable app, `push: true`, and a `vapid` block. The
328
+ * ONE answer the boot, the worker, `.env.example` and the drift gate read — `push: true` without
329
+ * `vapid` is accepted in 26.x (it was on 26.0.0) and wires nothing.
330
+ */
331
+ export const pushWired = (pwa: PwaConfig): boolean =>
332
+ pwa.enabled && pwa.push && pwa.vapid !== undefined;
333
+
220
334
  /** A path the manifest names must be absolute: a relative one resolves against the manifest's URL. */
221
335
  const absolute = (value: unknown): boolean => typeof value === 'string' && value.startsWith('/');
222
336
 
package/src/config.ts CHANGED
@@ -20,7 +20,7 @@ import { type Input, lastSaid, layered } from './config-merge';
20
20
  import type { NavigationConfig, NavigationSectionInput } from './config-navigation';
21
21
  import { mergeNavigation, navigationIssues } from './config-navigation';
22
22
  import type { PwaConfig, PwaOfflineConfig } from './config-pwa';
23
- import { PWA_FIX, pwaIssues } from './config-pwa';
23
+ import { PWA_FIX, PWA_PUSH_FIX, pwaIssues, pwaPushIssues } from './config-pwa';
24
24
  import { removedKeyFix, removedKeyIssue, removedKeysIn } from './config-removed';
25
25
  import {
26
26
  booleanIssue,
@@ -318,6 +318,7 @@ function validate(config: AppConfig): void {
318
318
  // What an install needs, asked at BOOT and not at emit — `config-pwa.ts` owns the rules and the
319
319
  // remedy: `pwa.enabled` turning four other requirements on is a question about that block alone.
320
320
  if (pwaIssues(config.pwa, issues)) pwaFix.push(PWA_FIX);
321
+ if (pwaPushIssues(config.pwa, issues)) pwaFix.push(PWA_PUSH_FIX);
321
322
  siteIssues(config, issues);
322
323
  navigationIssues(config, issues);
323
324
  islandsIssues(config, issues);
@@ -1,5 +1,5 @@
1
- // Single responsibility: the drain budget's default and its domain — `drain.deadlineMs` in
2
- // `app.config.ts`. One module because the config validator, the lifecycle's own default and the
1
+ // Single responsibility: the drain budget's default and its domain — `drain.deadlineMs` and the
2
+ // worker's `drain.workerDeadlineMs` in `app.config.ts`. One module because the config validator, the lifecycle's own default and the
3
3
  // chart's grace period must all mean the same number.
4
4
 
5
5
  import { countIssue } from './config-count';
@@ -19,7 +19,15 @@ export const DRAIN_DEADLINE_DEFAULT_MS = 25_000;
19
19
  */
20
20
  export const DRAIN_DEADLINE_MAX_MS = 3_600_000;
21
21
 
22
+ /**
23
+ * A day: `drain.workerDeadlineMs`'s ceiling. The worker is the one role whose in-flight unit — a
24
+ * job that must not run twice, a bank login — can outlast the hour, and a retired worker
25
+ * (`SIGUSR2`) is bound by this budget when a SIGTERM lands mid-retire.
26
+ */
27
+ export const WORKER_DRAIN_DEADLINE_MAX_MS = 86_400_000;
28
+
22
29
  const DEADLINE_KEY = 'drain.deadlineMs';
30
+ const WORKER_DEADLINE_KEY = 'drain.workerDeadlineMs';
23
31
 
24
32
  /**
25
33
  * Why a value is not a drain budget, or `undefined` when it is one. A whole number of milliseconds
@@ -34,10 +42,28 @@ export function drainDeadlineIssue(value: unknown): string | undefined {
34
42
  : undefined;
35
43
  }
36
44
 
45
+ /**
46
+ * Why a value is not the worker's drain budget, or `undefined` when it is one (or is unset — the
47
+ * worker then drains on `drain.deadlineMs`). A whole number in `1 ≤ v ≤ 86400000`.
48
+ */
49
+ export function workerDrainDeadlineIssue(value: unknown): string | undefined {
50
+ if (value === undefined) return undefined;
51
+ const count = countIssue(WORKER_DEADLINE_KEY, value, 1);
52
+ if (count !== undefined) return count;
53
+ return (value as number) > WORKER_DRAIN_DEADLINE_MAX_MS
54
+ ? `${WORKER_DEADLINE_KEY} must be at most ${WORKER_DRAIN_DEADLINE_MAX_MS} milliseconds (a day) — a job longer than that belongs in steps, which replay instead of re-running`
55
+ : undefined;
56
+ }
57
+
37
58
  /** The `drain` section's issues, one per key, for `defineConfig`'s validator. */
38
59
  export function drainIssues(drain: {
39
60
  readonly readinessGraceMs: unknown;
40
61
  readonly deadlineMs: unknown;
62
+ readonly workerDeadlineMs?: unknown;
41
63
  }): readonly (string | undefined)[] {
42
- return [readinessGraceIssue(drain.readinessGraceMs), drainDeadlineIssue(drain.deadlineMs)];
64
+ return [
65
+ readinessGraceIssue(drain.readinessGraceMs),
66
+ drainDeadlineIssue(drain.deadlineMs),
67
+ workerDrainDeadlineIssue(drain.workerDeadlineMs),
68
+ ];
43
69
  }
package/src/index.ts CHANGED
@@ -171,8 +171,15 @@ export type {
171
171
  PwaScreenshot,
172
172
  PwaShortcut,
173
173
  PwaText,
174
+ PwaVapidConfig,
175
+ } from './config-pwa';
176
+ export {
177
+ isSameOriginPath,
178
+ PWA_COLOR_KEYS,
179
+ PWA_PUSH_FIX,
180
+ PWA_SCHEMES,
181
+ pushWired,
174
182
  } from './config-pwa';
175
- export { isSameOriginPath, PWA_COLOR_KEYS, PWA_SCHEMES } from './config-pwa';
176
183
  export type {
177
184
  SeoConfig,
178
185
  SeoConfigInput,
@@ -225,7 +232,11 @@ export {
225
232
  CursorSecretDevError,
226
233
  devSecretsRefused,
227
234
  } from './dev-secrets';
228
- export { DRAIN_DEADLINE_DEFAULT_MS, DRAIN_DEADLINE_MAX_MS } from './drain-deadline';
235
+ export {
236
+ DRAIN_DEADLINE_DEFAULT_MS,
237
+ DRAIN_DEADLINE_MAX_MS,
238
+ WORKER_DRAIN_DEADLINE_MAX_MS,
239
+ } from './drain-deadline';
229
240
  export type {
230
241
  Env,
231
242
  EnvBooleanVar,
@@ -613,8 +624,10 @@ export {
613
624
  idleWaiterCount,
614
625
  inflightCount,
615
626
  isDraining,
627
+ isRetiring,
616
628
  lifecycleState,
617
629
  markReady,
630
+ markRetiring,
618
631
  onShutdown,
619
632
  readinessCheckCount,
620
633
  readinessChecks,
@@ -638,6 +651,8 @@ export type { Direction } from './locale-direction';
638
651
  export { directionOf, isRtl } from './locale-direction';
639
652
  export type { LocalePathSplit } from './locale-path';
640
653
  export { localeSegment, localizePath, splitLocalePath } from './locale-path';
654
+ // The supported tee: every default-writer line, after redaction, beside the streams.
655
+ export { addLogSink } from './log-tee';
641
656
  // The process logger's test seam, beside nothing it groups with: where a default-writer line goes.
642
657
  export type { LogSink } from './logger';
643
658
  export { setLogSink } from './logger';
package/src/lifecycle.ts CHANGED
@@ -61,6 +61,8 @@ let readinessMode: ReadinessMode = 'dependencies';
61
61
  let clock: Clock = systemClock;
62
62
  let log: Logger = rootLogger;
63
63
  let state: HealthState = 'starting';
64
+ /** One-way: set by a worker's retire, never cleared but by `resetLifecycle()`. */
65
+ let retiring = false;
64
66
  let startedAtMono = clock.monotonic();
65
67
  let inflight = 0;
66
68
  /** Bumped by `resetLifecycle()`: a `beginWork()` finisher only ever counts down its own lifetime. */
@@ -215,6 +217,22 @@ export function isDraining(): boolean {
215
217
  return state === 'draining' || state === 'stopped';
216
218
  }
217
219
 
220
+ /**
221
+ * True from the moment a worker's retire begins (SIGUSR2, `@ultimat3/cli`'s `serve-retire.ts`) for
222
+ * the rest of the process. The retire stops claiming and finishes every held job BEFORE the drain,
223
+ * so `isDraining()` stays false the whole time — this is how the app's own code (a heartbeat, a
224
+ * status row) tells "finishing up, about to exit" from "serving". Not a drain: `/readyz` and new
225
+ * work are untouched.
226
+ */
227
+ export function isRetiring(): boolean {
228
+ return retiring;
229
+ }
230
+
231
+ /** The retire's own call, made before it stops the worker. Idempotent; there is no un-retire. */
232
+ export function markRetiring(): void {
233
+ retiring = true;
234
+ }
235
+
218
236
  function waitForIdle(timeoutMs: number): Promise<boolean> {
219
237
  if (inflight === 0) return Promise.resolve(true);
220
238
  return new Promise<boolean>((resolve) => {
@@ -401,6 +419,7 @@ export function resetLifecycle(): void {
401
419
  clock = systemClock;
402
420
  log = rootLogger;
403
421
  state = 'starting';
422
+ retiring = false;
404
423
  startedAtMono = clock.monotonic();
405
424
  inflight = 0;
406
425
  lifetime += 1;
package/src/log-tee.ts ADDED
@@ -0,0 +1,73 @@
1
+ // Single responsibility: `addLogSink` — the supported tee of every default-writer log line, beside
2
+ // the process's streams and never instead of them (`setLogSink` is the test seam that replaces).
3
+ // Total: a sink that throws or logs cannot cost the line, the other sinks, or the process.
4
+
5
+ import { systemClock } from './clock';
6
+ import { renderThrowable } from './error-render';
7
+ import type { LogLevel, LogSink } from './logger';
8
+
9
+ let sinks: readonly LogSink[] = [];
10
+ /** Set while sinks run: a line a sink logs reaches the streams, never the tee it came from. */
11
+ let teeing = false;
12
+ /** Each sink says it failed ONCE — a sink that throws on every line would double the log. */
13
+ const reported = new WeakSet<LogSink>();
14
+
15
+ /**
16
+ * Tee every line the process logger writes — the complete JSON line, AFTER redaction, and its
17
+ * level — to `sink`, in addition to stdout/stderr (or a `setLogSink` test sink). Returns the
18
+ * unsubscribe; calling it twice is a no-op.
19
+ *
20
+ * The population is `setLogSink`'s: every logger without its own `writer` — `logger`, every
21
+ * `child()` of it, `ctx.logger`. Sinks run synchronously, in the order added, after the line is
22
+ * written; one added while a line is being teed sees the next line, not that one. A sink that
23
+ * throws is skipped for that line and reported once as `log.sink_failed` on the streams; a line a
24
+ * sink logs itself is written but not teed back into the sinks.
25
+ */
26
+ export function addLogSink(sink: LogSink): () => void {
27
+ sinks = [...sinks, sink];
28
+ return () => {
29
+ sinks = sinks.filter((candidate) => candidate !== sink);
30
+ };
31
+ }
32
+
33
+ /** How many sinks are teeing now — a count that climbs across a test is an unsubscribe missed. */
34
+ export function logTeeCount(): number {
35
+ return sinks.length;
36
+ }
37
+
38
+ /** Called by the logger's default writer after `write` has put the line on the streams. */
39
+ export function teeLogLine(line: string, level: LogLevel, write: LogSink): void {
40
+ if (teeing || sinks.length === 0) return;
41
+ teeing = true;
42
+ try {
43
+ for (const sink of sinks) {
44
+ try {
45
+ sink(line, level);
46
+ } catch (thrown) {
47
+ reportOnce(sink, thrown, write);
48
+ }
49
+ }
50
+ } finally {
51
+ teeing = false;
52
+ }
53
+ }
54
+
55
+ /** Straight to the streams, never through the logger: the logger is what called the sink. */
56
+ function reportOnce(sink: LogSink, thrown: unknown, write: LogSink): void {
57
+ if (reported.has(sink)) return;
58
+ reported.add(sink);
59
+ try {
60
+ write(
61
+ JSON.stringify({
62
+ ts: systemClock.now().toISOString(),
63
+ level: 'warn',
64
+ msg: 'log.sink_failed',
65
+ cause: `a log sink added with addLogSink threw ${renderThrowable(thrown)}; it is skipped for that line and still called for the next — this is reported once per sink`,
66
+ fix: 'addLogSink((line, level) => { try { store(line, level); } catch { /* drop it: a sink must not throw */ } })',
67
+ }),
68
+ 'warn',
69
+ );
70
+ } catch {
71
+ // The streams themselves are gone; there is nowhere left to say it.
72
+ }
73
+ }
package/src/logger.ts CHANGED
@@ -5,6 +5,7 @@ import { assert } from './assert';
5
5
  import { type Clock, systemClock } from './clock';
6
6
  import { renderCauseValue } from './error-render';
7
7
  import { isUltimateError } from './errors';
8
+ import { teeLogLine } from './log-tee';
8
9
  import { isSecret, REDACTED } from './secret';
9
10
 
10
11
  // Re-exported, not redefined: `secret.ts` owns the placeholder because a `Secret` has to render
@@ -219,6 +220,13 @@ export function setLogSink(sink: LogSink | undefined): LogSink | undefined {
219
220
  * its log stream, and where there is a `process` this writes to the fd as it always did.
220
221
  */
221
222
  function defaultWriter(line: string, level: LogLevel): void {
223
+ streamWriter(line, level);
224
+ // After the streams, so a sink can never cost a line its stdout (`addLogSink`, `log-tee.ts`).
225
+ teeLogLine(line, level, streamWriter);
226
+ }
227
+
228
+ /** The process's streams, or the `setLogSink` seam standing in for them. */
229
+ function streamWriter(line: string, level: LogLevel): void {
222
230
  if (logSink !== undefined) {
223
231
  logSink(line, level);
224
232
  return;
package/src/registrar.ts CHANGED
@@ -86,6 +86,8 @@ export const PRIMITIVE_FACTORIES = Object.freeze<readonly PrimitiveFactory[]>(
86
86
  { factory: 'webhook', pkg: '@ultimat3/jobs', kind: 'job' },
87
87
  { factory: 'mcpConfirmations', pkg: '@ultimat3/mcp', kind: 'action' },
88
88
  { factory: 'notifier', pkg: '@ultimat3/notify', kind: 'job' },
89
+ { factory: 'pushSubscribe', pkg: '@ultimat3/pwa', kind: 'action' },
90
+ { factory: 'pushUnsubscribe', pkg: '@ultimat3/pwa', kind: 'action' },
89
91
  { factory: 'scrape', pkg: '@ultimat3/scraping', kind: 'job' },
90
92
  ] satisfies readonly PrimitiveFactory[]
91
93
  ).map((entry) => Object.freeze(entry)),