@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 +2 -2
- package/src/client-transport.ts +26 -16
- package/src/client-writes.ts +40 -0
- package/src/config-navigation.ts +67 -0
- package/src/config.ts +8 -4
- package/src/index.ts +12 -0
- package/src/page-meta.ts +23 -0
- package/src/page.ts +5 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/core",
|
|
3
|
-
"version": "22.
|
|
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.
|
|
43
|
+
"@ultimat3/schema": "22.8.0"
|
|
44
44
|
}
|
|
45
45
|
}
|
package/src/client-transport.ts
CHANGED
|
@@ -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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
|
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,
|