@ultimat3/core 22.7.0 → 22.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/core",
3
- "version": "22.7.0",
3
+ "version": "22.8.0",
4
4
  "description": "Ultimate's foundation: errors, context, env, config, clock, ids, logging, telemetry, lifecycle",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -40,6 +40,6 @@
40
40
  "test": "bun test"
41
41
  },
42
42
  "dependencies": {
43
- "@ultimat3/schema": "22.7.0"
43
+ "@ultimat3/schema": "22.8.0"
44
44
  }
45
45
  }
@@ -21,6 +21,7 @@ import type { Answer, TransportRequest } from './client-dispatch';
21
21
  import { dispatch } from './client-dispatch';
22
22
  import { transportFailed } from './client-problem';
23
23
  import { scopeChanged } from './client-scope-error';
24
+ import { notifyClientWrite } from './client-writes';
24
25
  import type { RecordEnvelope } from './record-envelope';
25
26
  import { decodeRecordEnvelope } from './record-envelope';
26
27
  import { pageClient, recordSink } from './record-sink';
@@ -31,22 +32,31 @@ export async function clientTransport<T = unknown>(req: TransportRequest): Promi
31
32
  const read = req.method === 'GET';
32
33
  const issued = pageClient().scope.epoch;
33
34
  const flight = req.flight;
34
- const answer: Answer =
35
- flight === undefined
36
- ? await dispatch(req, read, issued, undefined)
37
- : await flight.run({
38
- // A mutation never joins another mutation, idempotency key or not: the key is for the
39
- // server's replay, and sharing one dispatch would hide the second intent from it.
40
- key: read ? flight.keyFor(req.url, { signal: req.signal, fresh: req.fresh }) : undefined,
41
- abortable: read,
42
- // A stream is read once, so a second attempt re-sends a body that is already spent —
43
- // and fails as the network would, until the attempts run out. One attempt, always.
44
- retry: req.rawBody instanceof ReadableStream ? ONCE : req.retry,
45
- run: (signal) => dispatch(req, read, issued, signal),
46
- // `dispatch` makes every wire failure `X_CLIENT_TRANSPORT_FAILED`; a bare throw that
47
- // reaches the flight is a caller hook's, never the network's.
48
- classified: true,
49
- });
35
+ let answer: Answer;
36
+ try {
37
+ answer =
38
+ flight === undefined
39
+ ? await dispatch(req, read, issued, undefined)
40
+ : await flight.run({
41
+ // A mutation never joins another mutation, idempotency key or not: the key is for the
42
+ // server's replay, and sharing one dispatch would hide the second intent from it.
43
+ key: read
44
+ ? flight.keyFor(req.url, { signal: req.signal, fresh: req.fresh })
45
+ : undefined,
46
+ abortable: read,
47
+ // A stream is read once, so a second attempt re-sends a body that is already spent —
48
+ // and fails as the network would, until the attempts run out. One attempt, always.
49
+ retry: req.rawBody instanceof ReadableStream ? ONCE : req.retry,
50
+ run: (signal) => dispatch(req, read, issued, signal),
51
+ // `dispatch` makes every wire failure `X_CLIENT_TRANSPORT_FAILED`; a bare throw that
52
+ // reaches the flight is a caller hook's, never the network's.
53
+ classified: true,
54
+ });
55
+ } finally {
56
+ // After it settles, landed or not — a write that failed on the wire may still have committed,
57
+ // and announcing it before it settled left a window for a prefetch to cache the old state.
58
+ if (!read) notifyClientWrite(req.url);
59
+ }
50
60
  const current = pageClient().scope.epoch;
51
61
  // A read that raced the abort still belongs to the previous principal.
52
62
  if (read && current !== issued) throw scopeChanged(req.url, issued, current);
@@ -0,0 +1,40 @@
1
+ /**
2
+ * "A write just went through this tab's client": the one signal a page-side cache of server
3
+ * answers (the client router's prefetched documents) needs to stop trusting them. Emitted by
4
+ * `clientTransport` for every non-GET, landed or failed — a failed write may still have committed.
5
+ * On `globalThis` under one `Symbol.for` key, like the page client: every bundle's copy of core
6
+ * shares one listener set.
7
+ */
8
+
9
+ const KEY = Symbol.for('ultimate.client-writes');
10
+
11
+ type Listener = (url: string) => void;
12
+
13
+ const listeners = (): Set<Listener> => {
14
+ const host = globalThis as unknown as Record<symbol, Set<Listener> | undefined>;
15
+ let set = host[KEY];
16
+ if (set === undefined) {
17
+ set = new Set();
18
+ host[KEY] = set;
19
+ }
20
+ return set;
21
+ };
22
+
23
+ /** Subscribe; returns the unsubscribe. A throwing listener never fails the write. */
24
+ export function onClientWrite(fn: Listener): () => void {
25
+ const entry: Listener = (url) => fn(url);
26
+ listeners().add(entry);
27
+ return (): void => {
28
+ listeners().delete(entry);
29
+ };
30
+ }
31
+
32
+ export function notifyClientWrite(url: string): void {
33
+ for (const listener of [...listeners()]) {
34
+ try {
35
+ listener(url);
36
+ } catch {
37
+ // A cache that could not clear is the listener's defect; the write itself stands.
38
+ }
39
+ }
40
+ }
@@ -0,0 +1,67 @@
1
+ // Single responsibility: the `navigation` block of `app.config.ts` — which surfaces move between
2
+ // their pages by client-side navigation over server-rendered documents (`@ultimat3/render`'s
3
+ // `navigation.ts`), and the boot-time refusal of a surface that has no pages to navigate between.
4
+ //
5
+ // Split out of `config.ts` at its 500-line ceiling, the way `config-site.ts` and `config-pwa.ts`
6
+ // were: shape, merge and screen are one subject.
7
+
8
+ import { describeValue } from './error-render';
9
+
10
+ /**
11
+ * The surfaces that render documents a browser navigates between. `api` answers JSON and `shared`
12
+ * renders nothing, so neither can opt in — and `navigation: { client: ['api'] }` is refused rather
13
+ * than accepted and ignored.
14
+ */
15
+ export const NAVIGATION_SURFACES = ['site', 'app'] as const;
16
+ export type NavigationSurface = (typeof NAVIGATION_SURFACES)[number];
17
+
18
+ /**
19
+ * `client` lists the surfaces whose same-surface links and forms are followed by the client
20
+ * router: it fetches the next document, swaps it in, and keeps the tab's islands, sockets and
21
+ * scroll state alive. EMPTY BY DEFAULT — every navigation is a full document load, and a surface
22
+ * that does not opt in ships no router byte (axiom 6: a `site/` page stays 0kb).
23
+ */
24
+ export interface NavigationConfig {
25
+ readonly client: readonly NavigationSurface[];
26
+ }
27
+
28
+ export interface NavigationSection {
29
+ readonly navigation: NavigationConfig;
30
+ }
31
+
32
+ export interface NavigationSectionInput {
33
+ readonly navigation?: { readonly client?: readonly NavigationSurface[] | undefined } | undefined;
34
+ }
35
+
36
+ /** A whole-value key: the last layer that listed surfaces wins, as `locales` does. */
37
+ export function mergeNavigation(layers: readonly NavigationSectionInput[]): NavigationSection {
38
+ let client: readonly NavigationSurface[] = [];
39
+ for (const layer of layers) {
40
+ const said = layer.navigation?.client;
41
+ if (said !== undefined) client = said;
42
+ }
43
+ return { navigation: { client } };
44
+ }
45
+
46
+ /** Appends every refusal the section earns to `issues`, `config.ts`' one list. */
47
+ export function navigationIssues(config: NavigationSection, issues: string[]): void {
48
+ // `unknown`: an untyped config file reaches this validator with whatever it wrote.
49
+ const client: unknown = config.navigation.client;
50
+ if (!Array.isArray(client)) {
51
+ issues.push(`navigation.client must be a list of surfaces, not ${describeValue(client)}`);
52
+ return;
53
+ }
54
+ const seen = new Set<unknown>();
55
+ for (const surface of client as readonly unknown[]) {
56
+ if (!NAVIGATION_SURFACES.some((known) => known === surface)) {
57
+ // A string is a surface name worth echoing; anything else goes through `describeValue`.
58
+ const said = typeof surface === 'string' ? `"${surface}"` : describeValue(surface);
59
+ issues.push(
60
+ `navigation.client contains ${said}, not one of ${NAVIGATION_SURFACES.join(', ')}`,
61
+ );
62
+ } else if (seen.has(surface)) {
63
+ issues.push(`navigation.client lists "${String(surface)}" twice`);
64
+ }
65
+ seen.add(surface);
66
+ }
67
+ }
package/src/config.ts CHANGED
@@ -12,6 +12,8 @@ import { BASE_FIX, CACHE_TIER_FIX, TIMEZONE_FIX } from './config-fixes';
12
12
  import type { DrainConfig, HealthConfig } from './config-health';
13
13
  import { readinessModeIssue } from './config-health';
14
14
  import { type Input, lastSaid, layered } from './config-merge';
15
+ import type { NavigationConfig, NavigationSectionInput } from './config-navigation';
16
+ import { mergeNavigation, navigationIssues } from './config-navigation';
15
17
  import type { PwaConfig, PwaOfflineConfig } from './config-pwa';
16
18
  import { PWA_FIX, pwaIssues } from './config-pwa';
17
19
  import type { SeoConfig, SiteConfig, SiteSectionsInput } from './config-site';
@@ -205,6 +207,7 @@ export interface AppConfig {
205
207
  readonly health: HealthConfig;
206
208
  readonly site: SiteConfig;
207
209
  readonly seo: SeoConfig;
210
+ readonly navigation: NavigationConfig;
208
211
  }
209
212
 
210
213
  /** `mcp` is the only member, and it is NESTED — `Input<AiConfig>` would make it all-or-nothing. */
@@ -223,7 +226,7 @@ export interface PwaConfigInput extends Omit<Input<PwaConfig>, 'offline'> {
223
226
  readonly offline?: Input<PwaOfflineConfig> | undefined;
224
227
  }
225
228
 
226
- export interface AppConfigInput extends SiteSectionsInput {
229
+ export interface AppConfigInput extends SiteSectionsInput, NavigationSectionInput {
227
230
  readonly name: string;
228
231
  readonly locales?: readonly string[] | undefined;
229
232
  readonly defaultLocale?: string | undefined;
@@ -265,7 +268,7 @@ function isLocale(value: string): boolean {
265
268
  }
266
269
  }
267
270
 
268
- function defaults(name: string): Omit<AppConfig, 'name' | 'site' | 'seo'> {
271
+ function defaults(name: string): Omit<AppConfig, 'name' | 'site' | 'seo' | 'navigation'> {
269
272
  return {
270
273
  locales: ['en'],
271
274
  defaultLocale: 'en',
@@ -373,10 +376,10 @@ function validate(config: AppConfig): void {
373
376
  }
374
377
 
375
378
  // What an install needs, asked at BOOT and not at emit — `config-pwa.ts` owns the rules and the
376
- // remedy, because `pwa.enabled` turning four other requirements on is a question about that block
377
- // and nothing else here.
379
+ // remedy: `pwa.enabled` turning four other requirements on is a question about that block alone.
378
380
  if (pwaIssues(config.pwa, issues)) pwaFix.push(PWA_FIX);
379
381
  siteIssues(config, issues);
382
+ navigationIssues(config, issues);
380
383
 
381
384
  // A rung the ladder cannot build is the defect this key had: `sortTiers` places a name by its
382
385
  // index in `CACHE_TIERS`, and a name missing from it sorts to `-1` — AHEAD of the request memo.
@@ -489,6 +492,7 @@ export function defineConfig(
489
492
  layers.map((layer) => layer.health),
490
493
  ),
491
494
  ...mergeSite(layers),
495
+ ...mergeNavigation(layers),
492
496
  };
493
497
 
494
498
  validate(config);
package/src/index.ts CHANGED
@@ -90,6 +90,7 @@ export { clientTransport } from './client-transport';
90
90
  /** What a typed client puts on the wire. `retryForStatus` is what fills a failure's `retry`. */
91
91
  export type { WireAnswer } from './client-wire';
92
92
  export { FRAMEWORK_CODE, problemOf, retryForStatus, traceHeaders } from './client-wire';
93
+ export { notifyClientWrite, onClientWrite } from './client-writes';
93
94
  export { type Clock, type FrozenClock, frozenClock, systemClock } from './clock';
94
95
  export type {
95
96
  AiConfig,
@@ -112,6 +113,13 @@ export type {
112
113
  export { defineConfig, INBOX_RETENTION_KEYS } from './config';
113
114
  export type { DrainConfig, HealthConfig, ReadinessMode } from './config-health';
114
115
  export { READINESS_MODES } from './config-health';
116
+ export type {
117
+ NavigationConfig,
118
+ NavigationSection,
119
+ NavigationSectionInput,
120
+ NavigationSurface,
121
+ } from './config-navigation';
122
+ export { NAVIGATION_SURFACES } from './config-navigation';
115
123
  export type {
116
124
  PwaColors,
117
125
  PwaConfig,
@@ -578,6 +586,10 @@ export { OUTBOX_DRAIN_MESSAGE, type OutboxDrainMessage } from './outbox-drain';
578
586
  export {
579
587
  APP_UPDATE_MESSAGE,
580
588
  CLIENT_BUILD_META,
589
+ CLIENT_NAVIGATION_HEADER,
590
+ CLIENT_NAVIGATION_LOCATION_HEADER,
591
+ CLIENT_NAVIGATION_SCOPE_HEADER,
592
+ CLIENT_NAVIGATION_SURFACE_HEADER,
581
593
  CLIENT_PERSIST_META,
582
594
  CLIENT_SCOPE_HEADER,
583
595
  CLIENT_SCOPE_META,
package/src/page-meta.ts CHANGED
@@ -39,3 +39,26 @@ export const CLIENT_SYNC_WORKER_META = 'ultimate-sync-worker';
39
39
  * place it learns which types `@ultimat3/realtime`'s persister may write. Absent = none.
40
40
  */
41
41
  export const CLIENT_PERSIST_META = 'ultimate-persist';
42
+
43
+ /**
44
+ * The client router's request headers (`@ultimat3/render`'s `navigation.ts`), read by
45
+ * `@ultimat3/http`'s navigation gate before any app code runs. `soft` is a visit the router will
46
+ * swap in; `prefetch` is a guess nobody clicked yet — answered only by a route that opted in.
47
+ */
48
+ export const CLIENT_NAVIGATION_HEADER = 'x-ultimate-navigation';
49
+
50
+ /** `<app>:<surface>` of the document the router is running in — the only pages it may swap in. */
51
+ export const CLIENT_NAVIGATION_SURFACE_HEADER = 'x-ultimate-surface';
52
+
53
+ /**
54
+ * The principal of the document the router is running in, when it carries `ultimate-scope`.
55
+ * Absent means an unscoped document. A page rendered for someone else is refused before `load`.
56
+ */
57
+ export const CLIENT_NAVIGATION_SCOPE_HEADER = 'x-ultimate-navigation-scope';
58
+
59
+ /**
60
+ * On a `204` to a router request: "load THIS with a real navigation". The server answers it
61
+ * instead of a redirect (the handler already ran; the browser follows once) and instead of a page
62
+ * that must be a real document load (nothing ran; the browser loads it once).
63
+ */
64
+ export const CLIENT_NAVIGATION_LOCATION_HEADER = 'x-ultimate-location';
package/src/page.ts CHANGED
@@ -20,6 +20,7 @@ export { actionPath, queryPath, splitWords } from './client-paths';
20
20
  export type { ClientScope } from './client-scope';
21
21
  export { onRescope, rescope } from './client-scope';
22
22
  export { clientTransport } from './client-transport';
23
+ export { notifyClientWrite, onClientWrite } from './client-writes';
23
24
  export { type Clock, systemClock } from './clock';
24
25
  export type { ConflictPolicy, Row } from './conflict-policy';
25
26
  // Registry-free: it merges two rows and throws nothing, so the page's record store settles a
@@ -38,6 +39,10 @@ export { OUTBOX_DRAIN_MESSAGE } from './outbox-drain';
38
39
  export {
39
40
  APP_UPDATE_MESSAGE,
40
41
  CLIENT_BUILD_META,
42
+ CLIENT_NAVIGATION_HEADER,
43
+ CLIENT_NAVIGATION_LOCATION_HEADER,
44
+ CLIENT_NAVIGATION_SCOPE_HEADER,
45
+ CLIENT_NAVIGATION_SURFACE_HEADER,
41
46
  CLIENT_PERSIST_META,
42
47
  CLIENT_SCOPE_HEADER,
43
48
  CLIENT_SCOPE_META,