@ultimat3/core 20.2.1 → 22.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 (59) hide show
  1. package/CLAUDE.md +189 -523
  2. package/README.md +62 -1
  3. package/package.json +6 -3
  4. package/src/actor.ts +14 -0
  5. package/src/address-class.ts +143 -0
  6. package/src/async-state.ts +19 -0
  7. package/src/canonical-json.ts +24 -1
  8. package/src/client-dispatch.ts +163 -0
  9. package/src/client-flight.ts +22 -3
  10. package/src/client-paths.ts +79 -0
  11. package/src/client-problem.ts +94 -0
  12. package/src/client-scope-error.ts +18 -0
  13. package/src/client-scope.ts +59 -0
  14. package/src/client-transport.ts +86 -0
  15. package/src/config-count.ts +19 -0
  16. package/src/config-fixes.ts +23 -0
  17. package/src/config-merge.ts +36 -0
  18. package/src/config.ts +122 -65
  19. package/src/conflict-policy.ts +48 -0
  20. package/src/context.ts +12 -1
  21. package/src/core-error-codes.ts +78 -0
  22. package/src/dev-secrets.ts +45 -0
  23. package/src/error-codes.ts +17 -66
  24. package/src/error-retry.ts +3 -0
  25. package/src/exports/error-contract.ts +2 -2
  26. package/src/exports/secrets.ts +1 -0
  27. package/src/generation-fence.ts +9 -2
  28. package/src/host-rules.ts +71 -0
  29. package/src/image/exif-orientation.ts +40 -0
  30. package/src/image/probe.ts +13 -1
  31. package/src/in-process-fetch.ts +39 -0
  32. package/src/index.ts +80 -30
  33. package/src/iso-date.ts +5 -0
  34. package/src/lifecycle-grace.ts +44 -0
  35. package/src/lifecycle-signals.ts +35 -0
  36. package/src/lifecycle.ts +44 -36
  37. package/src/logger.ts +22 -3
  38. package/src/measurement-actor.ts +52 -0
  39. package/src/metrics-text.ts +10 -2
  40. package/src/otlp-metric-exporter.ts +39 -12
  41. package/src/otlp-span-exporter.ts +38 -15
  42. package/src/outbound-headers.ts +16 -0
  43. package/src/outbox-drain.ts +14 -0
  44. package/src/page-meta.ts +41 -0
  45. package/src/page.ts +52 -0
  46. package/src/pending-records.ts +58 -0
  47. package/src/record-envelope-openapi.ts +41 -0
  48. package/src/record-envelope.ts +98 -0
  49. package/src/record-sink.ts +129 -0
  50. package/src/schema-error-codes.ts +1 -1
  51. package/src/secrets-errors.ts +1 -1
  52. package/src/secrets-store.ts +51 -5
  53. package/src/service.ts +9 -0
  54. package/src/source-mask.ts +30 -0
  55. package/src/telemetry.ts +3 -0
  56. package/src/type-pins.ts +9 -0
  57. package/src/write-digest.ts +29 -0
  58. package/src/write-origin.ts +31 -0
  59. package/src/result.ts +0 -78
package/README.md CHANGED
@@ -8,7 +8,6 @@ Zero dependencies, zero `@ultimat3/*` imports.
8
8
  | `UltimateError`, the 3-line rendering, `--json` shape | `errors.ts` |
9
9
  | rendering an app's value into a `cause` / `fix` without throwing | `error-render.ts` |
10
10
  | code → `{ title, docs }` registry, `registerErrorCodes()` | `error-codes.ts` |
11
- | `Result<T, E>` for boundaries where throwing is wrong | `result.ts` |
12
11
  | the one lazy `AsyncLocalStorage`, every ambient scope in the framework | `async-context.ts` |
13
12
  | request context on that seam | `context.ts` |
14
13
  | `Actor` (`user \| service \| agent \| anonymous`) | `actor.ts` |
@@ -22,14 +21,23 @@ Zero dependencies, zero `@ultimat3/*` imports.
22
21
  | whether an answer still applies — `X_SUPERSEDED` | `generation-fence.ts` |
23
22
  | the five composed into one typed-client call — dedup, fence, retry, deadline, ceiling | `client-flight.ts` |
24
23
  | what a typed client puts on the wire and reads back off it | `client-wire.ts` |
24
+ | the ONE browser HTTP function — credentials, JSON, the error decode, records, the fence | `client-transport.ts` + `client-dispatch.ts` + `client-problem.ts` |
25
+ | the ONE URL rule for actions and queries | `client-paths.ts` |
26
+ | the records envelope an answer carries behind `x-ultimate-records: 1` | `record-envelope.ts` |
27
+ | the per-tab page handle (`globalThis[Symbol.for('ultimate.client')]`) records land in | `record-sink.ts` |
28
+ | which principal the page acts for, and the epoch that moves when it changes | `client-scope.ts` |
29
+ | which row survives a conflict — `server-wins`, `last-write-wins`, `custom` | `conflict-policy.ts` |
30
+ | the four shapes an async region can be in | `async-state.ts` |
25
31
  | is this `unknown` a keyed record? | `json-object.ts` |
26
32
  | typed env validated at boot | `env.ts` |
27
33
  | `.env.example` rendered from that schema, and its drift check | `env-example.ts` |
28
34
  | named environments + `ULTIMATE_ENV` resolution | `environment.ts` |
35
+ | the boot refusal of a shipped dev signing secret outside development/test — `X_CURSOR_SECRET_DEV` | `dev-secrets.ts` |
29
36
  | a value that cannot be printed by accident | `secret.ts` |
30
37
  | the committed encrypted secrets envelope, AES-256-GCM | `secrets.ts` |
31
38
  | the two secrets files, and decrypted values → `defineEnv` | `secrets-store.ts` |
32
39
  | `defineConfig()` for `app.config.ts` | `config.ts` |
40
+ | how overlays layer onto it — per section, key by key | `config-merge.ts` |
33
41
  | the `pwa` block — what an install needs, and the boot refusal when it is not there | `config-pwa.ts` |
34
42
  | the closed route vocabulary every renderer names | `route-vocabulary.ts` |
35
43
  | runtime roles + `ROLE` resolution | `roles.ts` |
@@ -47,6 +55,9 @@ Zero dependencies, zero `@ultimat3/*` imports.
47
55
  | the `/metrics` scrape body | `metrics-text.ts` |
48
56
  | the series every process emits, incl. what the chart scales on | `runtime-metrics.ts` |
49
57
  | graceful drain, `/healthz`, `/readyz` | `lifecycle.ts` |
58
+ | the readiness grace between `/readyz` → 503 and the listener closing (`drain.readinessGraceMs`) | `lifecycle-grace.ts` |
59
+ | SIGTERM/SIGINT → the one drain | `lifecycle-signals.ts` |
60
+ | which network an IP literal belongs to — `classifyAddress`, for SSRF screens | `address-class.ts` |
50
61
  | the sockets this process opened, so a self-request is not egress | `listeners.ts` |
51
62
  | `defineService('orgs', …)` → `ctx.orgs`, rebuilt per actor | `service.ts` |
52
63
  | the registrar table one same-tier package reaches another through | `registrar.ts` |
@@ -157,6 +168,40 @@ Enforced, not documented: `x verify`'s `errors` step fails with `X_ERROR_RENDER_
157
168
  parameter typed `unknown` reaches a `cause:` or `fix:` through `JSON.stringify`, `String()` or a
158
169
  bare interpolation (`scripts/error-render.ts`).
159
170
 
171
+ ### Error classes
172
+
173
+ Every error class `src/index.ts` exports, for `instanceof` inside one process. Across a wire or
174
+ a job boundary the class is gone and the `code` is what survives — match on that.
175
+
176
+ | Class | Code | Declared in |
177
+ |---|---|---|
178
+ | `ConfigInvalidError` | `X_CONFIG_INVALID` | `src/errors.ts` |
179
+ | `CursorInvalidError` | `X_CURSOR_INVALID` | `src/cursor.ts` |
180
+ | `CursorSecretDevError` | `X_CURSOR_SECRET_DEV` | `src/dev-secrets.ts` |
181
+ | `EnvExampleDriftError` | `X_ENV_EXAMPLE_DRIFT` | `src/env-example.ts` |
182
+ | `EnvironmentInvalidError` | `X_ENVIRONMENT_INVALID` | `src/environment.ts` |
183
+ | `EnvMissingError` | `X_ENV_MISSING` | `src/errors.ts` |
184
+ | `ErrorReporterDsnInvalidError` | `X_ERROR_REPORTER_DSN_INVALID` | `src/error-reporter-sentry.ts` |
185
+ | `ImageDecodeFailedError` | `X_IMAGE_DECODE_FAILED` | `src/image/errors.ts` |
186
+ | `ImageTooLargeError` | `X_IMAGE_TOO_LARGE` | `src/image/errors.ts` |
187
+ | `ImageUnsupportedError` | `X_IMAGE_UNSUPPORTED` | `src/image/errors.ts` |
188
+ | `InternalError` | `X_INTERNAL` | `src/errors.ts` |
189
+ | `MetricCardinalityError` | `X_METRIC_CARDINALITY` | `src/metrics.ts` |
190
+ | `MetricNameInvalidError` | `X_METRIC_NAME_INVALID` | `src/metric-names.ts` |
191
+ | `MetricValueInvalidError` | `X_METRIC_VALUE_INVALID` | `src/metrics.ts` |
192
+ | `NotImplementedError` | `X_NOT_IMPLEMENTED` | `src/errors.ts` |
193
+ | `OtlpEndpointInvalidError` | `X_OTLP_ENDPOINT_INVALID` | `src/otlp.ts` |
194
+ | `OtlpHeadersInvalidError` | `X_OTLP_HEADERS_INVALID` | `src/otlp.ts` |
195
+ | `OtlpProtocolUnsupportedError` | `X_OTLP_PROTOCOL_UNSUPPORTED` | `src/otlp.ts` |
196
+ | `SecretsFileInvalidError` | `X_SECRETS_FILE_INVALID` | `src/secrets-errors.ts` |
197
+ | `SecretsFileMissingError` | `X_SECRETS_FILE_MISSING` | `src/secrets-errors.ts` |
198
+ | `SecretsKeyInvalidError` | `X_SECRETS_KEY_INVALID` | `src/secrets-errors.ts` |
199
+ | `SecretsKeyMismatchError` | `X_SECRETS_KEY_MISMATCH` | `src/secrets-errors.ts` |
200
+ | `SecretsKeyMissingError` | `X_SECRETS_KEY_MISSING` | `src/secrets-errors.ts` |
201
+ | `SecretsPlaintextInvalidError` | `X_SECRETS_PLAINTEXT_INVALID` | `src/secrets-errors.ts` |
202
+ | `SecretsTamperedError` | `X_SECRETS_TAMPERED` | `src/secrets-errors.ts` |
203
+ | `UltimateError` | any registered code — every class in the framework extends it | `src/errors.ts` |
204
+
160
205
  ## Context
161
206
 
162
207
  ```ts
@@ -562,6 +607,22 @@ cause and a runnable `fix:` with the fact that something was retried, which no r
562
607
  `classifyThrown` and `statedDelayMs` live in `error-retry.ts`, one import away from the table they
563
608
  read; `@ultimat3/jobs` re-exports both rather than keeping a second pair.
564
609
 
610
+ ## One browser seam — transport, records, page handle, principal fence
611
+
612
+ | Export | The one answer | The question it settles |
613
+ |---|---|---|
614
+ | `clientTransport({ method, url, body?, rawBody?, headers?, signal?, idempotencyKey?, flight?, fresh?, retry?, onResponse?, decodeError?, onEnvelope?, fetchImpl? })` | every browser request | `credentials: 'same-origin'`, JSON in and out, the `idempotency-key` header, a non-2xx `problem+json` back into the server's code (`meta.origin: 'remote'`) unless `decodeError` answers first, a network `TypeError` into `X_CLIENT_TRANSPORT_FAILED`. A GET is abortable on `rescope()` and deduped only when a `flight` is passed (`fresh` refuses to join); any other method is never deduped and never aborted by the fence. `rawBody` goes out verbatim with no default header and resolves `undefined`. `onResponse` sees headers before the body is read. `onEnvelope` sees the decoded records envelope after adoption — the one way to learn the order of `records[type]`. `fetchImpl` defaults to `globalThis.fetch`, read at call time |
615
+ | `actionPath(name)`, `actionRoute(name)`, `queryPath(name)`, `QUERY_PATH_PREFIX`, `splitWords`, `pluralize` | the one URL rule | `publishPost` → `/api/posts/publish`, `liveFeed` → `/_x/query/live-feed`. Tier 0 so `action`, `query` and `realtime` derive one URL with no sideways import |
616
+ | `RECORDS_HEADER`, `encodeRecordEnvelope`, `decodeRecordEnvelope`, `RecordRows` | `{ data, records?: { [type]: { [key]: Row } }, removed?: { [type]: key[] } }`, only behind `x-ultimate-records: 1` | how an answer carries entity rows without changing the wire of an answer that has none. A malformed envelope is `X_CLIENT_RECORD_ENVELOPE_INVALID` |
617
+ | `pageClient()` → `{ store, socket, scope }`, `RecordSink` | one handle per TAB, not per module copy | every island bundle carries its own core; the handle lives on `globalThis` under one `Symbol.for` key so all of them resolve the same store. No store installed = records dropped |
618
+ | `rescope(principal)`, `onRescope(fn)`, `isSuperseded(error)` | the principal fence | a principal change bumps the epoch and notifies synchronously; reads in flight reject `X_CLIENT_SCOPE_CHANGED`, writes complete but their records are not adopted. `isSuperseded` answers true for it and for `X_SUPERSEDED` |
619
+ | `resolveConflict(policy, local, server, { clockField? })`, `ConflictPolicy`, `Row` | one conflict vocabulary, over ROWS | `last-write-wins` keeps the local row only when its numeric clock field (default `updatedAt`) is newer; no provable clock = the server's row |
620
+ | `AsyncState<T>` | `pending \| refreshing \| ready \| failed` | the type `realtime` produces and `ui` renders; tier 0 because neither may import the other |
621
+
622
+ `clientTransport` does not value-import `createClientFlight` or `traceHeaders()` — measured sizes
623
+ are in its file header. A server-side caller that propagates a trace passes `traceHeaders()` in
624
+ `headers` itself.
625
+
565
626
  ## One image pipeline, everywhere
566
627
 
567
628
  ```ts
package/package.json CHANGED
@@ -1,11 +1,12 @@
1
1
  {
2
2
  "name": "@ultimat3/core",
3
- "version": "20.2.1",
3
+ "version": "22.0.0",
4
4
  "description": "Ultimate's foundation: errors, context, env, config, clock, ids, logging, telemetry, lifecycle",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "sideEffects": [
8
8
  "./src/context.ts",
9
+ "./src/core-error-codes.ts",
9
10
  "./src/lifecycle-errors.ts",
10
11
  "./src/schema-error-codes.ts",
11
12
  "./src/secrets-errors.ts"
@@ -20,11 +21,13 @@
20
21
  "provenance": true
21
22
  },
22
23
  "exports": {
23
- ".": "./src/index.ts"
24
+ ".": "./src/index.ts",
25
+ "./page": "./src/page.ts"
24
26
  },
25
27
  "files": [
26
28
  "src",
27
29
  "!src/**/*.test.ts",
30
+ "!src/**/*-fixture.ts",
28
31
  "CLAUDE.md",
29
32
  "README.md",
30
33
  "LICENSE"
@@ -37,6 +40,6 @@
37
40
  "test": "bun test"
38
41
  },
39
42
  "dependencies": {
40
- "@ultimat3/schema": "20.2.1"
43
+ "@ultimat3/schema": "22.0.0"
41
44
  }
42
45
  }
package/src/actor.ts CHANGED
@@ -105,6 +105,14 @@ export interface Actor {
105
105
  * one the customer issued. Every surface that renders an actor renders this with it.
106
106
  */
107
107
  readonly onBehalfOf?: ActorOrigin | undefined;
108
+ /**
109
+ * The actor's SAVED language and IANA zone — the `user` rung of `@ultimat3/i18n`'s
110
+ * `resolveLocale` and `@ultimat3/time`'s `resolveTimeZone`. Whoever authenticates sets them from
111
+ * the user record; `@ultimat3/http` re-resolves `ctx.locale` / `ctx.tz` with them once the actor
112
+ * is known, so a cookie the reader chose still wins exactly where those owners say it does.
113
+ */
114
+ readonly locale?: string | undefined;
115
+ readonly tz?: string | undefined;
108
116
  }
109
117
 
110
118
  export interface ActorInit {
@@ -117,6 +125,9 @@ export interface ActorInit {
117
125
  readonly facts?: ActorFactMap | undefined;
118
126
  /** For a session that already recorded an impersonation; `impersonate()` sets it otherwise. */
119
127
  readonly onBehalfOf?: ActorOrigin | undefined;
128
+ /** Saved preferences — see `Actor.locale`. */
129
+ readonly locale?: string | undefined;
130
+ readonly tz?: string | undefined;
120
131
  }
121
132
 
122
133
  const NO_FACTS: ActorFactMap = Object.freeze({});
@@ -143,6 +154,9 @@ function build(kind: ActorKind, init: ActorInit): Actor {
143
154
  permissions: Object.freeze([...(init.permissions ?? [])]),
144
155
  facts: Object.freeze({ ...init.facts }),
145
156
  onBehalfOf: init.onBehalfOf === undefined ? undefined : Object.freeze({ ...init.onBehalfOf }),
157
+ // Absent keys, not `undefined` ones: an actor is compared and serialised in tests and logs.
158
+ ...(init.locale === undefined ? {} : { locale: init.locale }),
159
+ ...(init.tz === undefined ? {} : { tz: init.tz }),
146
160
  });
147
161
  }
148
162
 
@@ -0,0 +1,143 @@
1
+ // Single responsibility: which kind of network an IP address literal belongs to. Tier 0 because the
2
+ // SSRF screens that need it sit in packages that may not import each other — `@ultimat3/jobs`'
3
+ // webhook delivery (tier 3) and `@ultimat3/scraping` (tier 5) — and a copy per screen is how one
4
+ // of them comes to miss `::ffff:127.0.0.1`.
5
+
6
+ /**
7
+ * `reserved` is every range that is neither a host network nor public: multicast, broadcast,
8
+ * `240.0.0.0/4`, the documentation and benchmarking nets. A screen allowing only `public` refuses
9
+ * all of them, which is the point of naming them rather than calling them public.
10
+ */
11
+ export type AddressClass =
12
+ | 'loopback'
13
+ | 'private'
14
+ | 'link-local'
15
+ | 'ula'
16
+ | 'cgnat'
17
+ | 'unspecified'
18
+ | 'reserved'
19
+ | 'public';
20
+
21
+ /** `[network, prefixBits]` over the 32-bit value; ordered, first match wins. */
22
+ const V4_RULES: readonly (readonly [number, number, AddressClass])[] = [
23
+ [0x00000000, 8, 'unspecified'],
24
+ [0x7f000000, 8, 'loopback'],
25
+ [0x0a000000, 8, 'private'],
26
+ [0xac100000, 12, 'private'],
27
+ [0xc0a80000, 16, 'private'],
28
+ [0xa9fe0000, 16, 'link-local'],
29
+ [0x64400000, 10, 'cgnat'],
30
+ [0xc0000000, 24, 'reserved'], // 192.0.0.0/24, IETF protocol assignments
31
+ [0xc0000200, 24, 'reserved'], // 192.0.2.0/24, TEST-NET-1
32
+ [0xc6120000, 15, 'reserved'], // 198.18.0.0/15, benchmarking
33
+ [0xc6336400, 24, 'reserved'], // 198.51.100.0/24, TEST-NET-2
34
+ [0xcb007100, 24, 'reserved'], // 203.0.113.0/24, TEST-NET-3
35
+ [0xe0000000, 4, 'reserved'], // multicast
36
+ [0xf0000000, 4, 'reserved'], // 240.0.0.0/4 and the broadcast address inside it
37
+ ];
38
+
39
+ /**
40
+ * Strict dotted quad: four decimal octets, no leading zero. `010.0.0.1` is octal to some parsers
41
+ * and decimal to others, so it is not an address this module will vouch for — a screen that
42
+ * refuses what it cannot classify is the only safe reader of it.
43
+ */
44
+ function parseV4(text: string): number | undefined {
45
+ const parts = text.split('.');
46
+ if (parts.length !== 4) return undefined;
47
+ let value = 0;
48
+ for (const part of parts) {
49
+ if (!/^(?:0|[1-9]\d{0,2})$/.test(part)) return undefined;
50
+ const octet = Number(part);
51
+ if (octet > 255) return undefined;
52
+ value = value * 256 + octet;
53
+ }
54
+ return value;
55
+ }
56
+
57
+ function classifyV4(value: number): AddressClass {
58
+ for (const [network, bits, kind] of V4_RULES) {
59
+ const size = 2 ** (32 - bits);
60
+ if (value >= network && value < network + size) return kind;
61
+ }
62
+ return 'public';
63
+ }
64
+
65
+ /** Eight 16-bit groups, or `undefined`. A trailing dotted quad fills the last two. */
66
+ function parseV6(text: string): readonly number[] | undefined {
67
+ let body = text;
68
+ let tail: number[] = [];
69
+ const lastColon = body.lastIndexOf(':');
70
+ if (body.includes('.', lastColon)) {
71
+ const v4 = parseV4(body.slice(lastColon + 1));
72
+ if (v4 === undefined) return undefined;
73
+ tail = [Math.floor(v4 / 0x10000), v4 % 0x10000];
74
+ // `::ffff:1.2.3.4` keeps `::ffff`; `::1.2.3.4` keeps its `::`, the one colon that is syntax.
75
+ const head = body.slice(0, lastColon + 1);
76
+ body = head.endsWith('::') ? head : head.slice(0, -1);
77
+ }
78
+ const halves = body.split('::');
79
+ if (halves.length > 2) return undefined;
80
+ const groups = (half: string): number[] | undefined => {
81
+ if (half === '') return [];
82
+ const out: number[] = [];
83
+ for (const group of half.split(':')) {
84
+ if (!/^[0-9a-f]{1,4}$/i.test(group)) return undefined;
85
+ out.push(Number.parseInt(group, 16));
86
+ }
87
+ return out;
88
+ };
89
+ const head = groups(halves[0] ?? '');
90
+ const rest = halves.length === 2 ? groups(halves[1] ?? '') : [];
91
+ if (head === undefined || rest === undefined) return undefined;
92
+ const width = head.length + rest.length + tail.length;
93
+ if (halves.length === 1) return width === 8 ? [...head, ...tail] : undefined;
94
+ if (width > 7) return undefined;
95
+ return [...head, ...new Array<number>(8 - width).fill(0), ...rest, ...tail];
96
+ }
97
+
98
+ const embeddedV4 = (g: readonly number[]): number => (g[6] ?? 0) * 0x10000 + (g[7] ?? 0);
99
+
100
+ function classifyV6(g: readonly number[]): AddressClass {
101
+ const zeroUpTo = (n: number): boolean => g.slice(0, n).every((group) => group === 0);
102
+ if (zeroUpTo(8)) return 'unspecified';
103
+ if (zeroUpTo(7) && g[7] === 1) return 'loopback';
104
+ // IPv4-mapped `::ffff:a.b.c.d`, IPv4-compatible `::a.b.c.d` and NAT64 `64:ff9b::/96`: each is
105
+ // routed to the IPv4 address it carries, so that address is what gets classified.
106
+ if (zeroUpTo(5) && g[5] === 0xffff) return classifyV4(embeddedV4(g));
107
+ if (zeroUpTo(6)) return classifyV4(embeddedV4(g));
108
+ if (g[0] === 0x64 && g[1] === 0xff9b && g.slice(2, 6).every((group) => group === 0)) {
109
+ return classifyV4(embeddedV4(g));
110
+ }
111
+ const first = g[0] ?? 0;
112
+ if ((first & 0xffc0) === 0xfe80) return 'link-local';
113
+ if ((first & 0xffc0) === 0xfec0) return 'private'; // deprecated site-local
114
+ if ((first & 0xfe00) === 0xfc00) return 'ula';
115
+ if ((first & 0xff00) === 0xff00) return 'reserved'; // multicast
116
+ if (first === 0x2001 && g[1] === 0x0db8) return 'reserved'; // documentation
117
+ if (first === 0x0100 && g.slice(1, 4).every((group) => group === 0)) return 'reserved'; // 100::/64
118
+
119
+ return 'public';
120
+ }
121
+
122
+ /**
123
+ * The class of an IP address LITERAL — IPv4, IPv6, bracketed `[::1]`, a zone id `fe80::1%eth0`,
124
+ * and every IPv6 form carrying an IPv4 address. `undefined` means "not an address literal": a
125
+ * hostname must be resolved first and each resolved address classified, never this string.
126
+ */
127
+ export function classifyAddress(address: string): AddressClass | undefined {
128
+ let text = address.trim();
129
+ if (text.startsWith('[') && text.endsWith(']')) text = text.slice(1, -1);
130
+ const zone = text.indexOf('%');
131
+ if (zone !== -1 && text.includes(':')) text = text.slice(0, zone);
132
+ if (text.includes(':')) {
133
+ const groups = parseV6(text);
134
+ return groups === undefined ? undefined : classifyV6(groups);
135
+ }
136
+ const v4 = parseV4(text);
137
+ return v4 === undefined ? undefined : classifyV4(v4);
138
+ }
139
+
140
+ /** Fails CLOSED: anything but a literal classified `public` — a hostname included — is `false`. */
141
+ export function isPublicAddress(address: string): boolean {
142
+ return classifyAddress(address) === 'public';
143
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * The four shapes an async region can be in — pending, refreshing, ready, failed. Tier 0 because
3
+ * every client layer produces or consumes one: `@ultimat3/realtime`'s `useQuery` returns it (tier 3)
4
+ * and `@ultimat3/ui`'s `asyncBranch` renders it (tier 4), and neither may import the other's way.
5
+ */
6
+
7
+ /**
8
+ * A STATE, never a query: a live-query accessor, a `createResource` and a plain signal all arrive
9
+ * at a region as the same four shapes.
10
+ *
11
+ * `refreshing` is the one that makes search feel fast — it CARRIES the previous data, so a refetch
12
+ * re-renders what is already on screen instead of tearing it down to a skeleton. `pending` holds
13
+ * no data, which is what makes "no results" before the first answer unconstructible.
14
+ */
15
+ export type AsyncState<T> =
16
+ | { readonly status: 'pending' }
17
+ | { readonly status: 'refreshing'; readonly data: T }
18
+ | { readonly status: 'ready'; readonly data: T }
19
+ | { readonly status: 'failed'; readonly error: unknown };
@@ -50,6 +50,27 @@ function hashCollection(value: Map<unknown, unknown> | Set<unknown>): string {
50
50
  return `${value instanceof Map ? 'Map' : 'Set'}(${entries.sort().join(',')})`;
51
51
  }
52
52
 
53
+ /** Lowercase hex, two digits a byte. Not `toBase64`: this module ships to browsers that lack it. */
54
+ function hexOf(bytes: Uint8Array): string {
55
+ let out = '';
56
+ for (const byte of bytes) out += byte.toString(16).padStart(2, '0');
57
+ return out;
58
+ }
59
+
60
+ /**
61
+ * Tagged, because a typed array's indices ARE own enumerable keys: `Uint8Array([1])` rendered
62
+ * `{"0":1}`, one key with the plain object `{ 0: 1 }`. The view's OWN window is read, never the
63
+ * whole backing buffer, and any view but a `Uint8Array` carries its type name — a `Uint16Array([1])`
64
+ * and a `Uint8Array([1, 0])` hold the same bytes and are different payloads.
65
+ */
66
+ function hashBytes(value: ArrayBuffer | ArrayBufferView): string {
67
+ if (value instanceof ArrayBuffer) return `ArrayBuffer(${hexOf(new Uint8Array(value))})`;
68
+ const bytes = new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
69
+ const tag =
70
+ value instanceof Uint8Array ? 'Bytes' : Object.prototype.toString.call(value).slice(8, -1);
71
+ return `${tag}(${hexOf(bytes)})`;
72
+ }
73
+
53
74
  /**
54
75
  * JSON with object keys sorted at every depth, and every value a JSON document would fold onto
55
76
  * `null` or `{}` given a token of its own. No timestamps, no insertion-order leaks.
@@ -64,7 +85,8 @@ export function canonicalJson(value: unknown): string {
64
85
  case 'boolean':
65
86
  return String(value);
66
87
  case 'bigint':
67
- return JSON.stringify(`${value}n`);
88
+ // A bare token like `Date(…)`: quoted as `"5n"`, it was one key with the STRING `'5n'`.
89
+ return `BigInt(${value})`;
68
90
  case 'undefined':
69
91
  case 'function':
70
92
  case 'symbol':
@@ -77,6 +99,7 @@ export function canonicalJson(value: unknown): string {
77
99
  // every set alike.
78
100
  if (value instanceof Date) return hashDate(value);
79
101
  if (value instanceof Map || value instanceof Set) return hashCollection(value);
102
+ if (value instanceof ArrayBuffer || ArrayBuffer.isView(value)) return hashBytes(value);
80
103
  if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`;
81
104
  const record = value as Record<string, unknown>;
82
105
  const keys = Object.keys(record)
@@ -0,0 +1,163 @@
1
+ /**
2
+ * One dispatch of a `clientTransport` request: the `RequestInit`, the fence's abort for a read,
3
+ * the network fault's code, and the ok/error split — nothing about how many times it happens
4
+ * (the flight's) or what the answer means (the transport's).
5
+ */
6
+
7
+ import type { ClientFlight, ClientRetry } from './client-flight';
8
+ import { problemError, transportFailed } from './client-problem';
9
+ import { onRescope } from './client-scope';
10
+ import { scopeChanged } from './client-scope-error';
11
+ import type { UltimateError } from './errors';
12
+ import type { RecordEnvelope } from './record-envelope';
13
+ import { RECORDS_HEADER } from './record-envelope';
14
+ import { outboundSlot, pageClient } from './record-sink';
15
+
16
+ export type FetchLike = (input: string, init: RequestInit) => Promise<Response>;
17
+
18
+ /** The header `@ultimat3/action`'s server reads to replay rather than re-run a write. */
19
+ export const IDEMPOTENCY_HEADER = 'idempotency-key';
20
+
21
+ export interface TransportRequest {
22
+ readonly method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
23
+ readonly url: string;
24
+ /** JSON-encoded, with `content-type: application/json`. */
25
+ readonly body?: unknown;
26
+ /**
27
+ * Sent VERBATIM instead of `body` — bytes to a signed URL. No default header is added (a signed
28
+ * request may cover them), and the answer is not decoded: the call resolves `undefined`.
29
+ */
30
+ readonly rawBody?: BodyInit | undefined;
31
+ /** Spread LAST, so an explicit value wins over every default. */
32
+ readonly headers?: Readonly<Record<string, string>> | undefined;
33
+ readonly signal?: AbortSignal | undefined;
34
+ readonly idempotencyKey?: string | undefined;
35
+ /** Dedup, retry, deadline and a concurrency ceiling — opt-in; a read dedupes only with one. */
36
+ readonly flight?: ClientFlight | undefined;
37
+ /** Refuse to join a read that left before this call did. Meaningful only with a `flight`. */
38
+ readonly fresh?: boolean | undefined;
39
+ /** This call's retry override, handed to the `flight`. */
40
+ readonly retry?: ClientRetry | undefined;
41
+ /**
42
+ * The decoded records envelope, after its records were adopted — only when the answer carried
43
+ * `x-ultimate-records: 1` and belongs to the current principal. What a caller reads to know the
44
+ * ORDER of `records[type]`, which the store (keyed, unordered) cannot give it back.
45
+ */
46
+ readonly onEnvelope?: ((envelope: RecordEnvelope) => void) | undefined;
47
+ /** Sees every response before its body is read — a header check may throw its own refusal. */
48
+ readonly onResponse?: ((response: Response) => void | Promise<void>) | undefined;
49
+ /** A non-2xx answer as the caller's own error; `undefined` falls back to the shared decode. */
50
+ readonly decodeError?: ((status: number, text: string) => UltimateError | undefined) | undefined;
51
+ /** Default `globalThis.fetch`, read at call time — never captured at module scope. */
52
+ readonly fetchImpl?: FetchLike | undefined;
53
+ }
54
+
55
+ /** One dispatch's answer: immutable TEXT, so a deduped read's joiners each parse their own. */
56
+ export interface Answer {
57
+ readonly text: string;
58
+ readonly enveloped: boolean;
59
+ }
60
+
61
+ /** Called, never captured: a browser's `fetch` throws `Illegal invocation` when detached. */
62
+ const browserFetch: FetchLike = (input, init) => globalThis.fetch(input, init);
63
+
64
+ export async function dispatch(
65
+ req: TransportRequest,
66
+ read: boolean,
67
+ issued: number,
68
+ flightSignal: AbortSignal | undefined,
69
+ ): Promise<Answer> {
70
+ // A read gets its own controller so the fence can abort it; a write never does, and only its
71
+ // caller's own signal reaches it (a flight never hands a write one).
72
+ const fence = read ? abortable([req.signal, flightSignal]) : undefined;
73
+ const signal = fence === undefined ? req.signal : fence.signal;
74
+ try {
75
+ const response = await onTheWire(req, read, () =>
76
+ (req.fetchImpl ?? browserFetch)(req.url, initOf(req, signal)),
77
+ );
78
+ await req.onResponse?.(response);
79
+ // Read as TEXT once: a body is a single-use stream, and the failure path wants it too.
80
+ const text = response.status === 204 ? '' : await onTheWire(req, read, () => response.text());
81
+ if (!response.ok) {
82
+ throw (
83
+ req.decodeError?.(response.status, text) ?? problemError(response.status, text, req.url)
84
+ );
85
+ }
86
+ return { text, enveloped: response.headers.get(RECORDS_HEADER) === '1' };
87
+ } catch (error) {
88
+ const current = pageClient().scope.epoch;
89
+ if (read && current !== issued) throw scopeChanged(req.url, issued, current);
90
+ throw error;
91
+ } finally {
92
+ fence?.release();
93
+ }
94
+ }
95
+
96
+ /**
97
+ * The network's half of a dispatch, and only that half. `fetch` — and a body read cut mid-stream —
98
+ * rejects with a bare `TypeError` when no response arrived; that is `failure: 'network'`. A caller's
99
+ * `onResponse` or `decodeError` throwing a `TypeError` is a bug in the caller, and classifying it
100
+ * as the network made it retryable, so a flight re-sent the request for it. An abort is a decision
101
+ * and passes through untouched.
102
+ */
103
+ async function onTheWire<T>(
104
+ req: TransportRequest,
105
+ read: boolean,
106
+ work: () => Promise<T>,
107
+ ): Promise<T> {
108
+ try {
109
+ return await work();
110
+ } catch (error) {
111
+ if (!(error instanceof TypeError)) throw error;
112
+ throw transportFailed(
113
+ 'network',
114
+ `${req.method} ${req.url} produced no response — the network refused or dropped it`,
115
+ read || req.idempotencyKey !== undefined ? 'retryable' : undefined,
116
+ { url: req.url, method: req.method },
117
+ error,
118
+ );
119
+ }
120
+ }
121
+
122
+ function initOf(req: TransportRequest, signal: AbortSignal | undefined): RequestInit {
123
+ const raw = req.rawBody !== undefined;
124
+ const headers: Record<string, string> = raw ? {} : { accept: 'application/json' };
125
+ if (req.body !== undefined && !raw) headers['content-type'] = 'application/json';
126
+ if (req.idempotencyKey !== undefined) headers[IDEMPOTENCY_HEADER] = req.idempotencyKey;
127
+ const body = raw ? req.rawBody : req.body === undefined ? undefined : JSON.stringify(req.body);
128
+ // Server-side only: the slot is filled by `runWithContext`/`startSpan` (`outbound-headers.ts`),
129
+ // and a signed raw upload never carries it — an extra header can break the signature.
130
+ const outbound = raw ? undefined : outboundSlot().outboundHeaders?.();
131
+ return {
132
+ method: req.method,
133
+ credentials: 'same-origin',
134
+ headers: { ...headers, ...outbound, ...req.headers },
135
+ ...(body === undefined ? {} : { body }),
136
+ ...(signal === undefined ? {} : { signal }),
137
+ };
138
+ }
139
+
140
+ /**
141
+ * A read's controller: aborted by `rescope()`, by the caller's signal or by the flight's. Every
142
+ * listener comes off in `release`, so a long-lived caller signal does not collect one per request.
143
+ */
144
+ function abortable(sources: readonly (AbortSignal | undefined)[]): {
145
+ readonly signal: AbortSignal;
146
+ release(): void;
147
+ } {
148
+ const controller = new AbortController();
149
+ const detach: (() => void)[] = [onRescope(() => controller.abort())];
150
+ for (const source of sources) {
151
+ if (source === undefined) continue;
152
+ if (source.aborted) controller.abort(source.reason);
153
+ const forward = (): void => controller.abort(source.reason);
154
+ source.addEventListener('abort', forward, { once: true });
155
+ detach.push(() => source.removeEventListener('abort', forward));
156
+ }
157
+ return {
158
+ signal: controller.signal,
159
+ release: (): void => {
160
+ for (const off of detach) off();
161
+ },
162
+ };
163
+ }
@@ -61,6 +61,12 @@ export function isTransientFailure(error: unknown): boolean {
61
61
  return isNetworkRejection(error);
62
62
  }
63
63
 
64
+ /** Only a classification somebody declared — for work that already classified its own wire. */
65
+ function isDeclaredTransient(error: unknown): boolean {
66
+ const declared = classifyThrown(error);
67
+ return declared !== undefined && declared !== 'terminal';
68
+ }
69
+
64
70
  /**
65
71
  * A dispatch that never produced a response. `fetch` rejects with a plain `TypeError` when the
66
72
  * network is down, DNS fails or the connection drops mid-body — the one unclassified throw a
@@ -89,6 +95,13 @@ export interface FlightPlan<T> {
89
95
  run(signal: AbortSignal | undefined, attempt: number): Promise<T>;
90
96
  /** Overrides the flight's policy for this one call. */
91
97
  readonly retry?: ClientRetry | undefined;
98
+ /**
99
+ * `true` when `run` already turns every wire failure into a classified error, as
100
+ * `clientTransport` does. An UNclassified throw is then the caller's own — a hook's `TypeError`
101
+ * — and is never sent again, where the default would read it as a network rejection. A
102
+ * flight-wide `transient` still wins: that is the app's own answer.
103
+ */
104
+ readonly classified?: boolean | undefined;
92
105
  }
93
106
 
94
107
  export interface ClientFlightOptions {
@@ -151,6 +164,8 @@ export function createClientFlight(options: ClientFlightOptions = {}): ClientFli
151
164
  schedule(done, ms);
152
165
  }));
153
166
  const transient = options.transient ?? isTransientFailure;
167
+ const transientFor = (plan: FlightPlan<unknown>): ((error: unknown) => boolean) =>
168
+ plan.classified === true && options.transient === undefined ? isDeclaredTransient : transient;
154
169
  const deadlineMs = options.deadlineMs;
155
170
  const flights = createSingleFlight({ deadlineMs, schedule });
156
171
  const gate: FlightGate | undefined =
@@ -169,12 +184,13 @@ export function createClientFlight(options: ClientFlightOptions = {}): ClientFli
169
184
  // and a foreign `TypeError` again. The original value is rethrown below, unwrapped — wrapping
170
185
  // it would replace a code, a cause and a runnable `fix:` with the fact that something retried.
171
186
  let stopped: { readonly error: unknown } | undefined;
187
+ const sendAgain = transientFor(plan);
172
188
  const answer = await retry<T | typeof STOPPED>(
173
189
  async (count) => {
174
190
  try {
175
191
  return await plan.run(signal, count);
176
192
  } catch (error) {
177
- if (transient(error)) throw error;
193
+ if (sendAgain(error)) throw error;
178
194
  stopped = { error };
179
195
  return STOPPED;
180
196
  }
@@ -259,8 +275,11 @@ export function createClientFlight(options: ClientFlightOptions = {}): ClientFli
259
275
  const work = (): Promise<T> =>
260
276
  gate === undefined ? dispatch(plan) : gate.run(() => dispatch(plan));
261
277
  // The single flight sits OUTSIDE the gate: a joiner takes no slot, so dedup relieves the
262
- // ceiling instead of queueing behind it.
263
- return settle(plan.key === undefined ? work() : flights.run(plan.key, work), issued);
278
+ // ceiling instead of queueing behind it. Keyed by GENERATION too: `bump()` aborts the old
279
+ // flights, but each holds its key until its rejection settles, and a same-key read issued
280
+ // in that window joined the aborted one and was answered with its AbortError.
281
+ const key = plan.key === undefined ? undefined : JSON.stringify([issued, plan.key]);
282
+ return settle(key === undefined ? work() : flights.run(key, work), issued);
264
283
  },
265
284
 
266
285
  get inflight(): number {