@ultimat3/core 1.2.0 → 3.0.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 (46) hide show
  1. package/CLAUDE.md +272 -0
  2. package/README.md +248 -10
  3. package/package.json +2 -1
  4. package/src/actor.ts +118 -4
  5. package/src/app-version.ts +32 -0
  6. package/src/assert.ts +5 -1
  7. package/src/config.ts +47 -12
  8. package/src/context.ts +30 -3
  9. package/src/cursor.ts +25 -4
  10. package/src/env-example.ts +2 -1
  11. package/src/env.ts +14 -3
  12. package/src/environment.ts +39 -13
  13. package/src/error-codes.ts +15 -1
  14. package/src/error-render.ts +249 -0
  15. package/src/error-reporter-sentry.ts +175 -0
  16. package/src/error-reporter.ts +212 -0
  17. package/src/error-retry.ts +112 -0
  18. package/src/errors.ts +55 -7
  19. package/src/exports/error-contract.ts +62 -0
  20. package/src/exports/observability.ts +161 -0
  21. package/src/exports/secrets.ts +71 -0
  22. package/src/ids.ts +49 -7
  23. package/src/impersonate.ts +62 -0
  24. package/src/index.ts +279 -113
  25. package/src/intl-cache.ts +43 -0
  26. package/src/lifecycle-deadline.ts +73 -0
  27. package/src/lifecycle-errors.ts +33 -0
  28. package/src/lifecycle.ts +237 -34
  29. package/src/logger.ts +99 -9
  30. package/src/mcp-exposure.ts +32 -0
  31. package/src/metrics.ts +0 -0
  32. package/src/otlp-metric-exporter.ts +136 -0
  33. package/src/otlp-span-exporter.ts +170 -0
  34. package/src/otlp.ts +217 -0
  35. package/src/read-capped.ts +47 -0
  36. package/src/runtime-metrics.ts +15 -0
  37. package/src/safe-url.ts +50 -0
  38. package/src/sampler.ts +126 -0
  39. package/src/schema-error-codes.ts +28 -0
  40. package/src/secrets-errors.ts +143 -0
  41. package/src/secrets-store.ts +173 -0
  42. package/src/secrets.ts +292 -0
  43. package/src/telemetry.ts +43 -11
  44. package/src/timing-safe-equal.ts +18 -0
  45. package/src/type-pins.ts +93 -0
  46. package/src/version.ts +53 -4
@@ -0,0 +1,62 @@
1
+ // Single responsibility: acting as another actor, on the record. `withChildContext({ actor })` is
2
+ // the mechanism; this is the ONE door through it, because a swap with no reason and no origin is
3
+ // indistinguishable in an audit trail from the customer doing it themselves.
4
+
5
+ import { AsyncLocalStorage } from 'node:async_hooks';
6
+ import { type Actor, actorLabel, actorOrigin } from './actor';
7
+ import { assert } from './assert';
8
+ import { useContext, withChildContext } from './context';
9
+ import { currentSpan } from './telemetry';
10
+
11
+ const storage = new AsyncLocalStorage<string>();
12
+
13
+ /**
14
+ * Run `fn` as `actor`, recording who asked and why.
15
+ *
16
+ * ```ts
17
+ * await impersonate(customer, 'ticket 4821: reproduce the failed refund', async () => { … });
18
+ * ```
19
+ *
20
+ * Three properties, none optional. **The original actor is preserved** — stamped onto the child as
21
+ * `onBehalfOf`, so every log line, span and `actorLabel()` inside renders `support:eng-7→user:…`
22
+ * and a refund issued here can never read as the customer's. **The reason is required and
23
+ * non-blank** because it *is* the mechanism: a swap with no argument is a pragma, and the next
24
+ * reader cannot tell a support session from a bug. **There must be a context** — outside one there
25
+ * is no original actor to preserve, so there is nothing to impersonate *from*, and
26
+ * `withChildContext` says so with `X_NO_CONTEXT`.
27
+ *
28
+ * Same template as `@ultimat3/entity`'s `crossTenant()`, deliberately: two escapes from the
29
+ * framework's default posture should not be two different-looking things.
30
+ */
31
+ export function impersonate<T>(actor: Actor, reason: string, fn: () => T): T {
32
+ assert(
33
+ reason.trim() !== '',
34
+ 'impersonate() was given a blank reason, so the identity swap it performs carries no argument',
35
+ "pass why one actor is acting as another: impersonate(customer, 'ticket 4821: reproduce the failed refund', fn)",
36
+ );
37
+ const parent = useContext();
38
+ const impersonated: Actor = Object.freeze({ ...actor, onBehalfOf: actorOrigin(parent.actor) });
39
+ const label = actorLabel(impersonated);
40
+ // The PARENT's logger, because the parent is who is performing this — and `warn`, not `info`,
41
+ // because this is rare, always interesting, and an audit query that has to read info-level to
42
+ // find it will be run at the wrong level during the one incident where it matters.
43
+ parent.logger.warn('impersonation', { event: 'impersonation', actor: label, reason });
44
+ currentSpan()?.addEvent('impersonation', {
45
+ 'actor.label': label,
46
+ 'impersonation.reason': reason,
47
+ });
48
+ return withChildContext({ actor: impersonated }, () => storage.run(reason, fn));
49
+ }
50
+
51
+ /**
52
+ * The innermost enclosing reason, or `undefined` outside every scope — which is every request in
53
+ * an app where nobody is impersonating. Read by an audit sink, and by nothing else.
54
+ */
55
+ export function impersonationReason(): string | undefined {
56
+ return storage.getStore();
57
+ }
58
+
59
+ /** Is the caller acting as somebody else right now? */
60
+ export function isImpersonating(): boolean {
61
+ return storage.getStore() !== undefined;
62
+ }
package/src/index.ts CHANGED
@@ -1,10 +1,24 @@
1
1
  // Single responsibility: the public API of @ultimat3/core. Explicit named exports only —
2
2
  // every other package imports from here, so this list is the tier-0 contract.
3
+ //
4
+ // Three slices arrive through a barrel in `exports/` — observability, the error contract and
5
+ // secrets — because each is one subject spread over a dozen modules. Every name they carry is
6
+ // still written out below: `export *` would make the contract something a reader has to resolve.
3
7
 
4
- export type { Actor, ActorInit, ActorKind } from './actor';
8
+ export type {
9
+ Actor,
10
+ ActorFactKey,
11
+ ActorFactMap,
12
+ ActorFacts,
13
+ ActorInit,
14
+ ActorKind,
15
+ ActorOrigin,
16
+ } from './actor';
5
17
  export {
6
18
  ACTOR_KINDS,
19
+ actorFact,
7
20
  actorLabel,
21
+ actorOrigin,
8
22
  agentActor,
9
23
  anonymousActor,
10
24
  hasRole,
@@ -13,7 +27,9 @@ export {
13
27
  isAnonymous,
14
28
  serviceActor,
15
29
  userActor,
30
+ withFacts,
16
31
  } from './actor';
32
+ export { APP_VERSION_KEY, appVersion, DEFAULT_APP_VERSION } from './app-version';
17
33
  export type { InvariantOptions } from './assert';
18
34
  export { assert, assertNever, invariant } from './assert';
19
35
  export type { Clock, FrozenClock } from './clock';
@@ -24,6 +40,7 @@ export type {
24
40
  AppConfig,
25
41
  AppConfigInput,
26
42
  AppConfigOverlay,
43
+ AuthConfig,
27
44
  CacheConfig,
28
45
  CacheTier,
29
46
  DatabaseConfig,
@@ -58,6 +75,7 @@ export {
58
75
  configureCursorSigning,
59
76
  decodeCursor,
60
77
  encodeCursor,
78
+ resetCursorSigning,
61
79
  usesDevCursorSecret,
62
80
  } from './cursor';
63
81
  export type {
@@ -95,44 +113,266 @@ export {
95
113
  isLocal,
96
114
  isProduction,
97
115
  resolveEnvironment,
116
+ tryResolveEnvironment,
98
117
  } from './environment';
99
118
  export type {
119
+ CodedErrorInit,
100
120
  CoreErrorCode,
101
121
  ErrorCodeDeclaration,
102
122
  ErrorCodeDescriptor,
103
123
  ErrorCodeEntry,
104
- } from './error-codes';
105
- export {
106
- CORE_ERROR_CODES,
107
- describeErrorCode,
108
- ERROR_DOCS_BASE,
109
- errorCodeSnapshot,
110
- errorDocsUrl,
111
- hasErrorCode,
112
- listErrorCodes,
113
- registerErrorCodes,
114
- resetErrorCodes,
115
- } from './error-codes';
116
- export type {
117
- CodedErrorInit,
124
+ ErrorRetry,
118
125
  FormatErrorOptions,
119
126
  UltimateErrorInit,
120
127
  UltimateErrorJSON,
121
- } from './errors';
128
+ } from './exports/error-contract';
122
129
  export {
130
+ CORE_ERROR_CODES,
123
131
  ConfigInvalidError,
132
+ DEFAULT_ERROR_RETRY,
133
+ declaredErrorRetry,
134
+ describeErrorCode,
135
+ describeValue,
124
136
  EnvMissingError,
137
+ ERROR_DOCS_BASE,
138
+ ERROR_RETRY_KINDS,
139
+ errorCodeSnapshot,
140
+ errorDocsUrl,
141
+ errorRetry,
125
142
  formatError,
143
+ hasErrorCode,
126
144
  InternalError,
145
+ isErrorRetry,
146
+ isThrownError,
127
147
  isUltimateError,
148
+ listErrorCodes,
149
+ MAX_RENDERED_LENGTH,
128
150
  NotImplementedError,
129
151
  notImplemented,
152
+ registerErrorCodes,
153
+ registerErrorRetry,
154
+ registeredErrorRetry,
155
+ renderCauseValue,
156
+ renderFixLiteral,
157
+ renderThrowable,
158
+ resetErrorCodes,
159
+ resetErrorRetry,
160
+ retryFor,
161
+ SCHEMA_ERROR_CODE_TITLES,
162
+ stringField,
130
163
  toUltimateError,
131
164
  ULTIMATE_ERROR_BRAND,
132
165
  UltimateError,
133
- } from './errors';
166
+ } from './exports/error-contract';
167
+ export type {
168
+ AttributeValue,
169
+ Counter,
170
+ ErrorReport,
171
+ ErrorReporter,
172
+ ErrorReportingOptions,
173
+ ErrorScope,
174
+ ErrorSeverity,
175
+ ErrorSource,
176
+ Gauge,
177
+ GaugeOptions,
178
+ Histogram,
179
+ HistogramOptions,
180
+ HistogramPoint,
181
+ InstrumentOptions,
182
+ LogFields,
183
+ Logger,
184
+ LoggerOptions,
185
+ LogLevel,
186
+ MemoryErrorReporter,
187
+ MemoryExporter,
188
+ MemoryMetricExporter,
189
+ MetricAttributes,
190
+ MetricAttributeValue,
191
+ MetricCollection,
192
+ MetricDescriptor,
193
+ MetricExporter,
194
+ MetricKind,
195
+ MetricPoint,
196
+ MetricsOptions,
197
+ OtlpAnyValue,
198
+ OtlpKeyValue,
199
+ OtlpMetricExporter,
200
+ OtlpMetricExporterOptions,
201
+ OtlpSignal,
202
+ OtlpSpanExporter,
203
+ OtlpSpanExporterOptions,
204
+ ReadableMetric,
205
+ ReadableSpan,
206
+ ReportErrorOptions,
207
+ RequestSample,
208
+ Sampler,
209
+ SentryDsn,
210
+ SentryEnvelopeOptions,
211
+ SentryReporterOptions,
212
+ Span,
213
+ SpanAttributes,
214
+ SpanContext,
215
+ SpanEvent,
216
+ SpanExporter,
217
+ SpanKind,
218
+ SpanResource,
219
+ SpanStatus,
220
+ SpanStatusCode,
221
+ StartSpanOptions,
222
+ TelemetryOptions,
223
+ } from './exports/observability';
224
+ export {
225
+ alwaysOffSampler,
226
+ alwaysOnSampler,
227
+ collectMetrics,
228
+ configureErrorReporting,
229
+ configureMetrics,
230
+ configureTelemetry,
231
+ connections,
232
+ counter,
233
+ createLogger,
234
+ currentSampler,
235
+ currentSpan,
236
+ currentSpanContext,
237
+ DEFAULT_HISTOGRAM_BOUNDS,
238
+ DEFAULT_MAX_SERIES,
239
+ DEFAULT_SAMPLE_RATIO,
240
+ defaultSampler,
241
+ ERROR_SOURCES,
242
+ ErrorReporterDsnInvalidError,
243
+ errorReport,
244
+ exportMetrics,
245
+ gauge,
246
+ histogram,
247
+ isRedactedKey,
248
+ jobs,
249
+ LOG_LEVELS,
250
+ leasesLost,
251
+ logger,
252
+ METRICS_CONTENT_TYPE,
253
+ METRICS_PATH,
254
+ MetricCardinalityError,
255
+ MetricNameInvalidError,
256
+ MetricValueInvalidError,
257
+ memoryErrorReporter,
258
+ memoryExporter,
259
+ memoryMetricExporter,
260
+ metricsText,
261
+ noopErrorReporter,
262
+ noopExporter,
263
+ noopMetricExporter,
264
+ OTEL_SAMPLER_ARG_KEY,
265
+ OTEL_SAMPLER_KEY,
266
+ OTLP_ENDPOINT_KEY,
267
+ OTLP_HEADERS_KEY,
268
+ OTLP_PROTOCOL_KEY,
269
+ OTLP_SCOPE,
270
+ OtlpEndpointInvalidError,
271
+ OtlpProtocolUnsupportedError,
272
+ OVERFLOW_ATTRIBUTE,
273
+ otlpAttributes,
274
+ otlpEndpoint,
275
+ otlpHeaders,
276
+ otlpMetricExporter,
277
+ otlpMetricsRequest,
278
+ otlpResource,
279
+ otlpSpanExporter,
280
+ otlpTraceRequest,
281
+ parentBasedRatioSampler,
282
+ parseSentryDsn,
283
+ parseTraceparent,
284
+ queueDepth,
285
+ REDACTED,
286
+ ratioSampler,
287
+ recordConnection,
288
+ recordJob,
289
+ recordLeaseLost,
290
+ recordQueueDepth,
291
+ recordRequest,
292
+ redactKeys,
293
+ reportError,
294
+ requestDuration,
295
+ requests,
296
+ resetDefaultSampler,
297
+ resetErrorReporting,
298
+ resetMetrics,
299
+ resetTelemetry,
300
+ SCALING_METRICS,
301
+ samplerFromEnv,
302
+ sentryEnvelope,
303
+ sentryErrorReporter,
304
+ serviceResource,
305
+ setLoggerContextFields,
306
+ startMetricExport,
307
+ startSpan,
308
+ traceparent,
309
+ tryOtlpEndpoint,
310
+ unixNano,
311
+ withSpan,
312
+ withSpanContext,
313
+ } from './exports/observability';
314
+ export type {
315
+ MasterKeyRef,
316
+ MasterKeySource,
317
+ Secret,
318
+ SecretSummary,
319
+ SecretsEnvelope,
320
+ SecretsErrorCode,
321
+ SecretsInstallOptions,
322
+ SecretsInstallReport,
323
+ SecretsLocation,
324
+ SecretValues,
325
+ } from './exports/secrets';
326
+ export {
327
+ assertSecretValues,
328
+ describeSecrets,
329
+ findMasterKey,
330
+ generateMasterKey,
331
+ installSecrets,
332
+ isSecret,
333
+ masterKeyId,
334
+ masterKeyIdOf,
335
+ masterKeyPath,
336
+ openSecrets,
337
+ parseMasterKey,
338
+ parseSecretsEnvelope,
339
+ readSecretsFile,
340
+ requireMasterKey,
341
+ revealOptionalSecret,
342
+ revealSecret,
343
+ SECRET_BRAND,
344
+ SECRET_NAME,
345
+ SECRETS_ALG,
346
+ SECRETS_ERROR_CODES,
347
+ SECRETS_FILE,
348
+ SECRETS_IV_BYTES,
349
+ SECRETS_KEY_BYTES,
350
+ SECRETS_KEY_ENV,
351
+ SECRETS_KEY_FILE,
352
+ SECRETS_KEY_HEX_LENGTH,
353
+ SECRETS_KEY_ID_LENGTH,
354
+ SECRETS_KEY_MODE,
355
+ SECRETS_TAG_BYTES,
356
+ SECRETS_VERSION,
357
+ SecretsFileInvalidError,
358
+ SecretsFileMissingError,
359
+ SecretsKeyInvalidError,
360
+ SecretsKeyMismatchError,
361
+ SecretsKeyMissingError,
362
+ SecretsPlaintextInvalidError,
363
+ SecretsTamperedError,
364
+ sealSecrets,
365
+ secret,
366
+ secretsFileExists,
367
+ secretsPath,
368
+ serializeSecretValues,
369
+ writeMasterKeyFile,
370
+ writeSecretsFile,
371
+ } from './exports/secrets';
134
372
  export type { Brand, Id } from './ids';
135
373
  export {
374
+ isSpanId,
375
+ isTraceId,
136
376
  isUuid,
137
377
  nanoid,
138
378
  parseId,
@@ -187,6 +427,8 @@ export {
187
427
  } from './image/raster';
188
428
  export type { ImageFit, ResizeSpec } from './image/resize';
189
429
  export { fitBox, resizeRaster, scaledToFit } from './image/resize';
430
+ export { impersonate, impersonationReason, isImpersonating } from './impersonate';
431
+ export { cachedFormatter, canonicalLocale, MAX_CACHED_FORMATTERS } from './intl-cache';
190
432
  export type {
191
433
  HealthPayload,
192
434
  HealthReport,
@@ -194,6 +436,8 @@ export type {
194
436
  LifecycleOptions,
195
437
  OnShutdownOptions,
196
438
  ProcessSignal,
439
+ ReadinessCheck,
440
+ ReadinessStatus,
197
441
  ShutdownHook,
198
442
  ShutdownPhase,
199
443
  ShutdownReason,
@@ -203,17 +447,23 @@ export {
203
447
  beginWork,
204
448
  configureLifecycle,
205
449
  drain,
450
+ drainDeadlineMs,
206
451
  healthReport,
207
452
  healthzPayload,
453
+ idleWaiterCount,
208
454
  inflightCount,
209
455
  installSignalHandlers,
210
456
  isDraining,
211
457
  lifecycleState,
212
458
  markReady,
213
459
  onShutdown,
460
+ readinessCheckCount,
461
+ readinessChecks,
214
462
  readyzPayload,
463
+ registerReadinessCheck,
215
464
  resetLifecycle,
216
465
  SHUTDOWN_PHASES,
466
+ shutdownHookCount,
217
467
  } from './lifecycle';
218
468
  export {
219
469
  isSelfOrigin,
@@ -221,51 +471,10 @@ export {
221
471
  markListening,
222
472
  resetListeners,
223
473
  } from './listeners';
224
- export type { LogFields, Logger, LoggerOptions, LogLevel } from './logger';
225
- export {
226
- createLogger,
227
- isRedactedKey,
228
- LOG_LEVELS,
229
- logger,
230
- REDACTED,
231
- redactKeys,
232
- setLoggerContextFields,
233
- } from './logger';
234
- export type {
235
- Counter,
236
- Gauge,
237
- GaugeOptions,
238
- Histogram,
239
- HistogramOptions,
240
- HistogramPoint,
241
- InstrumentOptions,
242
- MemoryMetricExporter,
243
- MetricAttributes,
244
- MetricAttributeValue,
245
- MetricCollection,
246
- MetricDescriptor,
247
- MetricExporter,
248
- MetricKind,
249
- MetricPoint,
250
- MetricsOptions,
251
- ReadableMetric,
252
- } from './metrics';
253
- export {
254
- collectMetrics,
255
- configureMetrics,
256
- counter,
257
- DEFAULT_HISTOGRAM_BOUNDS,
258
- exportMetrics,
259
- gauge,
260
- histogram,
261
- MetricNameInvalidError,
262
- MetricValueInvalidError,
263
- memoryMetricExporter,
264
- noopMetricExporter,
265
- resetMetrics,
266
- startMetricExport,
267
- } from './metrics';
268
- export { METRICS_CONTENT_TYPE, METRICS_PATH, metricsText } from './metrics-text';
474
+ export type { McpExposureDeclaration } from './mcp-exposure';
475
+ export { isMcpExposed } from './mcp-exposure';
476
+ export type { CappedBody } from './read-capped';
477
+ export { readWithinLimit } from './read-capped';
269
478
  export type { ModuleRegistrar, PrimitiveKind, RegisteredPrimitive } from './registrar';
270
479
  export {
271
480
  hasPrimitiveRegistrar,
@@ -278,57 +487,14 @@ export type { Err, Ok, Result } from './result';
278
487
  export { err, isErr, isOk, map, mapErr, ok, tryCatch, unwrap, unwrapOr } from './result';
279
488
  export type { ResolveRoleOptions, Role, RoleInfo, ScalingSignal } from './roles';
280
489
  export { DEFAULT_ROLE, isRole, ROLE_INFO, ROLES, resolveRole } from './roles';
281
- export type { RequestSample } from './runtime-metrics';
282
- export {
283
- connections,
284
- jobs,
285
- queueDepth,
286
- recordConnection,
287
- recordJob,
288
- recordQueueDepth,
289
- recordRequest,
290
- requestDuration,
291
- requests,
292
- SCALING_METRICS,
293
- } from './runtime-metrics';
294
- export type { Secret } from './secret';
295
- export {
296
- isSecret,
297
- revealOptionalSecret,
298
- revealSecret,
299
- SECRET_BRAND,
300
- secret,
301
- } from './secret';
490
+ export { safeUrl, URL_ATTRIBUTES } from './safe-url';
302
491
  export type { ServiceFactory } from './service';
303
492
  export { defineService, resetServices } from './service';
304
- export type {
305
- AttributeValue,
306
- MemoryExporter,
307
- ReadableSpan,
308
- Span,
309
- SpanAttributes,
310
- SpanContext,
311
- SpanEvent,
312
- SpanExporter,
313
- SpanKind,
314
- SpanResource,
315
- SpanStatus,
316
- SpanStatusCode,
317
- StartSpanOptions,
318
- TelemetryOptions,
319
- } from './telemetry';
493
+ export { timingSafeEqual } from './timing-safe-equal';
320
494
  export {
321
- configureTelemetry,
322
- currentSpan,
323
- currentSpanContext,
324
- memoryExporter,
325
- noopExporter,
326
- parseTraceparent,
327
- resetTelemetry,
328
- serviceResource,
329
- startSpan,
330
- traceparent,
331
- withSpan,
332
- withSpanContext,
333
- } from './telemetry';
334
- export { FRAMEWORK_VERSION, readPackageVersion, VERSION_MANIFEST } from './version';
495
+ frameworkVersion,
496
+ readPackageVersion,
497
+ resolveVersion,
498
+ VERSION_DEFINE,
499
+ VERSION_MANIFEST,
500
+ } from './version';
@@ -0,0 +1,43 @@
1
+ // One bounded cache, on one canonical key, for every `Intl` formatter the framework builds.
2
+ // A locale and a zone both arrive from a request header, so an unbounded `Map` keyed on the
3
+ // caller's spelling is memory the client chooses — 31 MB and 55.1 MB, measured `As of 2026-08` and
4
+ // written up in the README. The bound and the canonical key are two halves of ONE rule.
5
+
6
+ /**
7
+ * Above the full canonical IANA set (445 zones as of tzdata 2025) so a correct app never evicts,
8
+ * and small enough that the worst case is a few megabytes rather than a leak. A miss costs one
9
+ * `Intl` construction, never a wrong answer — which is what makes a bound safe here at all.
10
+ */
11
+ export const MAX_CACHED_FORMATTERS = 512;
12
+
13
+ /** FIFO — a `Map` iterates in insertion order, so the first key inserted is the first evicted. */
14
+ export function cachedFormatter<T>(cache: Map<string, T>, key: string, build: () => T): T {
15
+ // Membership decides, never truthiness: `T` is the caller's, so a stored `undefined` is a hit.
16
+ // The cast is sound because `has` just proved the key is present, which `get`'s signature cannot.
17
+ if (cache.has(key)) return cache.get(key) as T;
18
+ const formatter = build();
19
+ if (cache.size >= MAX_CACHED_FORMATTERS) {
20
+ const oldest = cache.keys().next().value;
21
+ if (oldest !== undefined) cache.delete(oldest);
22
+ }
23
+ cache.set(key, formatter);
24
+ return formatter;
25
+ }
26
+
27
+ /**
28
+ * The canonical BCP 47 spelling, or `undefined` when the tag is not structurally valid at all
29
+ * (`en_US`, `''`, `not a locale`). Well-formed but unknown to ICU (`zz`) is a locale — `Intl`
30
+ * falls back for it, and refusing here would be stricter than the formatters this feeds.
31
+ *
32
+ * Deliberately **not** memoised: this is string work, and a `Map` keyed on a header value is the
33
+ * unbounded cache the bound above exists to prevent.
34
+ */
35
+ export function canonicalLocale(locale: string): string | undefined {
36
+ try {
37
+ // `getCanonicalLocales` runs the same IsStructurallyValidLanguageTag check that
38
+ // `supportedLocalesOf` throws on, and unlike it, hands back the canonical spelling.
39
+ return Intl.getCanonicalLocales(locale)[0];
40
+ } catch {
41
+ return undefined;
42
+ }
43
+ }
@@ -0,0 +1,73 @@
1
+ // Single responsibility: run one shutdown hook under the drain's remaining budget. A hook that
2
+ // overruns is ABANDONED — still running, no longer awaited — because a deadline that only logged
3
+ // leaves the kubelet to SIGKILL the process, which is the failure the budget exists to prevent.
4
+ // Split out of `lifecycle.ts` so the race has no access to the drain's module state.
5
+
6
+ /**
7
+ * What became of one hook. `abandoned` is the only outcome the caller must report: the hook is
8
+ * still running, nothing will ever read its result, and whatever it had in flight may be
9
+ * incomplete — which is the price of a deadline and has to be visible in the log line.
10
+ */
11
+ export type HookOutcome =
12
+ | { readonly kind: 'settled' }
13
+ | { readonly kind: 'failed'; readonly error: unknown }
14
+ | { readonly kind: 'abandoned' };
15
+
16
+ const SETTLED: HookOutcome = Object.freeze({ kind: 'settled' });
17
+ const ABANDONED: HookOutcome = Object.freeze({ kind: 'abandoned' });
18
+
19
+ const failed = (error: unknown): HookOutcome => ({ kind: 'failed', error });
20
+
21
+ /**
22
+ * `work()` raced against `budgetMs` of REAL elapsed time. `budgetMs` is required and is a `number`:
23
+ * every drain has a budget, so "no budget" is not a case this has to answer for, and the signature
24
+ * is what stops it from becoming one.
25
+ *
26
+ * A synchronous throw and a rejection are one outcome (`failed`); the caller logs both the same
27
+ * way. Nothing here throws: a drain that rejects never reaches `process.exit(0)`.
28
+ */
29
+ export function settleWithin(
30
+ work: () => void | Promise<void>,
31
+ budgetMs: number,
32
+ ): Promise<HookOutcome> {
33
+ let started: void | Promise<void>;
34
+ try {
35
+ started = work();
36
+ } catch (error) {
37
+ return Promise.resolve(failed(error));
38
+ }
39
+ const pending = Promise.resolve(started);
40
+ return new Promise<HookOutcome>((resolve) => {
41
+ let decided = false;
42
+ const timer = setTimeout(
43
+ () => {
44
+ if (decided) return;
45
+ decided = true;
46
+ resolve(ABANDONED);
47
+ },
48
+ Math.max(0, budgetMs),
49
+ );
50
+ // A budget already spent still gives a synchronous hook its turn — a resolved promise settles
51
+ // on a microtask and this timer on a macrotask — so closing a pool costs nothing it does not
52
+ // already have. It must also never be the thing keeping a drained process alive.
53
+ timer.unref?.();
54
+ // Attached unconditionally, on both settle paths: an abandoned hook that rejects later has
55
+ // nobody left awaiting it, and an unhandled rejection would kill the process this drain is
56
+ // trying to end cleanly. After the decision the outcome is dropped on purpose — the overrun
57
+ // was already reported, and a second line about a hook nobody is waiting for is noise.
58
+ pending.then(
59
+ () => {
60
+ if (decided) return;
61
+ decided = true;
62
+ clearTimeout(timer);
63
+ resolve(SETTLED);
64
+ },
65
+ (error: unknown) => {
66
+ if (decided) return;
67
+ decided = true;
68
+ clearTimeout(timer);
69
+ resolve(failed(error));
70
+ },
71
+ );
72
+ });
73
+ }
@@ -0,0 +1,33 @@
1
+ // Single responsibility: the one condition a lifecycle that has already drained raises, and the
2
+ // error that carries it. Split from `lifecycle.ts` so the state machine reads as a state machine,
3
+ // and registered here rather than in `error-codes.ts` for the reason `secrets-errors.ts` gives:
4
+ // the code and the module that throws it ship together.
5
+
6
+ import { registerErrorCodes } from './error-codes';
7
+ import { UltimateError } from './errors';
8
+
9
+ registerErrorCodes({
10
+ X_LIFECYCLE_DRAINED: { title: 'a drained process cannot become ready again' },
11
+ });
12
+
13
+ /**
14
+ * A role tried to become ready in a process whose lifecycle has already drained.
15
+ *
16
+ * Loud rather than tolerant, because the silent version was measured and is worse than a failed
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
19
+ * still accepting connections after its own `stop()` returned — a dead listener holding a port,
20
+ * with no log line naming what happened.
21
+ *
22
+ * The `fix` has to answer two readers, because there are exactly two ways here: a boot that starts
23
+ * a role after SIGTERM has already arrived, and a test that drained in one case and built a server
24
+ * in the next.
25
+ */
26
+ export function lifecycleDrained(state: 'draining' | 'stopped'): UltimateError {
27
+ return new UltimateError({
28
+ code: 'X_LIFECYCLE_DRAINED',
29
+ cause: `this process is ${state} — its lifecycle has already drained, and a drain is terminal: one process, one lifecycle. A role marked ready now would bind a socket that answers 503 to every request, and drain() memoizes, so nothing would ever close it`,
30
+ fix: 'start every role BEFORE the first drain — a drained process is on its way out and is replaced, never reused. In a test, call resetLifecycle() from @ultimat3/core between the drain and the next start()',
31
+ meta: { state },
32
+ });
33
+ }