@lesto/ui 0.1.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 +58 -0
- package/src/bfcache.ts +121 -0
- package/src/client.ts +54 -0
- package/src/data-client.ts +361 -0
- package/src/data-resolve.tsx +120 -0
- package/src/data.ts +183 -0
- package/src/define-island.tsx +222 -0
- package/src/errors.ts +36 -0
- package/src/hydrate.tsx +563 -0
- package/src/index.ts +165 -0
- package/src/island.ts +258 -0
- package/src/link.tsx +103 -0
- package/src/metadata.ts +195 -0
- package/src/mount.ts +99 -0
- package/src/node.ts +8 -0
- package/src/props.ts +95 -0
- package/src/registry.ts +141 -0
- package/src/render.tsx +349 -0
- package/src/resources.ts +233 -0
- package/src/route.ts +62 -0
- package/src/routes.ts +101 -0
- package/src/schema.ts +244 -0
- package/src/serialize.ts +148 -0
- package/src/server-preact.ts +59 -0
- package/src/server.ts +44 -0
- package/src/softnav-contract.ts +144 -0
- package/src/softnav.ts +934 -0
- package/src/stream.tsx +387 -0
- package/src/types.ts +46 -0
- package/src/validate.ts +107 -0
package/src/routes.ts
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The typed-route seam — how an app's generated `routes.gen.ts` teaches `<Link>` and
|
|
3
|
+
* {@link route} the app's real routes, with ZERO runtime and no react dependency
|
|
4
|
+
* (pure type-level — only a TYPE-only import of `PathParams` from `@lesto/router` —
|
|
5
|
+
* so the `@lesto/ui` isomorphic core stays react-free here and the type gate can pin
|
|
6
|
+
* it without pulling the component tree in).
|
|
7
|
+
*
|
|
8
|
+
* The mechanism is declaration merging — the same `Register` idiom TanStack Router
|
|
9
|
+
* uses. `@lesto/web`'s `generateRouteManifest` emits, into each app's
|
|
10
|
+
* `routes.gen.ts`, a `RoutePath` + `RoutePattern` union of every route plus:
|
|
11
|
+
*
|
|
12
|
+
* declare module "@lesto/ui" {
|
|
13
|
+
* interface RegisteredRoutes { href: RoutePath; pattern: RoutePattern }
|
|
14
|
+
* }
|
|
15
|
+
*
|
|
16
|
+
* which merges `href`/`pattern` members into {@link RegisteredRoutes} below.
|
|
17
|
+
* {@link RouteHref} reads `href` (so `<Link href>` autocompletes the app's routes);
|
|
18
|
+
* {@link KnownPatterns} reads `pattern` (so {@link route} constrains its pattern arg
|
|
19
|
+
* and types its params). An app with NO route codegen leaves `RegisteredRoutes`
|
|
20
|
+
* empty, so both stay unconstrained (`string`): nothing breaks.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import type { PathParams } from "@lesto/router";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The route registry an app augments by declaration merging. Empty by design — it
|
|
27
|
+
* IS the extension point a generated `routes.gen.ts` merges a `href` member into
|
|
28
|
+
* (the same pattern as `@lesto/content-core`'s `CollectionRegistry`). Augmenting
|
|
29
|
+
* `@lesto/ui` reaches this interface because the package re-exports it.
|
|
30
|
+
*/
|
|
31
|
+
export interface RegisteredRoutes {}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The known route hrefs a registry shape `Reg` declares, or `never` when it declares
|
|
35
|
+
* none. Internal helper for {@link HrefFor} — kept as a generic over the registry
|
|
36
|
+
* (not reading the global {@link RegisteredRoutes}) so {@link HrefFor}'s behavior is
|
|
37
|
+
* pinnable in the type gate without a program-global `declare module` augmentation.
|
|
38
|
+
*/
|
|
39
|
+
type KnownRoutesOf<Reg> = Reg extends { href: infer H extends string } ? H : never;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The `<Link href>` type derived from a registry shape `Reg`:
|
|
43
|
+
*
|
|
44
|
+
* - `Reg` declares no routes → exactly `string` (the unchanged default for an app
|
|
45
|
+
* with no route codegen).
|
|
46
|
+
* - `Reg` declares routes → the known routes (autocompleted) PLUS a `(string & {})`
|
|
47
|
+
* escape that still accepts any other string — an external URL, a `?query`/`#hash`,
|
|
48
|
+
* or a code-first route — so typed routes only SURFACE the known ones, never
|
|
49
|
+
* BLOCK a valid link. Strict dead-link erroring is a follow-up (docs/plans/
|
|
50
|
+
* dx-parity.md, Workstream 1 Increment 2).
|
|
51
|
+
*
|
|
52
|
+
* `[KnownRoutesOf<Reg>] extends [never]` guards the empty case without distributing
|
|
53
|
+
* over the union, and `string & {}` is the standard trick that keeps the literal
|
|
54
|
+
* members visible to autocomplete while staying assignable from any string.
|
|
55
|
+
*/
|
|
56
|
+
export type HrefFor<Reg> = [KnownRoutesOf<Reg>] extends [never]
|
|
57
|
+
? string
|
|
58
|
+
: KnownRoutesOf<Reg> | (string & {});
|
|
59
|
+
|
|
60
|
+
/** A `<Link>` href — see {@link HrefFor}, resolved against the app's {@link RegisteredRoutes}. */
|
|
61
|
+
export type RouteHref = HrefFor<RegisteredRoutes>;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Like {@link HrefFor} but WITHOUT the `(string & {})` escape — only the registry's
|
|
65
|
+
* known routes, so an unknown literal is NOT assignable (a typo'd link is a `tsc`
|
|
66
|
+
* error). A generic over `Reg` so it's pinnable in the type gate. An empty registry
|
|
67
|
+
* still yields `string` (no codegen → unchanged).
|
|
68
|
+
*/
|
|
69
|
+
export type StrictHrefFor<Reg> = [KnownRoutesOf<Reg>] extends [never] ? string : KnownRoutesOf<Reg>;
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* A STRICT `<Link href>` type for {@link StrictLink}: the app's known routes with no
|
|
73
|
+
* escape, so a typo'd link does not compile. Sound only for a FULLY-file-routed app —
|
|
74
|
+
* every route is in the registry. A MIXED app (with code-first `.page()` routes the
|
|
75
|
+
* codegen can't see) must keep the lenient {@link RouteHref}, or strict mode would
|
|
76
|
+
* false-positive on those routes (making it strict BY DEFAULT for mixed apps needs
|
|
77
|
+
* code-first route capture, a deferred large refactor — docs/plans/dx-parity.md).
|
|
78
|
+
*/
|
|
79
|
+
export type StrictRouteHref = StrictHrefFor<RegisteredRoutes>;
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The known route PATTERNS a registry shape `Reg` declares (`"/blog/:id"`, with
|
|
83
|
+
* `:param` segments KEPT), or `never` when it declares none. {@link route} reads this
|
|
84
|
+
* to constrain its pattern argument to the app's real routes; a generic over `Reg`
|
|
85
|
+
* (like {@link HrefFor}) so it's pinnable in the type gate without a global augmentation.
|
|
86
|
+
*/
|
|
87
|
+
export type PatternsOf<Reg> = Reg extends { pattern: infer P extends string } ? P : never;
|
|
88
|
+
|
|
89
|
+
/** The app's known route patterns once codegen augments {@link RegisteredRoutes}, else `never`. */
|
|
90
|
+
export type KnownPatterns = PatternsOf<RegisteredRoutes>;
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The argument list a {@link route} call takes for a pattern `P`: the typed params
|
|
94
|
+
* object is REQUIRED when `P` has `:segments`, ABSENT otherwise — a tuple, so a
|
|
95
|
+
* param-less `route("/about")` needs no second argument (the `@lesto/client`
|
|
96
|
+
* `createApi` `GetArgs` precedent). Param names + value types come from
|
|
97
|
+
* `@lesto/router`'s `PathParams<P>`.
|
|
98
|
+
*/
|
|
99
|
+
export type ParamArgs<P extends string> = keyof PathParams<P> extends never
|
|
100
|
+
? []
|
|
101
|
+
: [params: PathParams<P>];
|
package/src/schema.ts
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The model-facing description of the registry.
|
|
3
|
+
*
|
|
4
|
+
* `treeJsonSchema` emits a JSON Schema an AI can be constrained to: a UI node is
|
|
5
|
+
* either a bare string (a text leaf) or one of the registered components — a
|
|
6
|
+
* `oneOf` whose variants each pin `type` to a const and describe their props.
|
|
7
|
+
* Each variant lives in its own `#/$defs/<name>` entry so a children allow-list
|
|
8
|
+
* can `$ref` exactly the components it permits — the children narrowing mirrors
|
|
9
|
+
* the runtime `validateTree` `allowsChild` policy, no looser. `componentCatalog`
|
|
10
|
+
* is the friendlier prose summary for a model's system prompt.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { UiError } from "./errors";
|
|
14
|
+
import type { Registry } from "./registry";
|
|
15
|
+
import type { ChildrenPolicy, ComponentDef, PropSpec, PropType } from "./types";
|
|
16
|
+
|
|
17
|
+
/** Map a `PropType` to the JSON Schema fragment that describes it. */
|
|
18
|
+
function propSchema(spec: PropSpec): Record<string, unknown> {
|
|
19
|
+
const base = jsonTypeFor(spec.type, spec);
|
|
20
|
+
|
|
21
|
+
// Carry the human description through to the model when present.
|
|
22
|
+
return spec.description === undefined ? base : { ...base, description: spec.description };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The bare JSON Schema type fragment for a prop, before description is added. */
|
|
26
|
+
function jsonTypeFor(type: PropType, spec: PropSpec): Record<string, unknown> {
|
|
27
|
+
if (type === "enum") {
|
|
28
|
+
// An enum without values is unbuildable: we'd emit a schema that admits
|
|
29
|
+
// nothing. Refuse loudly so the registry author fixes the spec.
|
|
30
|
+
if (spec.values === undefined) {
|
|
31
|
+
throw new UiError("UI_INVALID_ENUM_SPEC", "enum prop spec has no values", {
|
|
32
|
+
type,
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return { type: "string", enum: [...spec.values] };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (type === "object") return { type: "object" };
|
|
40
|
+
|
|
41
|
+
if (type === "array") return { type: "array" };
|
|
42
|
+
|
|
43
|
+
// string / number / boolean map straight across.
|
|
44
|
+
return { type };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The `properties` + `required` shape for one component's props. */
|
|
48
|
+
function propsObjectSchema(specs: Record<string, PropSpec>): Record<string, unknown> {
|
|
49
|
+
const properties: Record<string, unknown> = {};
|
|
50
|
+
|
|
51
|
+
const required: string[] = [];
|
|
52
|
+
|
|
53
|
+
for (const [name, spec] of Object.entries(specs)) {
|
|
54
|
+
properties[name] = propSchema(spec);
|
|
55
|
+
|
|
56
|
+
if (spec.required === true) required.push(name);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Only attach `required` when there is something to require — a cleaner schema.
|
|
60
|
+
return {
|
|
61
|
+
type: "object",
|
|
62
|
+
properties,
|
|
63
|
+
additionalProperties: false,
|
|
64
|
+
...(required.length === 0 ? {} : { required }),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Does any of a component's props carry `required: true`? */
|
|
69
|
+
function hasRequiredProp(specs: Record<string, PropSpec>): boolean {
|
|
70
|
+
return Object.values(specs).some((spec) => spec.required === true);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Does a component accept children at all?
|
|
75
|
+
*
|
|
76
|
+
* `true` admits any registered component; a non-empty allow-list admits only its
|
|
77
|
+
* members; `false` (and the degenerate empty list) is a leaf. Leaves get no
|
|
78
|
+
* `children` property, so the model is constrained no more loosely than the
|
|
79
|
+
* validator's missing/forbidden-child checks enforce.
|
|
80
|
+
*/
|
|
81
|
+
function acceptsChildren(policy: ChildrenPolicy): boolean {
|
|
82
|
+
if (policy === true) return true;
|
|
83
|
+
|
|
84
|
+
if (policy === false) return false;
|
|
85
|
+
|
|
86
|
+
return policy.length > 0;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** A bare string is the universal text leaf — valid as a child of ANY parent. */
|
|
90
|
+
const stringLeaf = { type: "string" };
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The `#/$defs/<name>` ref for one component variant.
|
|
94
|
+
*
|
|
95
|
+
* Component names are opaque strings, so the name is JSON-Pointer-escaped
|
|
96
|
+
* (`~` → `~0`, `/` → `~1`, in that order) before it goes into the ref — the def
|
|
97
|
+
* itself is keyed by the raw name (object keys are unrestricted), but a `$ref`
|
|
98
|
+
* is a JSON Pointer and must encode those two characters to resolve.
|
|
99
|
+
*/
|
|
100
|
+
function componentRef(name: string): { $ref: string } {
|
|
101
|
+
const pointer = name.replaceAll("~", "~0").replaceAll("/", "~1");
|
|
102
|
+
|
|
103
|
+
return { $ref: `#/$defs/${pointer}` };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The reserved `$defs` key for the node union (the schema root). A registered
|
|
108
|
+
* component may not share this name — `treeJsonSchema` refuses it, since it would
|
|
109
|
+
* otherwise clobber the union.
|
|
110
|
+
*/
|
|
111
|
+
const RESERVED_NODE_DEF = "node";
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The `items` schema for a children-accepting component, mirroring `allowsChild`.
|
|
115
|
+
*
|
|
116
|
+
* - `policy === true` → the full node union (any string leaf or component).
|
|
117
|
+
* - allow-list `[A,B]` → ONLY the named variants, plus the string leaf.
|
|
118
|
+
*
|
|
119
|
+
* The allow-list case is the correctness pin: `validateTree`'s `allowsChild`
|
|
120
|
+
* only consults the list for *node objects* (`isNodeObject(child)`), so a bare
|
|
121
|
+
* string leaf is always permitted and a component child must be a list member.
|
|
122
|
+
* We mirror that exactly — `oneOf: [stringLeaf, ...refs to listed components]` —
|
|
123
|
+
* so a tree the runtime would reject for a disallowed child cannot pass the
|
|
124
|
+
* schema either. A listed name with no registered `$def` can never be satisfied
|
|
125
|
+
* (the runtime flags it `unknown_component`), so we drop it rather than emit a
|
|
126
|
+
* dangling `$ref`; the admitted set stays exactly what `allowsChild` *and*
|
|
127
|
+
* `validateTree` together accept.
|
|
128
|
+
*/
|
|
129
|
+
function childItemsSchema(policy: ChildrenPolicy, registered: ReadonlySet<string>): object {
|
|
130
|
+
// `true`: any node. Keep the full union ref so the tree nests arbitrarily.
|
|
131
|
+
// (Only ever called for children-accepting policies, so `false` never lands
|
|
132
|
+
// here — but a list is the only other shape, so we narrow on that.)
|
|
133
|
+
if (!Array.isArray(policy)) return { $ref: `#/$defs/${RESERVED_NODE_DEF}` };
|
|
134
|
+
|
|
135
|
+
const allowed = policy.filter((name) => registered.has(name)).map(componentRef);
|
|
136
|
+
|
|
137
|
+
// A string child is always allowed (allowsChild is guarded by isNodeObject),
|
|
138
|
+
// so the leaf is a member alongside each listed, registered component.
|
|
139
|
+
return { oneOf: [stringLeaf, ...allowed] };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** The full JSON Schema fragment for one component variant. */
|
|
143
|
+
function componentVariant(def: ComponentDef, registered: ReadonlySet<string>): object {
|
|
144
|
+
return {
|
|
145
|
+
type: "object",
|
|
146
|
+
properties: {
|
|
147
|
+
type: { const: def.name },
|
|
148
|
+
props: propsObjectSchema(def.props),
|
|
149
|
+
|
|
150
|
+
// Only children-accepting components advertise a `children` array; leaves
|
|
151
|
+
// omit it so the schema is as strict as the runtime policy. The `items`
|
|
152
|
+
// schema is narrowed to the allow-list when there is one (see
|
|
153
|
+
// `childItemsSchema`) so the schema matches `allowsChild` exactly.
|
|
154
|
+
...(acceptsChildren(def.children)
|
|
155
|
+
? { children: { type: "array", items: childItemsSchema(def.children, registered) } }
|
|
156
|
+
: {}),
|
|
157
|
+
},
|
|
158
|
+
// A component with required props must require the `props` object itself —
|
|
159
|
+
// otherwise the nested `props.required` never bites (a model could omit
|
|
160
|
+
// `props` wholesale, passing the schema yet failing `validateTree`'s
|
|
161
|
+
// missing-required-prop check). The two must agree, so require `props` here.
|
|
162
|
+
required: hasRequiredProp(def.props) ? ["type", "props"] : ["type"],
|
|
163
|
+
additionalProperties: false,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* The recursive JSON Schema for a whole UI tree rooted at `#/$defs/node`.
|
|
169
|
+
*
|
|
170
|
+
* A node is a string leaf OR any one registered component. Each component gets
|
|
171
|
+
* its own `#/$defs/<name>` entry (so an allow-list can `$ref` individual
|
|
172
|
+
* variants); the `node` def is the `oneOf` of the string leaf and every variant.
|
|
173
|
+
* A component that accepts children also gets a `children` array whose `items`
|
|
174
|
+
* mirror the runtime `allowsChild` policy: the full node union for `children:
|
|
175
|
+
* true`, or a `oneOf` of just the allowed variants (plus the string leaf) for an
|
|
176
|
+
* allow-list — so a tree `validateTree` rejects for a disallowed child cannot
|
|
177
|
+
* pass the emitted schema either. A leaf omits `children` entirely.
|
|
178
|
+
*/
|
|
179
|
+
export function treeJsonSchema(registry: Registry): object {
|
|
180
|
+
const defs = registry.all();
|
|
181
|
+
|
|
182
|
+
// `node` is the reserved $defs key for the node union (the schema root). A
|
|
183
|
+
// component sharing that exact name would, via the `...componentDefs` spread
|
|
184
|
+
// below, clobber the union — leaving the root `$ref` resolving to one component
|
|
185
|
+
// and silently rejecting every other tree. Refuse it loudly rather than emit a
|
|
186
|
+
// corrupt schema.
|
|
187
|
+
const reserved = defs.find((def) => def.name === RESERVED_NODE_DEF);
|
|
188
|
+
if (reserved !== undefined) {
|
|
189
|
+
throw new UiError(
|
|
190
|
+
"UI_RESERVED_COMPONENT_NAME",
|
|
191
|
+
`a component may not be named "${RESERVED_NODE_DEF}": it is the reserved JSON Schema node-union key`,
|
|
192
|
+
{ name: reserved.name },
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const registered = new Set(defs.map((def) => def.name));
|
|
197
|
+
|
|
198
|
+
// One `$def` per component so an allow-list `oneOf` can reference individual
|
|
199
|
+
// variants by name; the `node` union refs them all (plus the string leaf).
|
|
200
|
+
const componentDefs: Record<string, object> = {};
|
|
201
|
+
|
|
202
|
+
for (const def of defs) {
|
|
203
|
+
componentDefs[def.name] = componentVariant(def, registered);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return {
|
|
207
|
+
$schema: "https://json-schema.org/draft/2020-12/schema",
|
|
208
|
+
$ref: `#/$defs/${RESERVED_NODE_DEF}`,
|
|
209
|
+
$defs: {
|
|
210
|
+
[RESERVED_NODE_DEF]: { oneOf: [stringLeaf, ...defs.map((def) => componentRef(def.name))] },
|
|
211
|
+
...componentDefs,
|
|
212
|
+
},
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** A compact, model-friendly summary of every component in the registry. */
|
|
217
|
+
export function componentCatalog(registry: Registry): object[] {
|
|
218
|
+
return registry.all().map(catalogEntry);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** One component's entry in the catalog — only the fields that carry information. */
|
|
222
|
+
function catalogEntry(def: ComponentDef): Record<string, unknown> {
|
|
223
|
+
const props = Object.fromEntries(
|
|
224
|
+
Object.entries(def.props).map(([name, spec]) => [name, catalogProp(spec)]),
|
|
225
|
+
);
|
|
226
|
+
|
|
227
|
+
return {
|
|
228
|
+
name: def.name,
|
|
229
|
+
children: def.children,
|
|
230
|
+
props,
|
|
231
|
+
...(def.description === undefined ? {} : { description: def.description }),
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** One prop's entry in the catalog — only the fields that carry information. */
|
|
236
|
+
function catalogProp(spec: PropSpec): Record<string, unknown> {
|
|
237
|
+
return {
|
|
238
|
+
type: spec.type,
|
|
239
|
+
...(spec.required === true ? { required: true } : {}),
|
|
240
|
+
...(spec.values === undefined ? {} : { values: [...spec.values] }),
|
|
241
|
+
...(spec.default === undefined ? {} : { default: spec.default }),
|
|
242
|
+
...(spec.description === undefined ? {} : { description: spec.description }),
|
|
243
|
+
};
|
|
244
|
+
}
|
package/src/serialize.ts
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The wire guard for island props.
|
|
3
|
+
*
|
|
4
|
+
* Island props are serialized on the server and revived in the browser, so they
|
|
5
|
+
* must be pure JSON — the values `JSON.stringify`/`JSON.parse` round-trip
|
|
6
|
+
* losslessly. A function, a `Date`, a class instance, a `Symbol`, a `bigint`, or
|
|
7
|
+
* `undefined` would either vanish or arrive as something the client cannot use.
|
|
8
|
+
* We reject them at the boundary with a stable code, rather than let a prop
|
|
9
|
+
* silently disappear between server and client.
|
|
10
|
+
*
|
|
11
|
+
* The check is structural and reports the FIRST offending path (e.g.
|
|
12
|
+
* `props.user.onClick`), so the author is told exactly which value to fix.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { UiError } from "./errors";
|
|
16
|
+
|
|
17
|
+
import type { IslandMount } from "./island";
|
|
18
|
+
|
|
19
|
+
/** A JSON-shaped value: the closure of null/boolean/number/string under array/object. */
|
|
20
|
+
type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue };
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The first path at which `value` is not JSON-serializable, or `undefined` if
|
|
24
|
+
* the whole structure is clean. `path` seeds the reported location.
|
|
25
|
+
*/
|
|
26
|
+
function firstNonSerializable(value: unknown, path: string): string | undefined {
|
|
27
|
+
if (value === null) return undefined;
|
|
28
|
+
|
|
29
|
+
// Primitives that JSON round-trips losslessly. A non-finite number (NaN,
|
|
30
|
+
// Infinity) becomes `null` through JSON, so it is not faithfully serializable.
|
|
31
|
+
if (typeof value === "boolean" || typeof value === "string") return undefined;
|
|
32
|
+
|
|
33
|
+
if (typeof value === "number") return Number.isFinite(value) ? undefined : path;
|
|
34
|
+
|
|
35
|
+
// Arrays: clean iff every element is clean, reported left to right.
|
|
36
|
+
if (Array.isArray(value)) {
|
|
37
|
+
for (const [index, element] of value.entries()) {
|
|
38
|
+
const offender = firstNonSerializable(element, `${path}[${index}]`);
|
|
39
|
+
|
|
40
|
+
if (offender !== undefined) return offender;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// A plain object: clean iff every own value is clean. Anything with a custom
|
|
47
|
+
// prototype (Date, Map, class instance) is rejected — it would not round-trip
|
|
48
|
+
// as the author intends.
|
|
49
|
+
if (typeof value === "object" && isPlainObject(value)) {
|
|
50
|
+
for (const [key, nested] of Object.entries(value)) {
|
|
51
|
+
const offender = firstNonSerializable(nested, `${path}.${key}`);
|
|
52
|
+
|
|
53
|
+
if (offender !== undefined) return offender;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Everything else — function, symbol, bigint, undefined, exotic object — is
|
|
60
|
+
// not JSON and so cannot cross the wire.
|
|
61
|
+
return path;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Is `value` a plain `{}`-style object (Object.prototype or null prototype)? */
|
|
65
|
+
function isPlainObject(value: object): boolean {
|
|
66
|
+
const proto = Object.getPrototypeOf(value) as object | null;
|
|
67
|
+
|
|
68
|
+
return proto === null || proto === Object.prototype;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Assert that `props` is pure JSON, returning the same bag typed as JSON. Throws
|
|
73
|
+
* `UI_ISLAND_PROPS_NOT_SERIALIZABLE` naming the offending path otherwise.
|
|
74
|
+
*/
|
|
75
|
+
export function assertSerializable(
|
|
76
|
+
component: string,
|
|
77
|
+
props: Record<string, unknown>,
|
|
78
|
+
): Record<string, JsonValue> {
|
|
79
|
+
const offender = firstNonSerializable(props, "props");
|
|
80
|
+
|
|
81
|
+
if (offender !== undefined) {
|
|
82
|
+
throw new UiError(
|
|
83
|
+
"UI_ISLAND_PROPS_NOT_SERIALIZABLE",
|
|
84
|
+
`island "${component}" has a non-serializable prop at ${offender}`,
|
|
85
|
+
{ component, path: offender },
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// The structural walk above proves the cast true: every reachable value is JSON.
|
|
90
|
+
return props as Record<string, JsonValue>;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Serialize a page-wide island manifest ARRAY for safe embedding in an inline
|
|
95
|
+
* `<script>` — the DEMOTED Registry/`UiNode` content path's emission (ADR 0011
|
|
96
|
+
* Increment 2). The canonical `.page` path uses {@link serializeScriptJson} on
|
|
97
|
+
* one mount object per island (co-located, streaming-safe); this array form is
|
|
98
|
+
* the niche where `renderPage` walks an AI-/DB-driven tree into one
|
|
99
|
+
* `#lesto-islands` manifest. Same audited escape, applied to the whole array.
|
|
100
|
+
*
|
|
101
|
+
* `JSON.stringify` alone is NOT safe to drop into HTML. A string value carrying
|
|
102
|
+
* `</script>` (or `<!--`, or the JS line terminators U+2028 / U+2029) terminates
|
|
103
|
+
* the surrounding element and lets attacker-influenced prop data execute — a
|
|
104
|
+
* textbook SSR-serialization XSS. HTML-entity escaping does not help either:
|
|
105
|
+
* entities are not decoded inside `<script>`. So we escape the breakout
|
|
106
|
+
* characters to their `\uXXXX` JSON escapes, which `JSON.parse` reads back as the
|
|
107
|
+
* byte-identical string — `<` and `>` (defeat `</script>` and `<!--`), `&`
|
|
108
|
+
* (belt-and-braces), and the two separators a JS parser treats as line breaks.
|
|
109
|
+
*
|
|
110
|
+
* The emission that consumes this MUST use `<script type="application/json">` and
|
|
111
|
+
* revive with `JSON.parse(el.textContent)` on the client: a non-executable type
|
|
112
|
+
* keeps even a future escaping miss inert, and the payload stays compatible with
|
|
113
|
+
* a strict, nonce-based CSP. This is the one audited seam every manifest payload
|
|
114
|
+
* crosses — never hand-roll `JSON.stringify` into a `<script>`, and never splice
|
|
115
|
+
* it in with `String.prototype.replace`, whose `$&`/`$'` tokens are themselves an
|
|
116
|
+
* injection vector.
|
|
117
|
+
*
|
|
118
|
+
* Mirrors the script-context escape that `@lesto/seo` and `@lesto/content-shared`
|
|
119
|
+
* already apply to inline JSON-LD; kept local so `@lesto/ui`'s render hot path
|
|
120
|
+
* pulls in no extra dependency for it.
|
|
121
|
+
*/
|
|
122
|
+
export function serializeManifest(manifest: readonly IslandMount[]): string {
|
|
123
|
+
return serializeScriptJson(manifest);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Serialize any value to JSON safe to embed inside a `<script>` element.
|
|
128
|
+
*
|
|
129
|
+
* The same script-context escape {@link serializeManifest} applies, generalized
|
|
130
|
+
* to one value — used by the per-island co-located mount script (ADR 0011),
|
|
131
|
+
* where each island emits its OWN `IslandMount` object rather than the page-wide
|
|
132
|
+
* array. The same rules and the same one-audited-seam discipline apply: emit only
|
|
133
|
+
* as `<script type="application/json">`, revive with `JSON.parse`, never a bare
|
|
134
|
+
* `JSON.stringify` into a `<script>`.
|
|
135
|
+
*/
|
|
136
|
+
export function serializeScriptJson(value: unknown): string {
|
|
137
|
+
// The JS line/paragraph separators, built from code points so no raw
|
|
138
|
+
// U+2028/U+2029 byte sits in this source (where tooling may mangle it).
|
|
139
|
+
const lineSeparator = String.fromCharCode(0x2028);
|
|
140
|
+
const paragraphSeparator = String.fromCharCode(0x2029);
|
|
141
|
+
|
|
142
|
+
return JSON.stringify(value)
|
|
143
|
+
.replaceAll("<", "\\u003c")
|
|
144
|
+
.replaceAll(">", "\\u003e")
|
|
145
|
+
.replaceAll("&", "\\u0026")
|
|
146
|
+
.replaceAll(lineSeparator, "\\u2028")
|
|
147
|
+
.replaceAll(paragraphSeparator, "\\u2029");
|
|
148
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Preact server-render dialect — re-exported from the `@lesto/ui/server`
|
|
3
|
+
* subpath as `preactServerRenderer`.
|
|
4
|
+
*
|
|
5
|
+
* This is the adapter half of the {@link ServerRenderer} seam (declared in
|
|
6
|
+
* `render.tsx`). It backs the two functions {@link renderPageMarkup} needs with
|
|
7
|
+
* `preact-render-to-string` instead of `react-dom/server`, so a page whose client
|
|
8
|
+
* bundle is Preact (the opt-in `react`→`preact/compat` alias, ADR 0007) renders
|
|
9
|
+
* its server markup in the SAME dialect the client will hydrate against.
|
|
10
|
+
*
|
|
11
|
+
* Why this matters, mechanically: an `ssr: true` island ships its real server
|
|
12
|
+
* render into the shell for the client to `hydrateRoot`. Hydration only succeeds
|
|
13
|
+
* when the server- and client-emitted markup agree, and React and Preact do NOT
|
|
14
|
+
* emit identical markup — most visibly, React delimits adjacent text segments with
|
|
15
|
+
* `<!-- -->` comment markers while Preact does not. Hydrate React's server markup
|
|
16
|
+
* with Preact's `hydrateRoot` and the common interpolated-text shape (`'Hi, ',
|
|
17
|
+
* name`) mismatches, firing `onRecoverableError` and forcing a full re-render —
|
|
18
|
+
* defeating `ssr: true`. Rendering the server side with THIS adapter removes the
|
|
19
|
+
* mismatch at the source: both sides speak Preact, so the markup lines up.
|
|
20
|
+
*
|
|
21
|
+
* Why it lives behind `@lesto/ui/server` (not the core barrel): keeping `@lesto/ui`
|
|
22
|
+
* dialect-agnostic means its core never hard-depends on `preact-render-to-string`.
|
|
23
|
+
* A server importer of `@lesto/ui` (the default React path, estate's `document.ts`)
|
|
24
|
+
* must never drag Preact's renderer into its build. So `preact-render-to-string` is
|
|
25
|
+
* an OPTIONAL peer dependency — present only when an adopter chooses the Preact
|
|
26
|
+
* client alias — and this module is the only place that imports it. Reach for it
|
|
27
|
+
* explicitly (`import { preactServerRenderer } from "@lesto/ui/server"`) and pass it
|
|
28
|
+
* to {@link renderPageMarkup}; the default React path never loads this file.
|
|
29
|
+
*
|
|
30
|
+
* The `as` cast at the call boundary is the honest cost of bridging two element
|
|
31
|
+
* factories: `@lesto/ui` builds its tree with React's `createElement`, and under
|
|
32
|
+
* the `preact/compat` alias those calls resolve to Preact-shaped vnodes that
|
|
33
|
+
* `preact-render-to-string` renders natively. The adapter's job is purely to map
|
|
34
|
+
* the engine's `ReactElement`-typed node onto that renderer's `VNode` parameter;
|
|
35
|
+
* the runtime shape is already a Preact vnode wherever this adapter is wired.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import type { ReactElement } from "react";
|
|
39
|
+
// `preact-render-to-string` mirrors `react-dom/server`'s two entry points, which
|
|
40
|
+
// is exactly the surface {@link ServerRenderer} asks for — so the adapter is a
|
|
41
|
+
// direct one-to-one binding, not a translation layer.
|
|
42
|
+
import { renderToStaticMarkup, renderToString } from "preact-render-to-string";
|
|
43
|
+
|
|
44
|
+
import type { ServerRenderer } from "./render";
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The Preact dialect, ready to hand to {@link renderPageMarkup} as its `renderer`.
|
|
48
|
+
*
|
|
49
|
+
* Both functions forward straight to `preact-render-to-string`. The node arrives
|
|
50
|
+
* typed as `ReactElement` (the engine's element type), but at runtime — under the
|
|
51
|
+
* `preact/compat` alias that makes this adapter meaningful — it is a Preact vnode,
|
|
52
|
+
* which is what `preact-render-to-string` consumes. The cast names that bridge
|
|
53
|
+
* rather than widening either library's public types.
|
|
54
|
+
*/
|
|
55
|
+
export const preactServerRenderer: ServerRenderer = {
|
|
56
|
+
dialect: "preact",
|
|
57
|
+
renderToString: (node: ReactElement) => renderToString(node as never),
|
|
58
|
+
renderToStaticMarkup: (node: ReactElement) => renderToStaticMarkup(node as never),
|
|
59
|
+
};
|
package/src/server.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@lesto/ui/server` — the server-render half of the engine.
|
|
3
|
+
*
|
|
4
|
+
* Everything here imports `react-dom/server` (or its Preact twin,
|
|
5
|
+
* `preact-render-to-string`) directly or transitively: the buffered and streamed
|
|
6
|
+
* page renderers and the two server-render dialects. It is split out of the core
|
|
7
|
+
* `@lesto/ui` barrel for one load-bearing reason — a CLIENT bundle that imports
|
|
8
|
+
* `@lesto/ui` (for `Registry`, `defineIsland`, the island/data tokens) must NEVER
|
|
9
|
+
* drag `react-dom/server` into the browser graph. React's `react-dom/server` is
|
|
10
|
+
* ~60 KB gzip of code the browser never runs (the browser only *hydrates*), and
|
|
11
|
+
* before this split the barrel pulled it into every client bundle, defeating the
|
|
12
|
+
* whole point of the opt-in ~10 KB Preact dialect (ADR 0007/0008).
|
|
13
|
+
*
|
|
14
|
+
* So the rule is mechanical: anything that calls `renderToString` /
|
|
15
|
+
* `renderToStaticMarkup` / `renderToReadableStream` lives behind this subpath; a
|
|
16
|
+
* server (the `@lesto/web` page renderer, estate's `document.ts`) reaches for it
|
|
17
|
+
* explicitly. The core barrel stays isomorphic — safe to import from a module the
|
|
18
|
+
* client bundle reaches.
|
|
19
|
+
*
|
|
20
|
+
* Mirrors `@lesto/ui/client` (the browser-only hydration runtime) and
|
|
21
|
+
* `react-dom`'s own server/client split.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
export { reactServerRenderer, renderPage, renderPageMarkup, renderTree } from "./render";
|
|
25
|
+
export type { Page, RenderError, ServerRenderer } from "./render";
|
|
26
|
+
|
|
27
|
+
// Streaming SSR: a live shell-first stream for humans, plus a buffered `allReady`
|
|
28
|
+
// exit for crawlers/SSG. Additive over `renderPageMarkup` (which stays the
|
|
29
|
+
// dependency-light buffered API). Server-safe — React's stream renderer runs on
|
|
30
|
+
// Node as of React 19.2.
|
|
31
|
+
export { renderPageStream, renderPageStreamToString } from "./stream";
|
|
32
|
+
export type {
|
|
33
|
+
ErrorInfo,
|
|
34
|
+
ReactRenderStream,
|
|
35
|
+
RenderToReadableStream,
|
|
36
|
+
StreamErrorSink,
|
|
37
|
+
StreamOptions,
|
|
38
|
+
} from "./stream";
|
|
39
|
+
|
|
40
|
+
// The Preact server-render dialect — the matched pair (ADR 0008) for a client
|
|
41
|
+
// bundle built under the `react`→`preact/compat` alias. An OPTIONAL peer
|
|
42
|
+
// (`preact-render-to-string`), present only when an adopter chooses Preact, so a
|
|
43
|
+
// default React server never drags Preact's renderer into its build.
|
|
44
|
+
export { preactServerRenderer } from "./server-preact";
|