@ultimat3/core 27.3.0 → 27.4.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-dispatch.ts +8 -1
- package/src/client-transport.ts +4 -1
- package/src/context.ts +1 -7
- package/src/default-locale.ts +8 -0
- package/src/index.ts +2 -2
- package/src/page.ts +2 -0
- package/src/wire-dates.ts +116 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/core",
|
|
3
|
-
"version": "27.
|
|
3
|
+
"version": "27.4.0",
|
|
4
4
|
"description": "Ultimate's foundation: errors, context, env, config, clock, ids, logging, telemetry, lifecycle",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -37,6 +37,6 @@
|
|
|
37
37
|
"test": "bun test"
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
|
-
"@ultimat3/schema": "27.
|
|
40
|
+
"@ultimat3/schema": "27.4.0"
|
|
41
41
|
}
|
|
42
42
|
}
|
package/src/client-dispatch.ts
CHANGED
|
@@ -14,6 +14,7 @@ import { CLIENT_BUILD_META } from './page-meta';
|
|
|
14
14
|
import type { RecordEnvelope } from './record-envelope';
|
|
15
15
|
import { RECORDS_HEADER } from './record-envelope';
|
|
16
16
|
import { outboundSlot, pageClient } from './record-sink';
|
|
17
|
+
import { DATES_HEADER } from './wire-dates';
|
|
17
18
|
|
|
18
19
|
export type FetchLike = (input: string, init: RequestInit) => Promise<Response>;
|
|
19
20
|
|
|
@@ -79,6 +80,8 @@ export interface TransportRequest {
|
|
|
79
80
|
export interface Answer {
|
|
80
81
|
readonly text: string;
|
|
81
82
|
readonly enveloped: boolean;
|
|
83
|
+
/** `x-ultimate-dates`: which values of the data were instants (`wire-dates.ts`). */
|
|
84
|
+
readonly dates: string | null;
|
|
82
85
|
}
|
|
83
86
|
|
|
84
87
|
/** Called, never captured: a browser's `fetch` throws `Illegal invocation` when detached. */
|
|
@@ -111,7 +114,11 @@ export async function dispatch(
|
|
|
111
114
|
problemError(response.status, text, req.url, stated)
|
|
112
115
|
);
|
|
113
116
|
}
|
|
114
|
-
return {
|
|
117
|
+
return {
|
|
118
|
+
text,
|
|
119
|
+
enveloped: response.headers.get(RECORDS_HEADER) === '1',
|
|
120
|
+
dates: response.headers.get(DATES_HEADER),
|
|
121
|
+
};
|
|
115
122
|
} catch (error) {
|
|
116
123
|
const current = pageClient().scope.epoch;
|
|
117
124
|
if (read && current !== issued) throw scopeChanged(req.url, issued, current);
|
package/src/client-transport.ts
CHANGED
|
@@ -25,6 +25,7 @@ import { notifyClientWrite } from './client-writes';
|
|
|
25
25
|
import type { RecordEnvelope } from './record-envelope';
|
|
26
26
|
import { decodeRecordEnvelope } from './record-envelope';
|
|
27
27
|
import { pageClient, recordSink } from './record-sink';
|
|
28
|
+
import { reviveWireDates } from './wire-dates';
|
|
28
29
|
|
|
29
30
|
const ONCE = { attempts: 1 } as const;
|
|
30
31
|
|
|
@@ -94,7 +95,9 @@ function unwrap(answer: Answer, url: string, read: boolean): RecordEnvelope {
|
|
|
94
95
|
error,
|
|
95
96
|
);
|
|
96
97
|
}
|
|
97
|
-
|
|
98
|
+
const envelope = answer.enveloped ? decodeRecordEnvelope(body) : { data: body };
|
|
99
|
+
// In place, on this caller's own parse: a deduped read's joiners each parsed their own copy.
|
|
100
|
+
return { ...envelope, data: reviveWireDates(envelope.data, answer.dates) };
|
|
98
101
|
}
|
|
99
102
|
|
|
100
103
|
/** Adopt, then remove: a key in both is gone, never resurrected by the same answer. */
|
package/src/context.ts
CHANGED
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
import { type Actor, anonymousActor } from './actor';
|
|
34
34
|
import { asyncContext } from './async-context';
|
|
35
35
|
import { type Clock, systemClock } from './clock';
|
|
36
|
+
import { DEFAULT_LOCALE, DEFAULT_TIME_ZONE } from './default-locale';
|
|
36
37
|
import { UltimateError } from './errors';
|
|
37
38
|
import { finiteOption } from './finite-option';
|
|
38
39
|
import { traceId as newTraceId, uuidV7 } from './ids';
|
|
@@ -157,13 +158,6 @@ const requestContext = asyncContext<Ctx>('the request context');
|
|
|
157
158
|
|
|
158
159
|
const neverAborted = new AbortController().signal;
|
|
159
160
|
|
|
160
|
-
/**
|
|
161
|
-
* The framework's default locale — the ONE declaration: `@ultimat3/i18n` imports it rather than
|
|
162
|
-
* restating it (a second `'en'` there could drift from the context's own default).
|
|
163
|
-
*/
|
|
164
|
-
export const DEFAULT_LOCALE = 'en';
|
|
165
|
-
export const DEFAULT_TIME_ZONE = 'UTC';
|
|
166
|
-
|
|
167
161
|
function buildId(): string {
|
|
168
162
|
return process.env['BUILD_ID'] ?? 'dev';
|
|
169
163
|
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// The framework's default locale and time zone — the ONE declaration, in a leaf of its own:
|
|
2
|
+
// `@ultimat3/i18n`'s translator reads the locale, and an island's translator
|
|
3
|
+
// (`@ultimat3/i18n/subset`) must not carry the request context (`context.ts`, its async storage)
|
|
4
|
+
// to read a constant.
|
|
5
|
+
|
|
6
|
+
/** `@ultimat3/i18n` imports it rather than restating it: a second `'en'` there could drift. */
|
|
7
|
+
export const DEFAULT_LOCALE = 'en';
|
|
8
|
+
export const DEFAULT_TIME_ZONE = 'UTC';
|
package/src/index.ts
CHANGED
|
@@ -200,8 +200,6 @@ export { resolveConflict } from './conflict-policy';
|
|
|
200
200
|
export type { Ctx, CtxFacts, CtxInit, CtxPatch, CtxServices, ServiceBag } from './context';
|
|
201
201
|
export {
|
|
202
202
|
ctxOf,
|
|
203
|
-
DEFAULT_LOCALE,
|
|
204
|
-
DEFAULT_TIME_ZONE,
|
|
205
203
|
hasContext,
|
|
206
204
|
runWithContext,
|
|
207
205
|
throwIfAborted,
|
|
@@ -228,6 +226,7 @@ export {
|
|
|
228
226
|
export type { Page } from './cursor-page';
|
|
229
227
|
export { pageOf } from './cursor-page';
|
|
230
228
|
export { compareDecimalText } from './decimal-order';
|
|
229
|
+
export { DEFAULT_LOCALE, DEFAULT_TIME_ZONE } from './default-locale';
|
|
231
230
|
export type { Deprecation, DeprecationField, DeprecationRender } from './deprecation';
|
|
232
231
|
export { recordDeprecatedCall, renderDeprecation } from './deprecation';
|
|
233
232
|
export type { DevSecretsOptions } from './dev-secrets';
|
|
@@ -801,6 +800,7 @@ export {
|
|
|
801
800
|
webhookSignature,
|
|
802
801
|
webhookSigningString,
|
|
803
802
|
} from './webhook-signature';
|
|
803
|
+
export { DATES_HEADER, reviveWireDates, wireDatePaths } from './wire-dates';
|
|
804
804
|
/**
|
|
805
805
|
* A write's public name — the digest of its idempotency key — and the server scope that carries it
|
|
806
806
|
* from `@ultimat3/action`'s HTTP projection to the layers that stamp it on a `records` frame.
|
package/src/page.ts
CHANGED
|
@@ -56,5 +56,7 @@ export type { PageClient, RecordSink } from './record-sink';
|
|
|
56
56
|
export { pageClient } from './record-sink';
|
|
57
57
|
// The key the theme boot script reads and the toggle island writes.
|
|
58
58
|
export { THEME_STORAGE_KEY } from './theme-storage';
|
|
59
|
+
// What `clientTransport` revives a read's instants by: the header the server names them in.
|
|
60
|
+
export { DATES_HEADER, reviveWireDates } from './wire-dates';
|
|
59
61
|
// A write's public name, so the page's store can recognise the `records` frame its own write made.
|
|
60
62
|
export { isWriteDigest, WRITE_DIGEST_LENGTH, writeDigest } from './write-digest';
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which values of an answer were instants. `JSON.stringify` writes a `Date` as its ISO string and
|
|
3
|
+
* nothing on the far side can tell it from text, so a read's row typed `Date` reached its caller as
|
|
4
|
+
* a string — and every app converted at its own edge (`wireDate()`). The server knows: it walks the
|
|
5
|
+
* answer once, names each `Date` by its path in `x-ultimate-dates`, and `clientTransport` turns
|
|
6
|
+
* exactly those strings back into `Date`s. The body is unchanged, so a client that ignores the
|
|
7
|
+
* header reads what it always read.
|
|
8
|
+
*
|
|
9
|
+
* A path is a list of segments: a string is an object key, a number an array index, and `null`
|
|
10
|
+
* EVERY element of an array — what a list of rows folds to (`[[null, "publishedAt"]]`), so the
|
|
11
|
+
* header is one entry per column, not per row. A pattern that also matches a string that was not a
|
|
12
|
+
* `Date` is never folded: its instants are sent by exact path, so revival is exact either way.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** The response header naming an answer's instants. Absent when it holds none. */
|
|
16
|
+
export const DATES_HEADER = 'x-ultimate-dates';
|
|
17
|
+
|
|
18
|
+
type Segment = string | number | null;
|
|
19
|
+
|
|
20
|
+
interface Pattern {
|
|
21
|
+
readonly exact: Segment[][];
|
|
22
|
+
/** A string that was not a `Date` sits at this pattern too, so folding it would revive text. */
|
|
23
|
+
text: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const isWalkable = (value: unknown): value is object =>
|
|
27
|
+
typeof value === 'object' &&
|
|
28
|
+
value !== null &&
|
|
29
|
+
// `JSON.stringify` writes what `toJSON` answers, not the object's keys — nothing to name inside.
|
|
30
|
+
typeof (value as { toJSON?: unknown }).toJSON !== 'function';
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The header value for an answer, or `undefined` when it holds no `Date`. URI-encoded JSON: a
|
|
34
|
+
* header value is bytes, and an object key may be any string.
|
|
35
|
+
*/
|
|
36
|
+
export function wireDatePaths(value: unknown): string | undefined {
|
|
37
|
+
const patterns = new Map<string, Pattern>();
|
|
38
|
+
const ancestors = new Set<object>();
|
|
39
|
+
const at = (pattern: Segment[]): Pattern => {
|
|
40
|
+
const key = JSON.stringify(pattern);
|
|
41
|
+
const found = patterns.get(key) ?? { exact: [], text: false };
|
|
42
|
+
patterns.set(key, found);
|
|
43
|
+
return found;
|
|
44
|
+
};
|
|
45
|
+
const walk = (node: unknown, pattern: Segment[], exact: Segment[]): void => {
|
|
46
|
+
if (node instanceof Date) {
|
|
47
|
+
if (!Number.isNaN(node.getTime())) at(pattern).exact.push(exact);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
if (typeof node === 'string') {
|
|
51
|
+
at(pattern).text = true;
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
// A cycle is `JSON.stringify`'s refusal to make, one step later — never a stack overflow here.
|
|
55
|
+
if (!isWalkable(node) || ancestors.has(node)) return;
|
|
56
|
+
ancestors.add(node);
|
|
57
|
+
if (Array.isArray(node)) {
|
|
58
|
+
for (let index = 0; index < node.length; index += 1) {
|
|
59
|
+
walk(node[index], [...pattern, null], [...exact, index]);
|
|
60
|
+
}
|
|
61
|
+
} else {
|
|
62
|
+
for (const [key, item] of Object.entries(node))
|
|
63
|
+
walk(item, [...pattern, key], [...exact, key]);
|
|
64
|
+
}
|
|
65
|
+
ancestors.delete(node);
|
|
66
|
+
};
|
|
67
|
+
walk(value, [], []);
|
|
68
|
+
const paths: Segment[][] = [];
|
|
69
|
+
for (const [key, { exact, text }] of patterns) {
|
|
70
|
+
if (exact.length === 0) continue;
|
|
71
|
+
if (text) paths.push(...exact);
|
|
72
|
+
else paths.push(JSON.parse(key) as Segment[]);
|
|
73
|
+
}
|
|
74
|
+
return paths.length === 0 ? undefined : encodeURIComponent(JSON.stringify(paths));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Every string the header names, in a freshly parsed answer, turned back into a `Date` — in place.
|
|
79
|
+
* A header that does not parse revives nothing: it is a statement about the body, and a proxy that
|
|
80
|
+
* mangled it has not mangled the body.
|
|
81
|
+
*/
|
|
82
|
+
export function reviveWireDates<T>(value: T, header: string | null): T {
|
|
83
|
+
if (header === null || header === '') return value;
|
|
84
|
+
let paths: unknown;
|
|
85
|
+
try {
|
|
86
|
+
paths = JSON.parse(decodeURIComponent(header));
|
|
87
|
+
} catch {
|
|
88
|
+
return value;
|
|
89
|
+
}
|
|
90
|
+
if (!Array.isArray(paths)) return value;
|
|
91
|
+
const holder: { root: unknown } = { root: value };
|
|
92
|
+
for (const path of paths) if (Array.isArray(path)) revive(holder, 'root', path, 0);
|
|
93
|
+
return holder.root as T;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function revive(parent: object, key: string | number, path: readonly unknown[], depth: number) {
|
|
97
|
+
const node: unknown = (parent as Record<string | number, unknown>)[key];
|
|
98
|
+
if (depth === path.length) {
|
|
99
|
+
if (typeof node !== 'string') return;
|
|
100
|
+
const instant = new Date(node);
|
|
101
|
+
if (!Number.isNaN(instant.getTime()))
|
|
102
|
+
(parent as Record<string | number, unknown>)[key] = instant;
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
const segment = path[depth];
|
|
106
|
+
if (Array.isArray(node)) {
|
|
107
|
+
if (segment === null) for (let i = 0; i < node.length; i += 1) revive(node, i, path, depth + 1);
|
|
108
|
+
else if (typeof segment === 'number' && segment < node.length)
|
|
109
|
+
revive(node, segment, path, depth + 1);
|
|
110
|
+
} else if (typeof node === 'object' && node !== null && typeof segment === 'string') {
|
|
111
|
+
// `JSON.parse` mints `__proto__` as an own key, and assigning through it sets the prototype.
|
|
112
|
+
if (segment !== '__proto__' && Object.hasOwn(node, segment)) {
|
|
113
|
+
revive(node, segment, path, depth + 1);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|