@ultimat3/core 24.0.0 → 25.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.
Files changed (77) hide show
  1. package/CLAUDE.md +24 -27
  2. package/README.md +72 -34
  3. package/package.json +4 -7
  4. package/src/actor.ts +21 -0
  5. package/src/address-class.ts +40 -4
  6. package/src/assert.ts +9 -5
  7. package/src/audit.ts +144 -0
  8. package/src/aws-sigv4.ts +275 -0
  9. package/src/backoff.ts +16 -0
  10. package/src/bunfs.ts +17 -0
  11. package/src/client-dispatch.ts +24 -3
  12. package/src/client-flight.ts +68 -13
  13. package/src/client-problem.ts +62 -6
  14. package/src/client-retry-after.ts +47 -0
  15. package/src/client-transport.ts +3 -1
  16. package/src/client-wire.ts +27 -3
  17. package/src/config-ai.ts +32 -0
  18. package/src/config-defaults.ts +18 -11
  19. package/src/config-fixes.ts +0 -10
  20. package/src/config-health.ts +9 -2
  21. package/src/config-jobs.ts +51 -0
  22. package/src/config-keys.ts +170 -0
  23. package/src/config-mail.ts +73 -0
  24. package/src/config-merge.ts +1 -1
  25. package/src/config-navigation.ts +1 -25
  26. package/src/config-pwa.ts +42 -5
  27. package/src/config-removed.ts +131 -0
  28. package/src/config-shape.ts +0 -33
  29. package/src/config.ts +71 -94
  30. package/src/context.ts +16 -12
  31. package/src/cookie.ts +267 -3
  32. package/src/core-error-codes.ts +2 -0
  33. package/src/cursor-page.ts +41 -0
  34. package/src/cursor.ts +23 -5
  35. package/src/deprecation.ts +77 -0
  36. package/src/dev-secrets.ts +18 -7
  37. package/src/drain-deadline.ts +43 -0
  38. package/src/env-example.ts +9 -29
  39. package/src/errors.ts +18 -9
  40. package/src/exports/error-contract.ts +0 -1
  41. package/src/exports/observability.ts +1 -1
  42. package/src/exports/secrets.ts +3 -0
  43. package/src/finite-option.ts +1 -1
  44. package/src/flight-gate.ts +29 -14
  45. package/src/generation-fence.ts +1 -1
  46. package/src/ids.ts +7 -7
  47. package/src/image/canvas.ts +76 -5
  48. package/src/image/pipeline.ts +17 -5
  49. package/src/image/raster.ts +24 -1
  50. package/src/index.ts +90 -35
  51. package/src/iso-date.ts +1 -1
  52. package/src/lifecycle-errors.ts +1 -1
  53. package/src/lifecycle-readiness.ts +60 -2
  54. package/src/lifecycle-signals.ts +27 -2
  55. package/src/lifecycle-types.ts +96 -0
  56. package/src/lifecycle.ts +44 -133
  57. package/src/locale-direction.ts +1 -1
  58. package/src/logger.ts +16 -7
  59. package/src/mcp-exposure.ts +70 -8
  60. package/src/measurement-actor.ts +16 -1
  61. package/src/metric-errors.ts +32 -0
  62. package/src/metric-registry.ts +151 -0
  63. package/src/metric-series.ts +94 -0
  64. package/src/metrics.ts +9 -255
  65. package/src/page.ts +4 -2
  66. package/src/pg-executor.ts +2 -2
  67. package/src/registrar.ts +1 -0
  68. package/src/retry.ts +25 -5
  69. package/src/secrets-key-file.ts +139 -0
  70. package/src/secrets-store.ts +32 -16
  71. package/src/service.ts +5 -5
  72. package/src/single-flight.ts +1 -1
  73. package/src/telemetry.ts +1 -1
  74. package/src/theme-storage.ts +12 -0
  75. package/src/type-pins.ts +51 -1
  76. package/src/image/fixtures.ts +0 -263
  77. package/src/time-zone-name.ts +0 -14
package/src/index.ts CHANGED
@@ -5,15 +5,17 @@
5
5
  // secrets — because each is one subject spread over a dozen modules. Every name they carry is
6
6
  // still written out below: `export *` would make the contract something a reader has to resolve.
7
7
 
8
- // Anchored on purpose, and NOT by the `sideEffects` array: Bun reads any array as `false` and drops
9
- // the module regardless (oven-sh/bun#40650). This module registers @ultimat3/schema's error TITLES,
10
- // because schema is tier 0 and cannot register its own — and what reads them is `UltimateError`'s
11
- // constructor, which never imports this file. Shaken out, every X_VALIDATION_FAILED renders
12
- // untitled in the browser with nothing to say why. `SIDE_EFFECTS_ANCHORS` carries the argument and
13
- // `bun run side-effects` enforces it. `context.ts`, `lifecycle-errors.ts` and `secrets-errors.ts`
14
- // are declared side-effecting too and are deliberately NOT anchored: each is reached by whatever
15
- // uses it, and anchoring `context.ts` alone measured +3,485 B on a browser chunk for a provider a
16
- // browser can never fire.
8
+ // Anchored on purpose, and not by the `sideEffects` array alone: Bun before 1.4.1 read any array as
9
+ // `false` and dropped the module regardless (oven-sh/bun#40650), and a bare import holds on every
10
+ // bundler — the array still lists both, because one that honours it drops a bare import of a module
11
+ // it does not list. This module registers @ultimat3/schema's error TITLES, because schema is tier 0
12
+ // and cannot register its own — and what reads them is `UltimateError`'s constructor, which never
13
+ // imports this file. Shaken out, every X_VALIDATION_FAILED renders untitled in the browser with
14
+ // nothing to say why. `SIDE_EFFECTS_ANCHORS` carries the argument and `bun run side-effects`
15
+ // enforces it. `context.ts`, `lifecycle-errors.ts` and `secrets-errors.ts` also run something at
16
+ // import and are neither anchored NOR listed: only their own bindings need the effect, so they ride
17
+ // along exactly where they are used. Listed, Bun 1.4.2 kept all three in every chunk reaching this
18
+ // barrel, ~5.4 kB an island (`SIDE_EFFECTS_BY_USE` in `scripts/side-effects.ts`).
17
19
  import './core-error-codes';
18
20
  import './schema-error-codes';
19
21
 
@@ -30,9 +32,11 @@ export {
30
32
  ACTOR_KINDS,
31
33
  actorFact,
32
34
  actorLabel,
35
+ actorOf,
33
36
  actorOrigin,
34
37
  agentActor,
35
38
  anonymousActor,
39
+ grantCovers,
36
40
  hasRole,
37
41
  hasScope,
38
42
  isActorKind,
@@ -42,22 +46,45 @@ export {
42
46
  withFacts,
43
47
  } from './actor';
44
48
  export type { AddressClass } from './address-class';
45
- export { classifyAddress, isPublicAddress } from './address-class';
49
+ export { addressNetwork, classifyAddress, isPublicAddress } from './address-class';
46
50
  export { APP_VERSION_KEY, appVersion, DEFAULT_APP_VERSION } from './app-version';
47
- export { assert, assertNever, type InvariantOptions, invariant } from './assert';
51
+ export { type AssertCodedOptions, assert, assertCoded, assertNever } from './assert';
48
52
  export { type AsyncContext, asyncContext } from './async-context';
49
53
  /** The four shapes an async region can be in — produced by `realtime`, rendered by `ui`. */
50
54
  export type { AsyncState } from './async-state';
55
+ /** The audit seam `action` and `query` share: one record shape, one installed sink. */
56
+ export type {
57
+ AuditFailure,
58
+ AuditOutcome,
59
+ AuditPrimitive,
60
+ AuditRecord,
61
+ AuditSink,
62
+ AuditSurface,
63
+ } from './audit';
64
+ export {
65
+ AUDIT_RECORD_FIELDS,
66
+ getAuditSink,
67
+ resetAuditSink,
68
+ setAuditSink,
69
+ } from './audit';
70
+ export type {
71
+ AwsCredentials,
72
+ AwsPayload,
73
+ SignAwsRequestInput,
74
+ SignedAwsRequest,
75
+ } from './aws-sigv4';
76
+ export { signAwsRequest, UNSIGNED_PAYLOAD } from './aws-sigv4';
51
77
  export type { BackoffCurve, BackoffOptions, JitterMode, Random } from './backoff';
52
- export { backoffDelay } from './backoff';
78
+ export { backoffDelay, jitterStatedDelay } from './backoff';
79
+ export { isCompiledBundle } from './bunfs';
53
80
  export { CACHE_TIERS, type CacheTierName } from './cache-vocabulary';
54
81
  export { canonicalJson, fingerprint } from './canonical-json';
55
82
  export type { FetchLike, TransportRequest } from './client-dispatch';
56
- export { IDEMPOTENCY_HEADER } from './client-dispatch';
83
+ export { BUILD_ID_HEADER, IDEMPOTENCY_HEADER } from './client-dispatch';
57
84
  /**
58
85
  * Flight control for a typed client, and OPT-IN by construction: `@ultimat3/action`'s and
59
86
  * `@ultimat3/query`'s `client.ts` each name `ClientFlight` as a TYPE only, so a caller that never
60
- * mentions `createClientFlight` pays nothing for the fence, the dedup map or the retry loop.
87
+ * mentions `clientFlight` pays nothing for the fence, the dedup map or the retry loop.
61
88
  * Both packages re-export these names unchanged; this is the one copy.
62
89
  */
63
90
  export type {
@@ -67,7 +94,7 @@ export type {
67
94
  FlightKeyOptions,
68
95
  FlightPlan,
69
96
  } from './client-flight';
70
- export { createClientFlight, DEFAULT_CLIENT_RETRY, isTransientFailure } from './client-flight';
97
+ export { clientFlight, DEFAULT_CLIENT_RETRY, isTransientFailure } from './client-flight';
71
98
  export type { ActionPathStyle, ActionRoute } from './client-paths';
72
99
  export {
73
100
  ACTION_PATH_PREFIX,
@@ -80,6 +107,9 @@ export {
80
107
  splitWords,
81
108
  } from './client-paths';
82
109
  export type { TransportFailure } from './client-problem';
110
+ export { MAX_REMOTE_TITLE_LENGTH, remoteTitleOf, withStatedDelay } from './client-problem';
111
+ /** What a decoder reads off a refusal: the stated `Retry-After`, and the body's display title. */
112
+ export { MAX_RETRY_AFTER_SECONDS, retryAfterSecondsOf } from './client-retry-after';
83
113
  /**
84
114
  * The browser seam (plan 101): ONE HTTP function, the records envelope it decodes, the per-tab
85
115
  * page handle records land in, and the principal fence every client layer subscribes to.
@@ -93,8 +123,6 @@ export { FRAMEWORK_CODE, problemOf, retryForStatus, traceHeaders } from './clien
93
123
  export { notifyClientWrite, onClientWrite } from './client-writes';
94
124
  export { type Clock, type FrozenClock, frozenClock, systemClock } from './clock';
95
125
  export type {
96
- AiConfig,
97
- AiConfigInput,
98
126
  AppConfig,
99
127
  AppConfigInput,
100
128
  AppConfigOverlay,
@@ -102,7 +130,6 @@ export type {
102
130
  CacheConfig,
103
131
  DatabaseConfig,
104
132
  JobsConfig,
105
- McpConfig,
106
133
  NotifyConfig,
107
134
  PwaConfigInput,
108
135
  RealtimeConfig,
@@ -111,9 +138,17 @@ export type {
111
138
  ThemeMode,
112
139
  } from './config';
113
140
  export { defineConfig, INBOX_RETENTION_KEYS } from './config';
141
+ export type { AiConfig, AiConfigInput, McpConfig } from './config-ai';
114
142
  export type { DrainConfig, HealthConfig, ReadinessMode } from './config-health';
115
143
  export { READINESS_MODES } from './config-health';
116
144
  export type { IslandsConfig, IslandsSection, IslandsSectionInput } from './config-islands';
145
+ export { JOBS_CONCURRENCY_DEFAULT, type JobsConcurrency } from './config-jobs';
146
+ export type {
147
+ MailConfig,
148
+ MailRetainMimeConfig,
149
+ MailSection,
150
+ MailSectionInput,
151
+ } from './config-mail';
117
152
  export type {
118
153
  NavigationConfig,
119
154
  NavigationSection,
@@ -125,7 +160,6 @@ export type {
125
160
  export {
126
161
  DEFAULT_SPECULATION,
127
162
  NAVIGATION_SURFACES,
128
- resolveSpeculation,
129
163
  SPECULATION_EAGERNESS,
130
164
  } from './config-navigation';
131
165
  export type {
@@ -138,7 +172,7 @@ export type {
138
172
  PwaShortcut,
139
173
  PwaText,
140
174
  } from './config-pwa';
141
- export { PWA_COLOR_KEYS, PWA_SCHEMES } from './config-pwa';
175
+ export { isSameOriginPath, PWA_COLOR_KEYS, PWA_SCHEMES } from './config-pwa';
142
176
  export type {
143
177
  SeoConfig,
144
178
  SeoConfigInput,
@@ -154,7 +188,7 @@ export type { ConflictPolicy, ResolveConflictOptions, Row } from './conflict-pol
154
188
  export { resolveConflict } from './conflict-policy';
155
189
  export type { Ctx, CtxFacts, CtxInit, CtxPatch, CtxServices, ServiceBag } from './context';
156
190
  export {
157
- createContext,
191
+ ctxOf,
158
192
  DEFAULT_LOCALE,
159
193
  DEFAULT_TIME_ZONE,
160
194
  hasContext,
@@ -165,10 +199,13 @@ export {
165
199
  useService,
166
200
  withChildContext,
167
201
  } from './context';
168
- /** The one `Cookie:` reader — auth, http and i18n each parsed the header and could not share it. */
169
- export { readCookie } from './cookie';
202
+ /** The one cookie codec — auth, http and i18n each parsed `Cookie:` and spelled `Set-Cookie`. */
203
+ export type { CookiePriority, CookieSameSite, SetCookieOptions } from './cookie';
204
+ export { CookieInvalidError, readCookie, serializeSetCookie } from './cookie';
170
205
  export type { CursorPayload } from './cursor';
171
206
  export {
207
+ CURSOR_SECRET_FIX,
208
+ CURSOR_SECRET_KEY,
172
209
  CursorInvalidError,
173
210
  configureCursorSigning,
174
211
  decodeCursor,
@@ -176,9 +213,19 @@ export {
176
213
  resetCursorSigning,
177
214
  usesDevCursorSecret,
178
215
  } from './cursor';
216
+ /** The ONE page shape — `nextCursor` is `null` exactly when `hasMore` is false (25.0.0). */
217
+ export type { Page } from './cursor-page';
218
+ export { pageOf } from './cursor-page';
179
219
  export { compareDecimalText } from './decimal-order';
220
+ export type { Deprecation, DeprecationField, DeprecationRender } from './deprecation';
221
+ export { recordDeprecatedCall, renderDeprecation } from './deprecation';
180
222
  export type { DevSecretsOptions } from './dev-secrets';
181
- export { assertNoDevSecretsOutsideLocal, CursorSecretDevError } from './dev-secrets';
223
+ export {
224
+ assertNoDevSecretsOutsideLocal,
225
+ CursorSecretDevError,
226
+ devSecretsRefused,
227
+ } from './dev-secrets';
228
+ export { DRAIN_DEADLINE_DEFAULT_MS, DRAIN_DEADLINE_MAX_MS } from './drain-deadline';
182
229
  export type {
183
230
  Env,
184
231
  EnvBooleanVar,
@@ -196,10 +243,8 @@ export type {
196
243
  export { checkEnv, defineEnv, describeEnv, maskedEnvValues } from './env';
197
244
  export type { EnvExampleOptions, EnvExampleReport } from './env-example';
198
245
  export {
199
- assertEnvExample,
200
246
  checkEnvExample,
201
247
  ENV_EXAMPLE_PATH,
202
- EnvExampleDriftError,
203
248
  envFileCandidates,
204
249
  parseEnvKeys,
205
250
  renderEnvExample,
@@ -269,7 +314,6 @@ export {
269
314
  statedDelayMs,
270
315
  stringField,
271
316
  toUltimateError,
272
- ULTIMATE_ERROR_BRAND,
273
317
  UltimateError,
274
318
  } from './exports/error-contract';
275
319
  export type {
@@ -338,7 +382,6 @@ export {
338
382
  configureTelemetry,
339
383
  connections,
340
384
  counter,
341
- createLogger,
342
385
  currentSampler,
343
386
  currentSpan,
344
387
  currentSpanContext,
@@ -405,6 +448,7 @@ export {
405
448
  setLogStream,
406
449
  startMetricExport,
407
450
  startSpan,
451
+ structuredLogger,
408
452
  traceparent,
409
453
  tryOtlpEndpoint,
410
454
  withSpan,
@@ -434,6 +478,7 @@ export {
434
478
  masterKeyPath,
435
479
  openSecrets,
436
480
  parseMasterKey,
481
+ promoteStagedMasterKey,
437
482
  readSecretsFile,
438
483
  requireMasterKey,
439
484
  revealOptionalSecret,
@@ -445,6 +490,7 @@ export {
445
490
  SECRETS_KEY_MODE,
446
491
  SecretsFileInvalidError,
447
492
  SecretsFileMissingError,
493
+ SecretsKeyAclError,
448
494
  SecretsKeyInvalidError,
449
495
  SecretsKeyMismatchError,
450
496
  SecretsKeyMissingError,
@@ -456,6 +502,7 @@ export {
456
502
  secretsPath,
457
503
  serializeSecretValues,
458
504
  stagedMasterKeyPath,
505
+ stageMasterKeyFile,
459
506
  writeMasterKeyFile,
460
507
  writeSecretsFile,
461
508
  } from './exports/secrets';
@@ -466,11 +513,11 @@ export type {
466
513
  FlightGateOptions,
467
514
  FlightGateState,
468
515
  } from './flight-gate';
469
- export { createFlightGate, gateOverloaded } from './flight-gate';
516
+ export { flightGate, gateOverloaded } from './flight-gate';
470
517
  export { fnv1a } from './fnv1a';
471
518
  export { formatBytes } from './format-bytes';
472
519
  export type { GenerationFence } from './generation-fence';
473
- export { createFence, isSuperseded } from './generation-fence';
520
+ export { generationFence, isSuperseded } from './generation-fence';
474
521
  export type { PublicHealthBody } from './health-disclosure';
475
522
  export { DEFAULT_HEALTH_DETAIL_PEERS, healthBody, healthPeerListed } from './health-disclosure';
476
523
  export type { HostDecision, HostRule } from './host-rules';
@@ -488,8 +535,8 @@ export {
488
535
  spanId,
489
536
  traceId,
490
537
  typedId,
491
- uuid,
492
538
  uuidTimestamp,
539
+ uuidV7,
493
540
  } from './ids';
494
541
  export type { ImageFit, ResizeSpec } from './image/canvas';
495
542
  export { parseColor } from './image/color';
@@ -523,7 +570,7 @@ export type { ImageFormat, ImageInfo } from './image/probe';
523
570
  export { IMAGE_FORMATS, IMAGE_MIME_TYPES, probeImage, sniffImageFormat } from './image/probe';
524
571
  export type { ImageSize, Raster } from './image/raster';
525
572
  export {
526
- createRaster,
573
+ blankRaster,
527
574
  hasAlpha,
528
575
  MAX_IMAGE_PIXELS,
529
576
  } from './image/raster';
@@ -585,7 +632,7 @@ export {
585
632
  } from './lifecycle-grace';
586
633
  export type { ReadinessCheck, ReadinessStatus } from './lifecycle-readiness';
587
634
  export type { SignalHandlerOptions } from './lifecycle-signals';
588
- export { installSignalHandlers } from './lifecycle-signals';
635
+ export { drainSignals, installSignalHandlers } from './lifecycle-signals';
589
636
  export { isSelfOrigin, listeningOrigins, markListening, resetListeners } from './listeners';
590
637
  export type { Direction } from './locale-direction';
591
638
  export { directionOf, isRtl } from './locale-direction';
@@ -594,9 +641,16 @@ export { localeSegment, localizePath, splitLocalePath } from './locale-path';
594
641
  // The process logger's test seam, beside nothing it groups with: where a default-writer line goes.
595
642
  export type { LogSink } from './logger';
596
643
  export { setLogSink } from './logger';
597
- export { isMcpExposed, type McpExposureDeclaration } from './mcp-exposure';
644
+ export {
645
+ isMcpExposed,
646
+ type McpAnnotationHints,
647
+ type McpExposureDeclaration,
648
+ type McpListFilterOp,
649
+ type McpListParams,
650
+ } from './mcp-exposure';
598
651
  export type { MeasurementActorFactory } from './measurement-actor';
599
652
  export {
653
+ declaredMeasurementActor,
600
654
  defineMeasurementActor,
601
655
  MEASUREMENT_ACTOR_ID,
602
656
  measurementActor,
@@ -686,10 +740,11 @@ export {
686
740
  type ServiceFactory,
687
741
  } from './service';
688
742
  export type { FlightJoin, Scheduler, SingleFlight, SingleFlightOptions } from './single-flight';
689
- export { createSingleFlight } from './single-flight';
743
+ export { singleFlight } from './single-flight';
690
744
  export { endOfLiteral, maskLiterals, QUOTES, stripComments } from './source-mask';
691
745
  export type { StoreMode } from './store-mode';
692
746
  export { STORE_MODES, storeMode } from './store-mode';
747
+ export { THEME_STORAGE_KEY } from './theme-storage';
693
748
  export { timingSafeEqual } from './timing-safe-equal';
694
749
  export {
695
750
  frameworkVersion,
package/src/iso-date.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  // Single responsibility: re-export `@ultimat3/schema`'s ISO date-time predicate, so a package that
2
2
  // depends on core and not on schema — `@ultimat3/ui` formats dates, it validates nothing — judges a
3
- // date string with the ONE rule `t.date` and `timestamp()` use. The `time-zone-name.ts` shape.
3
+ // date string with the ONE rule `t.date` and `timestamp()` use.
4
4
 
5
5
  export { isIsoDateTime } from '@ultimat3/schema';
@@ -15,7 +15,7 @@ registerErrorCodes({
15
15
  *
16
16
  * Loud rather than tolerant, because the silent version was measured and is worse than a failed
17
17
  * boot: `state` never leaves `stopped` and `drain()` has memoized its promise, so a second
18
- * `createServer().start()` bound a real port, answered `X_DRAINING` (503) to every request, and was
18
+ * `httpServer().start()` bound a real port, answered `X_DRAINING` (503) to every request, and was
19
19
  * still accepting connections after its own `stop()` returned — a dead listener holding a port,
20
20
  * with no log line naming what happened.
21
21
  *
@@ -1,5 +1,8 @@
1
- // Single responsibility: the readiness CHECK — its signature and its two answers. The mode that
2
- // decides what a failing one does to `/readyz` is `config-health.ts`'s.
1
+ // Single responsibility: the readiness CHECK — its signature, its two answers, and the registry of
2
+ // named checks `/readyz` runs. The mode that decides what a failing one does to `/readyz` is
3
+ // `config-health.ts`'s; the lifecycle that reports through them is `lifecycle.ts`.
4
+
5
+ import { UltimateError } from './errors';
3
6
 
4
7
  export type ReadinessStatus = 'ok' | 'failing';
5
8
 
@@ -19,3 +22,58 @@ export type ReadinessStatus = 'ok' | 'failing';
19
22
  * is `failing`.
20
23
  */
21
24
  export type ReadinessCheck = () => boolean;
25
+
26
+ const checks = new Map<string, ReadinessCheck>();
27
+
28
+ /**
29
+ * Register a named readiness check. Returns its unregister — the same shape as `onShutdown`, and
30
+ * owned by whoever can be started twice, for the same reason.
31
+ */
32
+ export function registerReadinessCheck(name: string, check: ReadinessCheck): () => void {
33
+ if (checks.has(name)) {
34
+ throw new UltimateError({
35
+ code: 'X_READINESS_CHECK_DUPLICATE',
36
+ cause: `a readiness check named "${name}" is already registered (have: ${[...checks.keys()].join(', ')})`,
37
+ fix: `name the second check for what it actually probes, e.g. registerReadinessCheck('${name}-replica', check) — or hold the unregister the first registration returned and call it first`,
38
+ meta: { name },
39
+ });
40
+ }
41
+ checks.set(name, check);
42
+ return () => {
43
+ if (checks.get(name) === check) checks.delete(name);
44
+ };
45
+ }
46
+
47
+ /** Test-only: registered checks. A count that climbs across a start/stop cycle is a leak. */
48
+ export function readinessCheckCount(): number {
49
+ return checks.size;
50
+ }
51
+
52
+ /** Drop every check — `resetLifecycle()`'s, and nothing else's. */
53
+ export function clearReadinessChecks(): void {
54
+ checks.clear();
55
+ }
56
+
57
+ /**
58
+ * Every check, run now, by name. A check that throws is `failing` — never an unhandled error —
59
+ * and is handed to `onThrow`, which `lifecycle.ts` routes through its total `report`.
60
+ *
61
+ * Built through `Object.fromEntries`, never by assigning `results[name]`: assignment to the one
62
+ * name `__proto__` sets the PROTOTYPE instead of adding a key, so that check vanished from the
63
+ * report, `ready` was computed over an empty object — vacuously true — and a failing check
64
+ * answered 200. `fromEntries` defines own properties and has no such name.
65
+ */
66
+ export function runReadinessChecks(
67
+ onThrow: (name: string, thrown: unknown) => void,
68
+ ): Readonly<Record<string, ReadinessStatus>> {
69
+ const results: [string, ReadinessStatus][] = [];
70
+ for (const [name, check] of checks) {
71
+ try {
72
+ results.push([name, check() ? 'ok' : 'failing']);
73
+ } catch (thrown) {
74
+ results.push([name, 'failing']);
75
+ onThrow(name, thrown);
76
+ }
77
+ }
78
+ return Object.fromEntries(results);
79
+ }
@@ -7,11 +7,36 @@ export interface SignalHandlerOptions {
7
7
  readonly signals?: readonly ProcessSignal[] | undefined;
8
8
  /** Call `process.exit()` once drained. Off in tests. */
9
9
  readonly exit?: boolean | undefined;
10
+ /** Whose default signal set to install — `process.platform` unless a test names one. */
11
+ readonly platform?: string | undefined;
10
12
  }
11
13
 
12
- /** Install SIGTERM/SIGINT handling. Returns an uninstall function. */
14
+ const POSIX_SIGNALS: readonly ProcessSignal[] = Object.freeze(['SIGTERM', 'SIGINT']);
15
+
16
+ /**
17
+ * Windows never sends SIGTERM to a console process: a service stop or a closed console window
18
+ * arrives as SIGHUP, and Ctrl-Break as SIGBREAK. Listening for the POSIX pair alone meant either
19
+ * one killed the process outright, mid-request, with no drain. SIGHUP stays off POSIX on purpose:
20
+ * there it is a terminal hang-up a supervised process should not read as "stop".
21
+ */
22
+ const WINDOWS_SIGNALS: readonly ProcessSignal[] = Object.freeze([
23
+ 'SIGTERM',
24
+ 'SIGINT',
25
+ 'SIGHUP',
26
+ 'SIGBREAK',
27
+ ]);
28
+
29
+ /** The signals that start a drain on `platform`. */
30
+ export function drainSignals(platform: string = process.platform): readonly ProcessSignal[] {
31
+ return platform === 'win32' ? WINDOWS_SIGNALS : POSIX_SIGNALS;
32
+ }
33
+
34
+ /**
35
+ * Install drain-on-signal handling — SIGTERM/SIGINT, plus SIGHUP/SIGBREAK on Windows
36
+ * (`drainSignals`). Returns an uninstall function.
37
+ */
13
38
  export function installSignalHandlers(options?: SignalHandlerOptions): () => void {
14
- const signals: readonly ProcessSignal[] = options?.signals ?? ['SIGTERM', 'SIGINT'];
39
+ const signals = options?.signals ?? drainSignals(options?.platform);
15
40
  const handlers = new Map<ProcessSignal, () => void>();
16
41
 
17
42
  for (const signal of signals) {
@@ -0,0 +1,96 @@
1
+ // Single responsibility: the lifecycle's data model — its states, phases, hook and option shapes,
2
+ // and the health report it answers with. The mechanism that moves between them is `lifecycle.ts`,
3
+ // which re-exports every name here so a caller keeps one import path.
4
+
5
+ import type { Clock } from './clock';
6
+ import type { ReadinessMode } from './config-health';
7
+ import type { ReadinessStatus } from './lifecycle-readiness';
8
+ import type { Logger } from './logger';
9
+
10
+ export type HealthState = 'starting' | 'ready' | 'draining' | 'stopped';
11
+
12
+ /** Ordered. `accept` runs first, `close` last. */
13
+ export type ShutdownPhase = 'accept' | 'inflight' | 'close';
14
+
15
+ export const SHUTDOWN_PHASES: readonly ShutdownPhase[] = ['accept', 'inflight', 'close'];
16
+
17
+ /**
18
+ * Signals Ultimate reacts to. Narrower than `NodeJS.Signals` on purpose. `SIGBREAK` is Windows'
19
+ * Ctrl-Break — the one console signal there that is neither Ctrl-C nor a window closing.
20
+ */
21
+ export type ProcessSignal = 'SIGTERM' | 'SIGINT' | 'SIGHUP' | 'SIGQUIT' | 'SIGBREAK';
22
+
23
+ export interface ShutdownReason {
24
+ readonly signal: string;
25
+ /**
26
+ * Real monotonic ms (`systemClock`) after which hooks are abandoned — deliberately NOT the
27
+ * injected clock. The budget this bounds is `terminationGracePeriodSeconds`, counted by the
28
+ * kubelet in real seconds, so a frozen clock must be unable to extend it: read off `clock` a
29
+ * test that advanced an hour of fake time handed the drain a 16-minute grace period, while
30
+ * `waitForIdle` went on sleeping on a real `setTimeout`. `clock` still owns `uptimeMs`.
31
+ */
32
+ readonly deadlineAt: number;
33
+ }
34
+
35
+ export type ShutdownHook = (reason: ShutdownReason) => void | Promise<void>;
36
+
37
+ export interface OnShutdownOptions {
38
+ readonly phase?: ShutdownPhase | undefined;
39
+ }
40
+
41
+ export interface LifecycleOptions {
42
+ /**
43
+ * The whole drain's budget — the in-flight wait AND every hook, in every phase. 25s by default,
44
+ * and **enforced whether or not an app sets it**: `ShutdownReason.deadlineAt` was always computed
45
+ * and handed to every hook, so the deadline was declared by the design and only the enforcement
46
+ * was missing. No hook reads `deadlineAt`, which is why it has to be imposed here.
47
+ *
48
+ * The lever is a LARGER value, not the absence of one: a `worker` holding a 10-minute job wants
49
+ * `configureLifecycle({ deadlineMs: 600_000 })` and a `terminationGracePeriodSeconds` at least as
50
+ * large. Left at 25s it is abandoned and the process exits clean — the row's visibility lease
51
+ * lapses and another worker re-claims it, which is what at-least-once already promises. The
52
+ * alternative is not "the job finishes": it is the same duplicate, delivered by SIGKILL at the
53
+ * kubelet's grace period, with no log line naming what overran.
54
+ *
55
+ * Screened where it is assigned: a whole number of milliseconds, 0 or more. `0` is "drain now".
56
+ */
57
+ readonly deadlineMs?: number | undefined;
58
+ /**
59
+ * How long `/readyz` answers 503 BEFORE the `accept` phase closes the listener — the time the
60
+ * endpoints controller and the ingress need to stop routing here. Closing on the flip itself left
61
+ * endpoints pointing at a closed socket, and a POST in that window got a 502. Added to
62
+ * `deadlineMs`, never taken from it. Unset: `defaultReadinessGraceMs()` of the process env at
63
+ * drain time — 0 in development/test, 5000 everywhere else, including a process naming no env.
64
+ * A whole number from 0 to 60000; 0 is no grace.
65
+ */
66
+ readonly readinessGraceMs?: number | undefined;
67
+ /** What a failing check does to `/readyz` — see `ReadinessMode`. Default `'dependencies'`. */
68
+ readonly readiness?: ReadinessMode | undefined;
69
+ readonly clock?: Clock | undefined;
70
+ readonly logger?: Logger | undefined;
71
+ }
72
+
73
+ export interface HealthReport {
74
+ readonly state: HealthState;
75
+ readonly ready: boolean;
76
+ readonly uptimeMs: number;
77
+ readonly inflight: number;
78
+ readonly buildId: string;
79
+ /** Named, because "alert on check failures BY CHECK NAME" is not writable against a boolean. */
80
+ readonly checks: Readonly<Record<string, ReadinessStatus>>;
81
+ /**
82
+ * How many checks are registered. `checks: {}` reads identically for "every check passed" and
83
+ * "nobody registered one", and only the second is a `/readyz` that means no more than "the
84
+ * socket is bound" — which is what the chart's and compose's healthchecks route traffic on.
85
+ * Reported rather than enforced: an empty registry is still ready, so a role that genuinely has
86
+ * no dependency does not have to invent a check to boot.
87
+ */
88
+ readonly registered: number;
89
+ }
90
+
91
+ export interface HealthPayload {
92
+ readonly ok: boolean;
93
+ /** The status code the HTTP layer should return. Core stays HTTP-free; this is just data. */
94
+ readonly status: number;
95
+ readonly body: HealthReport;
96
+ }