@ultimat3/core 22.14.0 → 23.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.
- package/CLAUDE.md +17 -2
- package/README.md +59 -1
- package/package.json +2 -2
- package/src/client-paths.ts +26 -6
- package/src/config-navigation.ts +122 -3
- package/src/core-error-codes.ts +5 -0
- package/src/error-render.ts +4 -2
- package/src/error-retry.ts +8 -0
- package/src/index.ts +28 -1
- package/src/logger.ts +26 -0
- package/src/page-meta.ts +7 -0
- package/src/page.ts +1 -0
- package/src/process-metrics.ts +206 -0
- package/src/seal-errors.ts +76 -0
- package/src/seal-keys.ts +121 -0
- package/src/seal.ts +259 -0
- package/src/secrets-errors.ts +21 -0
- package/src/secrets.ts +21 -11
package/CLAUDE.md
CHANGED
|
@@ -48,6 +48,9 @@ top-level `UltimateError` use in `error-codes.ts`.
|
|
|
48
48
|
- **A string's length is CODE POINTS** — `validators.ts` rejects in that unit and
|
|
49
49
|
`json-schema.ts` publishes `minLength` in it. `parseId`/`uuidTimestamp` describe a rejected id and
|
|
50
50
|
never echo it: a value baked into a message has no log field key to redact.
|
|
51
|
+
- **A test never patches `process.stdout` to read a log line**: `setLogSink(sink)` is the seam, and the
|
|
52
|
+
test preloads install one that drops every line (`LOG_LEVEL` named = not installed). It decides
|
|
53
|
+
WHERE a line goes, never whether — the level is untouched.
|
|
51
54
|
- `logger.ts` must not import `context.ts` (`context.ts` injects ids via `setLoggerContextFields()`).
|
|
52
55
|
It imports `secret.ts` one way only: `secret.ts` owns `REDACTED`, `logger.ts` re-exports it.
|
|
53
56
|
- **`ActorFacts` is the app's extension point on `Actor`** (module augmentation). Core never declares
|
|
@@ -71,7 +74,7 @@ top-level `UltimateError` use in `error-codes.ts`.
|
|
|
71
74
|
| which HTTP statuses are worth repeating | `retryable-status.ts` | `>= 500` plus 408, 409, 425, 429 |
|
|
72
75
|
| how long this request has left | `request-budget.ts` (`Ctx.deadlineAt`, `REQUEST_TIMEOUT_HEADER`) | `@ultimat3/http`'s `startDeadline` is the one writer of the instant; `traceHeaders()` the one writer of the header. A spent budget sends nothing, never `0` |
|
|
73
76
|
| the above, composed into one typed-client call | `client-flight.ts` + `client-wire.ts` | shared by `@ultimat3/action` and `@ultimat3/query` (both tier 3), re-exported by both. Declares no code of its own |
|
|
74
|
-
| the browser's one HTTP function, records envelope, per-tab handle and principal fence | `client-transport.ts`, `client-dispatch.ts`, `client-problem.ts`, `client-paths.ts`, `record-envelope.ts`, `record-sink.ts`, `client-scope.ts` | `pageClient()` is the ONE `globalThis` write (`Symbol.for('ultimate.client')`); the scope's listeners live ON the handle. `clientTransport` never value-imports `createClientFlight` or `traceHeaders()`: trace/budget headers reach it through the page handle's OUTBOUND SLOT (`outbound-headers.ts`). `isSuperseded` answers both `X_SUPERSEDED` and `X_CLIENT_SCOPE_CHANGED`. The fence never `bump()`s a caller's flight. A write never dedupes; a read dedupes only with a caller's `flight`. `pageClient()` reads `<meta name="ultimate-scope">` (`CLIENT_SCOPE_META`) once: content = principal, empty = anonymous (`null`), absent = UNSCOPED (`undefined`, nothing persisted). Byte figures (`As of 2026-
|
|
77
|
+
| the browser's one HTTP function, records envelope, per-tab handle and principal fence | `client-transport.ts`, `client-dispatch.ts`, `client-problem.ts`, `client-paths.ts`, `record-envelope.ts`, `record-sink.ts`, `client-scope.ts` | `pageClient()` is the ONE `globalThis` write (`Symbol.for('ultimate.client')`); the scope's listeners live ON the handle. `clientTransport` never value-imports `createClientFlight` or `traceHeaders()`: trace/budget headers reach it through the page handle's OUTBOUND SLOT (`outbound-headers.ts`). `isSuperseded` answers both `X_SUPERSEDED` and `X_CLIENT_SCOPE_CHANGED`. The fence never `bump()`s a caller's flight. A write never dedupes; a read dedupes only with a caller's `flight`. `pageClient()` reads `<meta name="ultimate-scope">` (`CLIENT_SCOPE_META`) once: content = principal, empty = anonymous (`null`), absent = UNSCOPED (`undefined`, nothing persisted). `actionPath(name)` with no `style` reads the document's `<meta name="ultimate-path-style">` (`renderedActionPathStyle()`; `CLIENT_PATH_STYLE_META` in `page-meta.ts`) and falls back to `'resource'`. Byte figures (`As of 2026-10-01`, `bun build --target=browser --minify`): `clientTransport` 14,251 B and `pageClient` 8,348 B through the barrel, 9,633 B and 1,156 B through `./page`; the whole `./page` entry 15,979 B against `page-bundle.test.ts`'s 16,384; `rpc`'s live in `packages/action/CLAUDE.md`, `queryClient`'s in `packages/query/CLAUDE.md` |
|
|
75
78
|
| a write's public name | `write-digest.ts` (`writeDigest`, `isWriteDigest`, also on `./page`) + `write-origin.ts` (`withWriteOrigin`, `currentWriteOrigin`, `WRITE_ORIGIN_WAL_PREFIX`, server-only) | SHA-256 of an idempotency key, 32 hex; carried action → entity → WAL → realtime `records` frame. A malformed value runs the work unnamed: a label, never a gate |
|
|
76
79
|
| which row survives a conflict | `conflict-policy.ts` (`ConflictPolicy`, `resolveConflict`, `Row`) | read by `action`'s mutator and `realtime`'s rebase |
|
|
77
80
|
| the four shapes of an async region | `async-state.ts` (`AsyncState`) | `realtime` returns it, `ui` renders it. `bun run render-modes` refuses a second status union sharing three members |
|
|
@@ -80,6 +83,7 @@ top-level `UltimateError` use in `error-codes.ts`.
|
|
|
80
83
|
| an `Intl` formatter cache, and the screen in front of it | `intl-cache.ts` (`cachedFormatter`, `canonicalLocale`, `assertLocale`, `MAX_CACHED_FORMATTERS`, `MAX_LOCALE_EXCERPT`) | a locale arrives from a header: refuse a non-tag (`X_LOCALE_INVALID`), key canonically AND bound the cache — never a copy of any of the three. The cause quotes at most `MAX_LOCALE_EXCERPT` (35) code points; the whole tag rides in `meta.locale` |
|
|
81
84
|
| the text direction of a locale | `locale-direction.ts` (`directionOf`, `isRtl`, `Direction`) | re-exported by `@ultimat3/i18n`; lives here so `@ultimat3/ui` need not reach the i18n barrel |
|
|
82
85
|
| the committed encrypted values | `secrets.ts` (envelope) + `secrets-store.ts` (files, `installSecrets`) | plaintext is a flat map of ENV NAMES; there is no `secrets.get()` |
|
|
86
|
+
| ONE sealed value | `seal.ts` (`seal`, `open`, `openText`, `sealAll`) + `seal-keys.ts` (the ring) + `seal-errors.ts` | the framework's one AES call above the envelope: `entity`'s sealed column and `scraping`'s stored session call it, never WebCrypto. `purpose` is required and is AAD |
|
|
83
87
|
|
|
84
88
|
- **`installSecrets()` is the ONLY path from `secrets.enc.json` to an app value**, landing in
|
|
85
89
|
`process.env` before `defineEnv` reads it. The real environment always wins.
|
|
@@ -91,6 +95,14 @@ top-level `UltimateError` use in `error-codes.ts`.
|
|
|
91
95
|
`X_SECRETS_KEY_MISMATCH`'s command carries no key id (it is read from a file). Its seven codes
|
|
92
96
|
register through `registerErrorCodes()`, so `resetErrorCodes()` drops them — take
|
|
93
97
|
`errorCodeSnapshot()` first. The envelope's `kid` lets *wrong key* and *edited file* be two codes.
|
|
98
|
+
- **`seal.ts` adds no variable for the CURRENT key** — `findMasterKey()`, the same call
|
|
99
|
+
`installSecrets()` makes. Retired keys are `ULTIMATE_SECRETS_RETIRED_KEYS`, written into
|
|
100
|
+
`secrets.enc.json` by `x secrets rotate`; the ring memo is keyed by its source strings, never by
|
|
101
|
+
time. Constants, `parseMasterKey`, `masterKeyId`, `importKey` and the base64 codec are
|
|
102
|
+
`secrets.ts`'s — no second set. `seal-errors.ts` runs nothing at import: its titles are in
|
|
103
|
+
`CORE_CODE_TITLES` and its retry class in `CORE_ERROR_RETRY`, so it is no `sideEffects` anchor.
|
|
104
|
+
The wire form `x1.<keyId>.<iv>.<ciphertext+tag>` and the AAD string are persisted data: a change
|
|
105
|
+
is `x2`, never an edit.
|
|
94
106
|
- **`schema-error-codes.ts` registers `@ultimat3/schema`'s codes** (schema cannot call core), and
|
|
95
107
|
derives their retry classification from the same set. It is a `SIDE_EFFECTS_ANCHORS` entry.
|
|
96
108
|
- `timing-safe-equal.ts` is the one constant-time comparison (`@ultimat3/auth`, `@ultimat3/storage`).
|
|
@@ -134,7 +146,10 @@ top-level `UltimateError` use in `error-codes.ts`.
|
|
|
134
146
|
`metrics.ts` is to `telemetry.ts` what a counter is to a span: always on, no-op exporter by
|
|
135
147
|
default. `runtime-metrics.ts` is the only place that names a series the chart reads
|
|
136
148
|
(`http_requests_total`, `connections`, `queue_depth`), keyed by `ScalingSignal` in
|
|
137
|
-
`SCALING_METRICS`.
|
|
149
|
+
`SCALING_METRICS`. `process-metrics.ts` names what the PROCESS costs (`process_*`: resident
|
|
150
|
+
memory, heap, external, CPU seconds, event-loop lag, start time, `process_info{role}`); server-only
|
|
151
|
+
— it reads `process`, so it is never exported from `page.ts`. One call site per package; a second
|
|
152
|
+
is the bug:
|
|
138
153
|
|
|
139
154
|
| Recorder | The one caller |
|
|
140
155
|
|---|---|
|
package/README.md
CHANGED
|
@@ -36,6 +36,8 @@ Zero dependencies, zero `@ultimat3/*` imports.
|
|
|
36
36
|
| a value that cannot be printed by accident | `secret.ts` |
|
|
37
37
|
| the committed encrypted secrets envelope, AES-256-GCM | `secrets.ts` |
|
|
38
38
|
| the two secrets files, and decrypted values → `defineEnv` | `secrets-store.ts` |
|
|
39
|
+
| one value sealed under the master key — `seal()` / `open()` | `seal.ts` |
|
|
40
|
+
| the key ring those work under: the current key plus retired ones | `seal-keys.ts` |
|
|
39
41
|
| `defineConfig()` for `app.config.ts` | `config.ts` |
|
|
40
42
|
| how overlays layer onto it — per section, key by key | `config-merge.ts` |
|
|
41
43
|
| the `pwa` block — what an install needs, and the boot refusal when it is not there | `config-pwa.ts` |
|
|
@@ -43,7 +45,7 @@ Zero dependencies, zero `@ultimat3/*` imports.
|
|
|
43
45
|
| runtime roles + `ROLE` resolution | `roles.ts` |
|
|
44
46
|
| `Clock` — the only source of "now" | `clock.ts` |
|
|
45
47
|
| UUIDv7, nanoid, branded ids | `ids.ts` |
|
|
46
|
-
| structured JSON logging + redaction | `logger.ts` |
|
|
48
|
+
| structured JSON logging + redaction; `setLogSink(sink)` — the test seam that sends every default-writer line to a sink instead of the process's streams (a test preload drops them; a test asserting on the process logger collects them) | `logger.ts` |
|
|
47
49
|
| OTel-shaped spans, always on, no-op by default | `telemetry.ts` |
|
|
48
50
|
| the sampling decision, and `OTEL_TRACES_SAMPLER*` | `sampler.ts` |
|
|
49
51
|
| OTLP/HTTP JSON: endpoint, headers, value encoding | `otlp.ts` |
|
|
@@ -54,6 +56,7 @@ Zero dependencies, zero `@ultimat3/*` imports.
|
|
|
54
56
|
| OTel-shaped counter / gauge / histogram, same seam | `metrics.ts` |
|
|
55
57
|
| the `/metrics` scrape body | `metrics-text.ts` |
|
|
56
58
|
| the series every process emits, incl. what the chart scales on | `runtime-metrics.ts` |
|
|
59
|
+
| what the process itself costs: `process_resident_memory_bytes`, `process_heap_used_bytes`, `process_heap_total_bytes`, `process_external_memory_bytes`, `process_cpu_seconds_total`, `process_event_loop_lag_seconds`, `process_start_time_seconds`, `process_info{role}` — **server-only**, never on `@ultimat3/core/page` | `process-metrics.ts` (`startProcessMetrics`, `readProcess`) |
|
|
57
60
|
| graceful drain, `/healthz`, `/readyz` | `lifecycle.ts` |
|
|
58
61
|
| the readiness grace between `/readyz` → 503 and the listener closing (`drain.readinessGraceMs`) | `lifecycle-grace.ts` |
|
|
59
62
|
| SIGTERM/SIGINT → the one drain | `lifecycle-signals.ts` |
|
|
@@ -193,9 +196,13 @@ a job boundary the class is gone and the `code` is what survives — match on th
|
|
|
193
196
|
| `OtlpEndpointInvalidError` | `X_OTLP_ENDPOINT_INVALID` | `src/otlp.ts` |
|
|
194
197
|
| `OtlpHeadersInvalidError` | `X_OTLP_HEADERS_INVALID` | `src/otlp.ts` |
|
|
195
198
|
| `OtlpProtocolUnsupportedError` | `X_OTLP_PROTOCOL_UNSUPPORTED` | `src/otlp.ts` |
|
|
199
|
+
| `SealInvalidError` | `X_SEAL_INVALID` | `src/seal-errors.ts` |
|
|
200
|
+
| `SealKeyMissingError` | `X_SEAL_KEY_MISSING` | `src/seal-errors.ts` |
|
|
201
|
+
| `SealKeyUnknownError` | `X_SEAL_KEY_UNKNOWN` | `src/seal-errors.ts` |
|
|
196
202
|
| `SecretsFileInvalidError` | `X_SECRETS_FILE_INVALID` | `src/secrets-errors.ts` |
|
|
197
203
|
| `SecretsFileMissingError` | `X_SECRETS_FILE_MISSING` | `src/secrets-errors.ts` |
|
|
198
204
|
| `SecretsKeyInvalidError` | `X_SECRETS_KEY_INVALID` | `src/secrets-errors.ts` |
|
|
205
|
+
| `SecretsRingKeyInvalidError` | `X_SECRETS_KEY_INVALID` — a malformed entry of `ULTIMATE_SECRETS_RETIRED_KEYS` | `src/secrets-errors.ts` |
|
|
199
206
|
| `SecretsKeyMismatchError` | `X_SECRETS_KEY_MISMATCH` | `src/secrets-errors.ts` |
|
|
200
207
|
| `SecretsKeyMissingError` | `X_SECRETS_KEY_MISSING` | `src/secrets-errors.ts` |
|
|
201
208
|
| `SecretsPlaintextInvalidError` | `X_SECRETS_PLAINTEXT_INVALID` | `src/secrets-errors.ts` |
|
|
@@ -350,6 +357,56 @@ A missing file is not an error — an app may declare no secrets. A file with **
|
|
|
350
357
|
is `X_SECRETS_KEY_MISSING` and fatal: a process that booted past its secrets authenticates against
|
|
351
358
|
nothing and still reports healthy.
|
|
352
359
|
|
|
360
|
+
## Seal one value
|
|
361
|
+
|
|
362
|
+
One function seals a value under the app's master key. Nothing above tier 0 writes its own AES call.
|
|
363
|
+
|
|
364
|
+
```ts
|
|
365
|
+
import { openText, seal } from '@ultimat3/core';
|
|
366
|
+
|
|
367
|
+
export async function roundTrip(password: string): Promise<string> {
|
|
368
|
+
const purpose = 'scrape-session';
|
|
369
|
+
// 'x1.4f2a9c0d1e2b3a4f.<iv>.<ciphertext+tag>' — one string, base64url
|
|
370
|
+
const stored = await seal(password, { purpose });
|
|
371
|
+
return openText(stored, { purpose });
|
|
372
|
+
}
|
|
373
|
+
```
|
|
374
|
+
|
|
375
|
+
A column is sealed by declaring it — `text().sealed()` in `@ultimat3/entity`, which derives the
|
|
376
|
+
purpose `entity:<table>.<column>` and calls this. Call `seal()` yourself only for a value that is
|
|
377
|
+
not a column.
|
|
378
|
+
|
|
379
|
+
| Export | Signature | |
|
|
380
|
+
|---|---|---|
|
|
381
|
+
| `seal` | `(plaintext: string \| Uint8Array, options: SealOptions) => Promise<string>` | always under the CURRENT key |
|
|
382
|
+
| `open` | `(sealed: string, options: SealPurposeOptions) => Promise<Uint8Array>` | picks the key the string names |
|
|
383
|
+
| `openText` | `(sealed: string, options: SealPurposeOptions) => Promise<string>` | the string spelling; no JSON helper |
|
|
384
|
+
| `sealAll` | `(plaintext: string \| Uint8Array, options: SealPurposeOptions) => Promise<readonly string[]>` | the deterministic seal under every declared key, current first |
|
|
385
|
+
| `isSealed` | `(value: unknown) => value is string` | shape only — tells a legacy plaintext row from a sealed one |
|
|
386
|
+
| `sealedKeyId` | `(sealed: string) => string` | what a re-seal `backfill()` compares to the current id |
|
|
387
|
+
| `sealKeyIds` | `(source?: SealKeySource) => Promise<{ current: string; retired: readonly string[] }>` | ids, never keys |
|
|
388
|
+
| `resolveSealKeys` | `(source?: SealKeySource) => Promise<SealKeyRing>` | the ring, resolved once — pass it as `keys` to seal or open MANY values in one operation |
|
|
389
|
+
|
|
390
|
+
`SealPurposeOptions` is `{ purpose: string; root?: string; env?: Record<string, string | undefined> }`;
|
|
391
|
+
`SealOptions` adds `deterministic?: boolean`. `root` and `env` default to the working directory and
|
|
392
|
+
`process.env`, exactly as `installSecrets()` does. `keys?: SealKeyRing` skips that lookup: a batch
|
|
393
|
+
resolves the ring once and hands it to every call — never kept past the operation, so the next one
|
|
394
|
+
sees a rotation.
|
|
395
|
+
|
|
396
|
+
| Rule | |
|
|
397
|
+
|---|---|
|
|
398
|
+
| Key | the one `x secrets` manages — `ULTIMATE_SECRETS_KEY` first, `.secrets.key` second. No second variable |
|
|
399
|
+
| `purpose` | REQUIRED, bound as additional authenticated data with the key id: a value sealed for `scrape-session` does not open as `entity:connections.password` |
|
|
400
|
+
| Wire form | `x1.<keyId>.<iv>.<ciphertext+tag>`. `keyId` is `masterKeyId`'s, so a rotated key is a named mismatch, never a garbled read |
|
|
401
|
+
| Key ring | retired keys in `ULTIMATE_SECRETS_RETIRED_KEYS` (comma-separated hex). `x secrets rotate` writes the replaced key there, inside `secrets.enc.json`; `installSecrets()` carries it into the process; `x secrets rotate --drop <keyId>` removes it |
|
|
402
|
+
| Refusals | `X_SEAL_KEY_MISSING`, `X_SEAL_KEY_UNKNOWN`, `X_SEAL_INVALID` — all terminal. Never garbage, never the raw string back, and no reading of an unsealed value |
|
|
403
|
+
|
|
404
|
+
**`deterministic: true` reveals equality.** The IV is an HMAC of the purpose and the plaintext, so
|
|
405
|
+
equal values seal to equal strings and a column can be matched by `=`. Anyone who can read the
|
|
406
|
+
stored strings sees which rows hold the same value; never use it for a low-entropy value (a
|
|
407
|
+
boolean, a status, a PIN). During a rotation one value has one sealed form per declared key —
|
|
408
|
+
match with `sealAll()`; uniqueness cannot be held across keys.
|
|
409
|
+
|
|
353
410
|
## Time, ids, telemetry, drain
|
|
354
411
|
|
|
355
412
|
- Never call `Date.now()`. Take a `Clock`; tests pass `frozenClock('2026-07-26T10:00:00Z')`.
|
|
@@ -614,6 +671,7 @@ read; `@ultimat3/jobs` re-exports both rather than keeping a second pair.
|
|
|
614
671
|
|---|---|---|
|
|
615
672
|
| `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 |
|
|
616
673
|
| `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 |
|
|
674
|
+
| `actionPath(name)` with no `style`, `renderedActionPathStyle()`, `CLIENT_PATH_STYLE_META` | the style nobody restates | `actionPath(name)` reads the document's `<meta name="ultimate-path-style">` stamp, so a browser caller derives under the style the server serves (`defineApi({ http: { pathStyle } })`). No document, no stamp or an unknown value is `'resource'` — a `'resource'` server writes no stamp. A named `style` is never overridden |
|
|
617
675
|
| `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` |
|
|
618
676
|
| `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 |
|
|
619
677
|
| `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` |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/core",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "23.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",
|
|
@@ -40,6 +40,6 @@
|
|
|
40
40
|
"test": "bun test"
|
|
41
41
|
},
|
|
42
42
|
"dependencies": {
|
|
43
|
-
"@ultimat3/schema": "
|
|
43
|
+
"@ultimat3/schema": "23.0.0"
|
|
44
44
|
}
|
|
45
45
|
}
|
package/src/client-paths.ts
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The ONE URL rule for the two HTTP primitives: an action's export name derives `POST
|
|
3
|
-
* /api/<resource>/<verb>`, a query's derives `GET /_x/query/<kebab>`. Tier 0 and
|
|
4
|
-
*
|
|
5
|
-
*
|
|
3
|
+
* /api/<resource>/<verb>`, a query's derives `GET /_x/query/<kebab>`. Tier 0 and string math, so
|
|
4
|
+
* `action`, `query` and `realtime` (all tier 3) derive the same URL with no sideways import and a
|
|
5
|
+
* browser bundle pays a few hundred bytes for it. The one thing read rather than computed is the
|
|
6
|
+
* style a caller did not name: `actionPath` takes it from the document the server rendered.
|
|
6
7
|
*/
|
|
7
8
|
|
|
9
|
+
import { CLIENT_PATH_STYLE_META } from './page-meta';
|
|
10
|
+
|
|
8
11
|
/** Irregular plurals we actually hit in domain models. A `Map`: the key is a caller's word. */
|
|
9
12
|
const IRREGULAR: ReadonlyMap<string, string> = new Map([
|
|
10
13
|
['person', 'people'],
|
|
@@ -98,9 +101,26 @@ export function actionRoute(name: string, style: ActionPathStyle = 'resource'):
|
|
|
98
101
|
return { verb: head, resource, path: `/api/${resource}/${head}` };
|
|
99
102
|
}
|
|
100
103
|
|
|
101
|
-
/**
|
|
102
|
-
|
|
103
|
-
|
|
104
|
+
/**
|
|
105
|
+
* The style the SERVER stamped into this document (`<meta name="ultimate-path-style">`), or
|
|
106
|
+
* `undefined`: no document (a server, a worker, a script), no stamp (a `'resource'` server writes
|
|
107
|
+
* none), or a value this build does not know. A style is a runtime value and a type is erased, so
|
|
108
|
+
* this is the only way a browser learns it without the app restating it.
|
|
109
|
+
*/
|
|
110
|
+
export function renderedActionPathStyle(): ActionPathStyle | undefined {
|
|
111
|
+
const doc: { querySelector?: (selector: string) => { content?: unknown } | null } | undefined =
|
|
112
|
+
Reflect.get(globalThis, 'document');
|
|
113
|
+
const stamped = doc?.querySelector?.(`meta[name="${CLIENT_PATH_STYLE_META}"]`)?.content;
|
|
114
|
+
return ACTION_PATH_STYLES.find((known) => known === stamped);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* The path an action is POSTed to. A caller that names no `style` gets the document's — every
|
|
119
|
+
* browser caller (`rpc`, `useMutation`, the outbox replay) derives under the one the server serves
|
|
120
|
+
* — and `'resource'` where there is no document to ask. A named style is never overridden.
|
|
121
|
+
*/
|
|
122
|
+
export function actionPath(name: string, style?: ActionPathStyle): string {
|
|
123
|
+
return actionRoute(name, style ?? renderedActionPathStyle() ?? 'resource').path;
|
|
104
124
|
}
|
|
105
125
|
|
|
106
126
|
/** `liveFeed` -> `/_x/query/live-feed`, read with `GET …?orgId=…`. */
|
package/src/config-navigation.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
// were: shape, merge and screen are one subject.
|
|
7
7
|
|
|
8
8
|
import { describeValue } from './error-render';
|
|
9
|
+
import { ConfigInvalidError } from './errors';
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* The surfaces that render documents a browser navigates between. `api` answers JSON and `shared`
|
|
@@ -23,28 +24,146 @@ export type NavigationSurface = (typeof NAVIGATION_SURFACES)[number];
|
|
|
23
24
|
*/
|
|
24
25
|
export interface NavigationConfig {
|
|
25
26
|
readonly client: readonly NavigationSurface[];
|
|
27
|
+
readonly speculation: SpeculationConfig;
|
|
26
28
|
}
|
|
27
29
|
|
|
30
|
+
/**
|
|
31
|
+
* How eagerly a browser may fetch a link's document before the click, on a page that carries no
|
|
32
|
+
* client router. `'moderate'`: on pointer rest or pointer down. `'conservative'`: on pointer down
|
|
33
|
+
* only. `'eager'` and `'immediate'` are not offered — they fetch links nobody pointed at.
|
|
34
|
+
*/
|
|
35
|
+
export const SPECULATION_EAGERNESS = ['moderate', 'conservative'] as const;
|
|
36
|
+
export type SpeculationEagerness = (typeof SPECULATION_EAGERNESS)[number];
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Speculation Rules (`<script type="speculationrules">`) for the documents a browser navigates
|
|
40
|
+
* between WITHOUT the client router — the 0kb answer to a slow full-page load. PREFETCH only, never
|
|
41
|
+
* prerender: a prefetch runs no script of the next page, so no analytics hit and no island boots
|
|
42
|
+
* for a page nobody opened. ON BY DEFAULT at `'moderate'`; `prefetch: false` emits nothing.
|
|
43
|
+
*
|
|
44
|
+
* Only pages the route table knows to be a pure read are candidates (`@ultimat3/cli`'s
|
|
45
|
+
* `page-speculation.ts`); `exclude` removes more, as URL patterns (`/blog/*`, `/legal/:doc`).
|
|
46
|
+
*/
|
|
47
|
+
export interface SpeculationConfig {
|
|
48
|
+
readonly prefetch: SpeculationEagerness | false;
|
|
49
|
+
readonly exclude: readonly string[];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export const DEFAULT_SPECULATION: SpeculationConfig = Object.freeze({
|
|
53
|
+
prefetch: 'moderate',
|
|
54
|
+
exclude: Object.freeze([]) as readonly string[],
|
|
55
|
+
});
|
|
56
|
+
|
|
28
57
|
export interface NavigationSection {
|
|
29
58
|
readonly navigation: NavigationConfig;
|
|
30
59
|
}
|
|
31
60
|
|
|
32
61
|
export interface NavigationSectionInput {
|
|
33
|
-
readonly navigation?:
|
|
62
|
+
readonly navigation?:
|
|
63
|
+
| {
|
|
64
|
+
readonly client?: readonly NavigationSurface[] | undefined;
|
|
65
|
+
readonly speculation?:
|
|
66
|
+
| {
|
|
67
|
+
readonly prefetch?: SpeculationEagerness | false | undefined;
|
|
68
|
+
readonly exclude?: readonly string[] | undefined;
|
|
69
|
+
}
|
|
70
|
+
| undefined;
|
|
71
|
+
}
|
|
72
|
+
| undefined;
|
|
34
73
|
}
|
|
35
74
|
|
|
36
|
-
/**
|
|
75
|
+
/**
|
|
76
|
+
* Whole-value keys: the last layer that listed surfaces wins, as `locales` does — and so does the
|
|
77
|
+
* last one that set `speculation.prefetch` or listed `speculation.exclude`, each on its own.
|
|
78
|
+
*/
|
|
37
79
|
export function mergeNavigation(layers: readonly NavigationSectionInput[]): NavigationSection {
|
|
38
80
|
let client: readonly NavigationSurface[] = [];
|
|
81
|
+
let prefetch: SpeculationEagerness | false = DEFAULT_SPECULATION.prefetch;
|
|
82
|
+
let exclude: readonly string[] = DEFAULT_SPECULATION.exclude;
|
|
83
|
+
// A layer that wrote something other than an object (`speculation: 'off'`, `null`, a list) has
|
|
84
|
+
// no key to merge. It is carried through AS WRITTEN so `navigationIssues` refuses it — dropped
|
|
85
|
+
// here, the app would run at the default it believed it had turned off.
|
|
86
|
+
let unmergeable: { readonly said: unknown } | undefined;
|
|
39
87
|
for (const layer of layers) {
|
|
40
88
|
const said = layer.navigation?.client;
|
|
41
89
|
if (said !== undefined) client = said;
|
|
90
|
+
const speculation: unknown = layer.navigation?.speculation;
|
|
91
|
+
if (speculation === undefined) continue;
|
|
92
|
+
if (!isSpeculationObject(speculation)) {
|
|
93
|
+
unmergeable ??= { said: speculation };
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (speculation.prefetch !== undefined) prefetch = speculation.prefetch;
|
|
97
|
+
if (speculation.exclude !== undefined) exclude = speculation.exclude;
|
|
98
|
+
}
|
|
99
|
+
const speculation =
|
|
100
|
+
unmergeable === undefined ? { prefetch, exclude } : (unmergeable.said as SpeculationConfig);
|
|
101
|
+
return { navigation: { client, speculation } };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
type SpeculationInput = NonNullable<
|
|
105
|
+
NonNullable<NavigationSectionInput['navigation']>['speculation']
|
|
106
|
+
>;
|
|
107
|
+
|
|
108
|
+
const isSpeculationObject = (value: unknown): value is SpeculationInput =>
|
|
109
|
+
typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* A pattern is emitted inside a JSON string the browser parses as a URL pattern: it must be a
|
|
113
|
+
* same-origin PATH, so anything not starting with `/` (a host, a scheme, `*`) is refused.
|
|
114
|
+
*/
|
|
115
|
+
function speculationIssues(speculation: unknown, issues: string[]): void {
|
|
116
|
+
if (!isSpeculationObject(speculation)) {
|
|
117
|
+
issues.push(`navigation.speculation must be an object, not ${describeValue(speculation)}`);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
const { prefetch, exclude } = speculation as { prefetch?: unknown; exclude?: unknown };
|
|
121
|
+
if (prefetch !== false && !SPECULATION_EAGERNESS.some((known) => known === prefetch)) {
|
|
122
|
+
issues.push(
|
|
123
|
+
`navigation.speculation.prefetch must be ${SPECULATION_EAGERNESS.join(', ')} or false, not ${describeValue(prefetch)}`,
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
if (!Array.isArray(exclude)) {
|
|
127
|
+
issues.push(
|
|
128
|
+
`navigation.speculation.exclude must be a list of URL patterns, not ${describeValue(exclude)}`,
|
|
129
|
+
);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
for (const pattern of exclude as readonly unknown[]) {
|
|
133
|
+
if (typeof pattern !== 'string' || !pattern.startsWith('/')) {
|
|
134
|
+
issues.push(
|
|
135
|
+
`navigation.speculation.exclude contains ${describeValue(pattern)}, not a path pattern starting with "/"`,
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* `navigation.speculation` as some reader OUTSIDE `defineConfig` found it (`@ultimat3/cli` imports
|
|
143
|
+
* the app's config module structurally): the defaults for what it does not say, and the SAME
|
|
144
|
+
* refusal `defineConfig` gives for what it says wrongly. One validator — a second reader that
|
|
145
|
+
* coerced `'eager'` to `'moderate'` or dropped a bad pattern would serve rules the app never wrote.
|
|
146
|
+
*/
|
|
147
|
+
export function resolveSpeculation(said: unknown): SpeculationConfig {
|
|
148
|
+
if (said === undefined) return DEFAULT_SPECULATION;
|
|
149
|
+
const { speculation } = mergeNavigation([
|
|
150
|
+
{ navigation: { speculation: said as SpeculationInput } },
|
|
151
|
+
]).navigation;
|
|
152
|
+
const issues: string[] = [];
|
|
153
|
+
speculationIssues(speculation, issues);
|
|
154
|
+
if (issues.length > 0) {
|
|
155
|
+
throw new ConfigInvalidError({
|
|
156
|
+
cause: issues.join('; '),
|
|
157
|
+
fix: 'Correct navigation.speculation in app.config.ts: prefetch is "moderate", "conservative" or false, and exclude is a list of path patterns starting with "/"',
|
|
158
|
+
meta: { issues },
|
|
159
|
+
});
|
|
42
160
|
}
|
|
43
|
-
return
|
|
161
|
+
return speculation;
|
|
44
162
|
}
|
|
45
163
|
|
|
46
164
|
/** Appends every refusal the section earns to `issues`, `config.ts`' one list. */
|
|
47
165
|
export function navigationIssues(config: NavigationSection, issues: string[]): void {
|
|
166
|
+
speculationIssues(config.navigation.speculation, issues);
|
|
48
167
|
// `unknown`: an untyped config file reaches this validator with whatever it wrote.
|
|
49
168
|
const client: unknown = config.navigation.client;
|
|
50
169
|
if (!Array.isArray(client)) {
|
package/src/core-error-codes.ts
CHANGED
|
@@ -54,6 +54,11 @@ const CORE_CODE_TITLES = {
|
|
|
54
54
|
X_REGISTRAR_CONFLICT: 'two different registrars are loaded for one primitive kind',
|
|
55
55
|
X_REGISTRAR_MISSING: 'no registrar is loaded for a primitive kind',
|
|
56
56
|
X_ROLE_INVALID: 'ROLE is not a known runtime role',
|
|
57
|
+
// `seal.ts`'s three. Titled here, not registered beside their classes the way the X_SECRETS_*
|
|
58
|
+
// set is, so `seal-errors.ts` runs nothing at import and is no `sideEffects` anchor.
|
|
59
|
+
X_SEAL_INVALID: 'a sealed value did not authenticate, or is not a sealed value',
|
|
60
|
+
X_SEAL_KEY_MISSING: 'no master key to seal or open a value with',
|
|
61
|
+
X_SEAL_KEY_UNKNOWN: 'a sealed value names a master key this process does not declare',
|
|
57
62
|
X_SERVICE_DUPLICATE: 'a service name is registered twice',
|
|
58
63
|
X_SERVICE_MISSING: 'service is not registered on the request context',
|
|
59
64
|
X_SHUTDOWN_TIMEOUT: 'graceful shutdown exceeded its deadline',
|
package/src/error-render.ts
CHANGED
|
@@ -188,9 +188,11 @@ export function renderFixLiteral(value: unknown, placeholder: string): string {
|
|
|
188
188
|
* denylist has to be right about every character every shell will ever read, and this only has to
|
|
189
189
|
* be right about the ones a fix line needs. No space, so a value is always one word; no leading
|
|
190
190
|
* `-` or `~`, because an argument starting with either is an OPTION or a home directory rather
|
|
191
|
-
* than the value it reads as.
|
|
191
|
+
* than the value it reads as. A leading `@` IS carried: a scoped package name starts with one, and
|
|
192
|
+
* `@` opens nothing in a POSIX shell — `$@` needs the `$`, an extglob `@(…)` the parenthesis, and
|
|
193
|
+
* neither is in the set.
|
|
192
194
|
*/
|
|
193
|
-
const SHELL_ARG_SAFE = /^[A-Za-z0-9
|
|
195
|
+
const SHELL_ARG_SAFE = /^[A-Za-z0-9/@][A-Za-z0-9._:/@=+,%~-]*$/;
|
|
194
196
|
|
|
195
197
|
/**
|
|
196
198
|
* The same value where the text is read by a SHELL. A `fix:` is a command meant to be pasted, so a
|
package/src/error-retry.ts
CHANGED
|
@@ -61,6 +61,14 @@ const CORE_ERROR_RETRY: ReadonlyMap<string, ErrorRetry> = new Map(
|
|
|
61
61
|
// The principal fence's twin of `X_SUPERSEDED`, listed for the same reason: re-sending a read
|
|
62
62
|
// from the previous principal's scope is refused identically every time.
|
|
63
63
|
X_CLIENT_SCOPE_CHANGED: 'terminal',
|
|
64
|
+
// `seal.ts`'s three, listed for `X_NOT_IMPLEMENTED`'s reason: a missing key, an undeclared
|
|
65
|
+
// key and a value that failed its tag are each the same answer on attempt five, and left
|
|
66
|
+
// unclassified a job opening a sealed column would spend its whole retry policy re-proving it.
|
|
67
|
+
// Here rather than through `registerErrorRetry` because they are core's own codes, which that
|
|
68
|
+
// function refuses, and a module-scope call would make `seal-errors.ts` a side-effect anchor.
|
|
69
|
+
X_SEAL_INVALID: 'terminal',
|
|
70
|
+
X_SEAL_KEY_MISSING: 'terminal',
|
|
71
|
+
X_SEAL_KEY_UNKNOWN: 'terminal',
|
|
64
72
|
} as const),
|
|
65
73
|
);
|
|
66
74
|
|
package/src/index.ts
CHANGED
|
@@ -119,8 +119,15 @@ export type {
|
|
|
119
119
|
NavigationSection,
|
|
120
120
|
NavigationSectionInput,
|
|
121
121
|
NavigationSurface,
|
|
122
|
+
SpeculationConfig,
|
|
123
|
+
SpeculationEagerness,
|
|
124
|
+
} from './config-navigation';
|
|
125
|
+
export {
|
|
126
|
+
DEFAULT_SPECULATION,
|
|
127
|
+
NAVIGATION_SURFACES,
|
|
128
|
+
resolveSpeculation,
|
|
129
|
+
SPECULATION_EAGERNESS,
|
|
122
130
|
} from './config-navigation';
|
|
123
|
-
export { NAVIGATION_SURFACES } from './config-navigation';
|
|
124
131
|
export type {
|
|
125
132
|
PwaColors,
|
|
126
133
|
PwaConfig,
|
|
@@ -578,6 +585,9 @@ export type { Direction } from './locale-direction';
|
|
|
578
585
|
export { directionOf, isRtl } from './locale-direction';
|
|
579
586
|
export type { LocalePathSplit } from './locale-path';
|
|
580
587
|
export { localeSegment, localizePath, splitLocalePath } from './locale-path';
|
|
588
|
+
// The process logger's test seam, beside nothing it groups with: where a default-writer line goes.
|
|
589
|
+
export type { LogSink } from './logger';
|
|
590
|
+
export { setLogSink } from './logger';
|
|
581
591
|
export { isMcpExposed, type McpExposureDeclaration } from './mcp-exposure';
|
|
582
592
|
export type { MeasurementActorFactory } from './measurement-actor';
|
|
583
593
|
export {
|
|
@@ -597,12 +607,15 @@ export {
|
|
|
597
607
|
CLIENT_NAVIGATION_LOCATION_HEADER,
|
|
598
608
|
CLIENT_NAVIGATION_SCOPE_HEADER,
|
|
599
609
|
CLIENT_NAVIGATION_SURFACE_HEADER,
|
|
610
|
+
CLIENT_PATH_STYLE_META,
|
|
600
611
|
CLIENT_PERSIST_META,
|
|
601
612
|
CLIENT_SCOPE_HEADER,
|
|
602
613
|
CLIENT_SCOPE_META,
|
|
603
614
|
CLIENT_SYNC_META,
|
|
604
615
|
CLIENT_SYNC_WORKER_META,
|
|
605
616
|
} from './page-meta';
|
|
617
|
+
export type { ProcessMetricsOptions, ProcessReading } from './process-metrics';
|
|
618
|
+
export { readProcess, resetProcessMetrics, startProcessMetrics } from './process-metrics';
|
|
606
619
|
export { type CappedBody, readWithinLimit } from './read-capped';
|
|
607
620
|
export type { RecordEnvelope, RecordRows } from './record-envelope';
|
|
608
621
|
export { decodeRecordEnvelope, encodeRecordEnvelope, RECORDS_HEADER } from './record-envelope';
|
|
@@ -641,6 +654,20 @@ export {
|
|
|
641
654
|
type OriginVerdict,
|
|
642
655
|
proveSameOrigin,
|
|
643
656
|
} from './same-origin';
|
|
657
|
+
export type { SealOptions, SealPurposeOptions } from './seal';
|
|
658
|
+
export { isSealed, open, openText, SEAL_VERSION, seal, sealAll, sealedKeyId } from './seal';
|
|
659
|
+
export type { SealInvalidReason } from './seal-errors';
|
|
660
|
+
export { SealInvalidError, SealKeyMissingError, SealKeyUnknownError } from './seal-errors';
|
|
661
|
+
export type { SealKeyRing, SealKeySource } from './seal-keys';
|
|
662
|
+
export {
|
|
663
|
+
resolveSealKeys,
|
|
664
|
+
SECRETS_RETIRED_KEYS_ENV,
|
|
665
|
+
sealKeyIds,
|
|
666
|
+
splitRetiredKeys,
|
|
667
|
+
} from './seal-keys';
|
|
668
|
+
// Beside the ring it is raised for, not in the `exports/secrets` group: same code as
|
|
669
|
+
// `SecretsKeyInvalidError`, a different variable to repair.
|
|
670
|
+
export { SecretsRingKeyInvalidError } from './secrets-errors';
|
|
644
671
|
export {
|
|
645
672
|
defineService,
|
|
646
673
|
installedServices,
|
package/src/logger.ts
CHANGED
|
@@ -121,6 +121,28 @@ export function setLogStream(stream: 'stdout' | 'stderr'): void {
|
|
|
121
121
|
logStream = stream;
|
|
122
122
|
}
|
|
123
123
|
|
|
124
|
+
/** What `setLogSink` installs: one complete JSON line, and the level it was written at. */
|
|
125
|
+
export type LogSink = (line: string, level: LogLevel) => void;
|
|
126
|
+
|
|
127
|
+
let logSink: LogSink | undefined;
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* TEST SEAM. Every line with no explicit `writer` goes to `sink` INSTEAD of the process's streams,
|
|
131
|
+
* until it is cleared with `undefined`. Returns the sink that was installed, so a caller restores
|
|
132
|
+
* rather than clears — the shape `setRowObserver` has, for the same shared-process reason.
|
|
133
|
+
*
|
|
134
|
+
* It exists for two callers. A test preload installs a sink that drops every line, so a green run
|
|
135
|
+
* prints its reporter and nothing else; and a test that asserts on what the PROCESS logger wrote
|
|
136
|
+
* installs one that collects, instead of patching `process.stdout`. A logger given its own
|
|
137
|
+
* `writer` never reaches it, and the level is untouched: this decides where a line goes, never
|
|
138
|
+
* whether it is written.
|
|
139
|
+
*/
|
|
140
|
+
export function setLogSink(sink: LogSink | undefined): LogSink | undefined {
|
|
141
|
+
const previous = logSink;
|
|
142
|
+
logSink = sink;
|
|
143
|
+
return previous;
|
|
144
|
+
}
|
|
145
|
+
|
|
124
146
|
/**
|
|
125
147
|
* The second half of the same defect, one call deeper than `envLevel`. A module init made safe
|
|
126
148
|
* that still reached `process.stdout` here would only move the `ReferenceError` from load to the
|
|
@@ -135,6 +157,10 @@ export function setLogStream(stream: 'stdout' | 'stderr'): void {
|
|
|
135
157
|
* its log stream, and where there is a `process` this writes to the fd as it always did.
|
|
136
158
|
*/
|
|
137
159
|
function defaultWriter(line: string, level: LogLevel): void {
|
|
160
|
+
if (logSink !== undefined) {
|
|
161
|
+
logSink(line, level);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
138
164
|
const toStderr = logStream === 'stderr' || LEVEL_WEIGHT[level] >= LEVEL_WEIGHT.error;
|
|
139
165
|
if (typeof process === 'undefined') {
|
|
140
166
|
if (toStderr) console.error(line);
|
package/src/page-meta.ts
CHANGED
|
@@ -27,6 +27,13 @@ export const CLIENT_BUILD_META = 'x-ultimate-build';
|
|
|
27
27
|
*/
|
|
28
28
|
export const APP_UPDATE_MESSAGE = 'AppUpdateAvailable';
|
|
29
29
|
|
|
30
|
+
/**
|
|
31
|
+
* How this server turns an action's name into its URL (`defineApi({ http: { pathStyle } })`), for
|
|
32
|
+
* the browser's `actionPath`. Written only for a style other than the default: absent IS
|
|
33
|
+
* `'resource'`, so an app that declares nothing renders the bytes it always did.
|
|
34
|
+
*/
|
|
35
|
+
export const CLIENT_PATH_STYLE_META = 'ultimate-path-style';
|
|
36
|
+
|
|
30
37
|
/** Where the page's one socket dials: `/_x/sync`, or the deployment's absolute `SYNC_URL`. */
|
|
31
38
|
export const CLIENT_SYNC_META = 'ultimate-sync';
|
|
32
39
|
|