@ultimat3/render 1.2.0 → 2.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 +74 -0
- package/README.md +179 -11
- package/package.json +7 -4
- package/src/css-modules.ts +138 -0
- package/src/errors.ts +98 -0
- package/src/head.ts +49 -9
- package/src/html.ts +141 -0
- package/src/hydrate.ts +20 -4
- package/src/index.ts +57 -1
- package/src/island-collector.ts +148 -0
- package/src/island-props.ts +144 -0
- package/src/island.ts +218 -0
- package/src/islands.ts +8 -1
- package/src/jsx.ts +46 -0
- package/src/modes.ts +28 -3
- package/src/module-loader.ts +144 -0
- package/src/registry.ts +77 -11
- package/src/render-html.ts +149 -0
- package/src/render-isr.ts +64 -4
- package/src/render-stream.ts +80 -17
- package/src/route-component.ts +36 -0
- package/src/route-data.ts +59 -0
- package/src/route.ts +137 -15
- package/src/surfaces.ts +22 -3
- package/src/type-pins.tsx +101 -0
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What an island may close over: nothing but declared, JSON-safe, budgeted props.
|
|
3
|
+
* An island is named by specifier, so it cannot capture a scope — the only way the server
|
|
4
|
+
* reaches the browser is this bag, and every rule here is about what must not travel.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { IslandPropsInvalidError } from './errors';
|
|
8
|
+
import type { JsxProps } from './jsx';
|
|
9
|
+
|
|
10
|
+
export type JsonValue =
|
|
11
|
+
| string
|
|
12
|
+
| number
|
|
13
|
+
| boolean
|
|
14
|
+
| null
|
|
15
|
+
| readonly JsonValue[]
|
|
16
|
+
| { readonly [key: string]: JsonValue };
|
|
17
|
+
|
|
18
|
+
export type IslandProps = Readonly<Record<string, JsonValue>>;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Props ship inside the HTML of every response, so they are page weight the `budget` never sees
|
|
22
|
+
* as JS. A cap turns "I passed the whole row" into a number and a fix instead of a slow page.
|
|
23
|
+
*/
|
|
24
|
+
export const ISLAND_PROPS_MAX_BYTES = 4096;
|
|
25
|
+
|
|
26
|
+
/** JSX keys that are markup, not data: they stay on the server and never serialize. */
|
|
27
|
+
const SERVER_ONLY_KEYS = new Set(['children']);
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Names the value the way an author can act on it. `[object Object]` is not an instruction;
|
|
31
|
+
* "a Date at props.at" is.
|
|
32
|
+
*/
|
|
33
|
+
function describeValue(value: unknown): string {
|
|
34
|
+
if (value === undefined) return 'undefined';
|
|
35
|
+
if (typeof value === 'function') return 'a function';
|
|
36
|
+
if (typeof value === 'bigint') return 'a bigint';
|
|
37
|
+
if (typeof value === 'symbol') return 'a symbol';
|
|
38
|
+
if (value instanceof Date) return 'a Date';
|
|
39
|
+
if (value instanceof Map || value instanceof Set) return `a ${value.constructor.name}`;
|
|
40
|
+
if (typeof value === 'number' && !Number.isFinite(value)) return String(value);
|
|
41
|
+
if (typeof value === 'object' && value !== null) {
|
|
42
|
+
const proto: unknown = Object.getPrototypeOf(value);
|
|
43
|
+
if (proto !== Object.prototype && proto !== null) {
|
|
44
|
+
return `an instance of ${(value as { constructor?: { name?: string } }).constructor?.name ?? 'a class'}`;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return `a ${typeof value}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* A structural walk rather than a `JSON.stringify` round-trip: stringify drops a function and a
|
|
52
|
+
* `undefined` silently, which is the exact footgun — the prop the author meant to pass arrives
|
|
53
|
+
* missing in the browser and the island renders an empty state nobody can reproduce on the server.
|
|
54
|
+
*/
|
|
55
|
+
function assertJsonSafe(value: unknown, path: string, seen: Set<object>, file: string): JsonValue {
|
|
56
|
+
if (value === null) return null;
|
|
57
|
+
if (typeof value === 'string' || typeof value === 'boolean') return value;
|
|
58
|
+
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
|
59
|
+
|
|
60
|
+
if (Array.isArray(value)) {
|
|
61
|
+
guardCycle(value, path, seen, file);
|
|
62
|
+
const out = value.map((item, index) => assertJsonSafe(item, `${path}[${index}]`, seen, file));
|
|
63
|
+
seen.delete(value);
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (isPlainObject(value)) {
|
|
68
|
+
guardCycle(value, path, seen, file);
|
|
69
|
+
const out: Record<string, JsonValue> = {};
|
|
70
|
+
for (const [key, item] of Object.entries(value)) {
|
|
71
|
+
out[key] = assertJsonSafe(item, `${path}.${key}`, seen, file);
|
|
72
|
+
}
|
|
73
|
+
seen.delete(value);
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
throw new IslandPropsInvalidError(
|
|
78
|
+
`${path} is ${describeValue(value)}, which cannot cross the server/client boundary — ` +
|
|
79
|
+
'an island receives JSON and nothing else',
|
|
80
|
+
`pass a plain JSON value at ${path} in ${file} (an id, not the row; a string, not a Date), ` +
|
|
81
|
+
'or fetch it inside the island',
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
86
|
+
if (typeof value !== 'object' || value === null) return false;
|
|
87
|
+
const proto: unknown = Object.getPrototypeOf(value);
|
|
88
|
+
return proto === Object.prototype || proto === null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function guardCycle(value: object, path: string, seen: Set<object>, file: string): void {
|
|
92
|
+
if (!seen.has(value)) {
|
|
93
|
+
seen.add(value);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
throw new IslandPropsInvalidError(
|
|
97
|
+
`${path} closes a cycle, so it can never be serialized for the browser`,
|
|
98
|
+
`break the cycle at ${path} in ${file} — pass ids instead of linked objects`,
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The one gate between a page's scope and an island's props. Undeclared keys are refused by
|
|
104
|
+
* name because `<Modal {…row} />` is how a password hash reaches the browser, and the error that
|
|
105
|
+
* lists the columns is the one that stops it.
|
|
106
|
+
*/
|
|
107
|
+
export function checkIslandProps(
|
|
108
|
+
props: JsxProps,
|
|
109
|
+
declared: readonly string[],
|
|
110
|
+
file: string,
|
|
111
|
+
moduleId: string,
|
|
112
|
+
): IslandProps {
|
|
113
|
+
const allowed = new Set(declared);
|
|
114
|
+
const passed = Object.keys(props).filter((key) => !SERVER_ONLY_KEYS.has(key));
|
|
115
|
+
const undeclared = passed.filter((key) => !allowed.has(key));
|
|
116
|
+
|
|
117
|
+
if (undeclared.length > 0) {
|
|
118
|
+
throw new IslandPropsInvalidError(
|
|
119
|
+
`${file} passes ${undeclared.map((key) => `\`${key}\``).join(', ')} to the ${moduleId} ` +
|
|
120
|
+
`island, which declares ${declared.length === 0 ? 'no props' : declared.join(', ')} — ` +
|
|
121
|
+
'an island receives exactly what it declared, so a spread row cannot leak a column',
|
|
122
|
+
`add ${undeclared.map((key) => `'${key}'`).join(', ')} to props: [] on the island() call, ` +
|
|
123
|
+
`or stop passing ${undeclared.length === 1 ? 'it' : 'them'} in ${file}`,
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const bag: Record<string, JsonValue> = {};
|
|
128
|
+
const seen = new Set<object>();
|
|
129
|
+
for (const key of passed) {
|
|
130
|
+
bag[key] = assertJsonSafe(props[key], `props.${key}`, seen, file);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const bytes = new TextEncoder().encode(JSON.stringify(bag)).byteLength;
|
|
134
|
+
if (bytes > ISLAND_PROPS_MAX_BYTES) {
|
|
135
|
+
throw new IslandPropsInvalidError(
|
|
136
|
+
`the ${moduleId} island in ${file} carries ${bytes} bytes of props (cap ` +
|
|
137
|
+
`${ISLAND_PROPS_MAX_BYTES}), and every one of them ships inside the HTML on every request`,
|
|
138
|
+
`pass an id in ${file} and fetch the rest inside the island, or raise the cap deliberately ` +
|
|
139
|
+
'by splitting the island',
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return bag;
|
|
144
|
+
}
|
package/src/island.ts
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The island: one interactive component on an otherwise static page.
|
|
3
|
+
*
|
|
4
|
+
* Declared by module SPECIFIER, never by import — a string cannot close over a database handle,
|
|
5
|
+
* and there is no import edge for a bundler to follow, so a static page's graph stays the page's
|
|
6
|
+
* graph (axiom 6). WHEN it wakes is the route's `hydrate`, never a second declaration here.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { IslandInvalidError } from './errors';
|
|
10
|
+
import type { JsonValue } from './island-props';
|
|
11
|
+
import type { JsxProps } from './jsx';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* One spelling, like `page.tsx` and `route.ts`: a file is a client entry if and only if its name
|
|
15
|
+
* says so. That is what makes "what ships JS?" answerable by `grep` and by the bundler, without
|
|
16
|
+
* opening a file or following an import.
|
|
17
|
+
*/
|
|
18
|
+
export const ISLAND_EXTENSION = '.island.tsx';
|
|
19
|
+
|
|
20
|
+
/** Registered globally so two copies of this module agree on what an island node is. */
|
|
21
|
+
export const ISLAND_NODE: unique symbol = Symbol.for('ultimate.render.island') as never;
|
|
22
|
+
|
|
23
|
+
/** Characters that would break out of the `data-x-entry` attribute the specifier lands in. */
|
|
24
|
+
const UNSAFE_SPECIFIER = /["'`<>\s\\]/;
|
|
25
|
+
|
|
26
|
+
/** The same test the resolver's output has to pass: one rule, applied at both ends of the seam. */
|
|
27
|
+
export function isEmittableSpecifier(value: string): boolean {
|
|
28
|
+
return value.length > 0 && !UNSAFE_SPECIFIER.test(value);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface IslandDeclaration<TKeys extends readonly string[] = readonly string[]> {
|
|
32
|
+
/** Relative specifier of the client entry, e.g. `./contact-modal.island.tsx`. */
|
|
33
|
+
readonly src: string;
|
|
34
|
+
/** The exact prop names this island accepts. Anything else is a build failure. */
|
|
35
|
+
readonly props?: TKeys;
|
|
36
|
+
/** The wrapper element. `span` for an island inside a line of text. */
|
|
37
|
+
readonly tag?: string;
|
|
38
|
+
/** Events replayed for `hydrate: 'interaction'`. */
|
|
39
|
+
readonly events?: readonly string[];
|
|
40
|
+
/** `rootMargin` for `hydrate: 'visible'`. */
|
|
41
|
+
readonly rootMargin?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The normalized declaration. `island()` is the one normalizer, as `defineRoute` is for routes. */
|
|
45
|
+
export interface IslandSpec {
|
|
46
|
+
/** Stable, derived from `src`: the unit a bundle is measured in and a budget counts. */
|
|
47
|
+
readonly moduleId: string;
|
|
48
|
+
readonly src: string;
|
|
49
|
+
readonly propKeys: readonly string[];
|
|
50
|
+
readonly tag: string;
|
|
51
|
+
readonly events?: readonly string[];
|
|
52
|
+
readonly rootMargin?: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* An island node is a branded ARRAY, and that is not decoration.
|
|
57
|
+
*
|
|
58
|
+
* An app types its JSX with `jsxImportSource: solid-js`, whose `JSX.Element` is a type ALIAS —
|
|
59
|
+
* `Node | ArrayElement | (string & {}) | number | boolean | null | undefined` — so it can neither
|
|
60
|
+
* be augmented nor satisfied by a plain object. A component returning one is TS2786 at every
|
|
61
|
+
* `<ContactSales />`, which is how a feature whose whole point is "a contact modal on an otherwise
|
|
62
|
+
* static page" shipped usable only through `h(Modal, …)`. `ArrayElement` is the union's one
|
|
63
|
+
* object-shaped member, so being an array is what makes the island an ordinary JSX child.
|
|
64
|
+
*
|
|
65
|
+
* The array is empty and stays empty — the shell is `props.children`, walked by `render-html.ts`,
|
|
66
|
+
* and `never[]` is the honest element type for an array nothing is ever pushed into. It also keeps
|
|
67
|
+
* render free of `solid-js`: the constraint is satisfied structurally, not by importing the union.
|
|
68
|
+
* `type-pins.tsx` is what holds the claim to a build error.
|
|
69
|
+
*/
|
|
70
|
+
export interface IslandNode extends Array<never> {
|
|
71
|
+
readonly [ISLAND_NODE]: true;
|
|
72
|
+
readonly spec: IslandSpec;
|
|
73
|
+
readonly props: JsxProps;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Tested BEFORE `Array.isArray` by every walker, since an island node is now both. `render-html.ts`
|
|
78
|
+
* is the one that matters: the array branch would render an empty shell and drop the island.
|
|
79
|
+
*/
|
|
80
|
+
export function isIslandNode(value: unknown): value is IslandNode {
|
|
81
|
+
return typeof value === 'object' && value !== null && ISLAND_NODE in value;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Declared props are JSON, plus the server-only children that become the island's shell. */
|
|
85
|
+
export type IslandComponent<TKeys extends readonly string[]> = (
|
|
86
|
+
props: Readonly<Record<TKeys[number], JsonValue>> & { readonly children?: unknown },
|
|
87
|
+
) => IslandNode;
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* `./contact-modal.island.tsx` → `contact-modal`; `../shared/search.island.tsx` → `shared-search`.
|
|
91
|
+
* Derived from the path rather than hashed so the id in the HTML, the budget report and the
|
|
92
|
+
* manifest is the one an author can find on disk.
|
|
93
|
+
*/
|
|
94
|
+
export function islandModuleId(src: string): string {
|
|
95
|
+
return src
|
|
96
|
+
.slice(0, -ISLAND_EXTENSION.length)
|
|
97
|
+
.replace(/^(?:\.\.?\/)+/, '')
|
|
98
|
+
.toLowerCase()
|
|
99
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
100
|
+
.replace(/^-+|-+$/g, '');
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Islands declared since the last `defineRoute` drained this list — the seam that lets a page
|
|
105
|
+
* declaring an island hydrate without saying so twice.
|
|
106
|
+
*
|
|
107
|
+
* Ambient, and deliberately not the thing `island-collector.ts` refuses to be: that one is about
|
|
108
|
+
* a RENDER, where two concurrent requests would bill one page for the other's JS. This one is
|
|
109
|
+
* about a MODULE, evaluated once, on one thread, before any request exists — and `src` is resolved
|
|
110
|
+
* relative to the route file (`island-bundle.ts`), so an `island()` call is a route-module-local
|
|
111
|
+
* declaration by construction. `defineRoute` empties it, so nothing accumulates.
|
|
112
|
+
*/
|
|
113
|
+
const declaredIslands: IslandSpec[] = [];
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Called by `defineRoute` alone. Returns what this module declared and resets the list.
|
|
117
|
+
*
|
|
118
|
+
* Package-internal on purpose — reachable from `./island`, absent from `src/index.ts`. An app that
|
|
119
|
+
* could call this between its `island()` and its `defineRoute` would silently drain the
|
|
120
|
+
* declarations the route derives `hydrate`, `budget.js` and `entry.islands` from, and get a page
|
|
121
|
+
* that renders an island nothing boots. Machinery is not API, and a public export is semver-locked
|
|
122
|
+
* the moment it ships.
|
|
123
|
+
*/
|
|
124
|
+
export function drainDeclaredIslands(): readonly IslandSpec[] {
|
|
125
|
+
if (declaredIslands.length === 0) return [];
|
|
126
|
+
const drained = [...declaredIslands];
|
|
127
|
+
declaredIslands.length = 0;
|
|
128
|
+
return drained;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Whether this exact spec is still waiting to be drained — which is decidable, and is the whole
|
|
133
|
+
* difference between the two causes of `X_ISLAND_NOT_HYDRATED`. A spec still pending at RENDER
|
|
134
|
+
* time was declared where no `defineRoute` could see it (below the route, or outside a route
|
|
135
|
+
* module); a spec already drained means the route reached `'never'` because an author wrote it.
|
|
136
|
+
*
|
|
137
|
+
* Identity, not equality: `island()` pushes the object it closes over, so the spec reaching the
|
|
138
|
+
* collector is the same one, and two islands with identical fields never collide.
|
|
139
|
+
*/
|
|
140
|
+
export function islandNeverDrained(spec: IslandSpec): boolean {
|
|
141
|
+
return declaredIslands.includes(spec);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Test seam: the list is process-global because the module cache it mirrors is too. */
|
|
145
|
+
export function clearDeclaredIslands(): void {
|
|
146
|
+
declaredIslands.length = 0;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Declare an island. Runs at the page module's scope, so a bad declaration fails when the route
|
|
151
|
+
* is loaded — at build time for `static`, and before the first request for every other mode.
|
|
152
|
+
*
|
|
153
|
+
* Declare it ABOVE `defineRoute`, which is where JavaScript already puts a `const` a page uses:
|
|
154
|
+
* that is what lets the route derive `hydrate` and its JS budget instead of asking for both. An
|
|
155
|
+
* island declared below the route still renders — and still fails loudly, as
|
|
156
|
+
* `X_ISLAND_NOT_HYDRATED`, rather than shipping markup nothing boots.
|
|
157
|
+
*/
|
|
158
|
+
export function island<const TKeys extends readonly string[] = []>(
|
|
159
|
+
declaration: IslandDeclaration<TKeys>,
|
|
160
|
+
): IslandComponent<TKeys> {
|
|
161
|
+
const spec = normalizeIsland(declaration);
|
|
162
|
+
declaredIslands.push(spec);
|
|
163
|
+
return (props) => islandNode(spec, props as JsxProps);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** The one constructor. `Object.assign` over an array is what gives the node both identities. */
|
|
167
|
+
function islandNode(spec: IslandSpec, props: JsxProps): IslandNode {
|
|
168
|
+
return Object.assign([] as never[], { [ISLAND_NODE]: true as const, spec, props });
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function normalizeIsland(declaration: IslandDeclaration): IslandSpec {
|
|
172
|
+
const src = declaration.src;
|
|
173
|
+
if (typeof src !== 'string' || src.length === 0) {
|
|
174
|
+
throw new IslandInvalidError(
|
|
175
|
+
'island() was given no src, so there is no module for the browser to import',
|
|
176
|
+
`pass src: './<name>${ISLAND_EXTENSION}' — the client entry, as a specifier, never an import`,
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
if (src.includes('://')) {
|
|
180
|
+
throw new IslandInvalidError(
|
|
181
|
+
`island src ${JSON.stringify(src)} is a remote URL, so it is outside the bundle graph and ` +
|
|
182
|
+
'outside the route budget that has to count it',
|
|
183
|
+
`vendor the module into the app and pass src: './<name>${ISLAND_EXTENSION}'`,
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
if (UNSAFE_SPECIFIER.test(src)) {
|
|
187
|
+
throw new IslandInvalidError(
|
|
188
|
+
`island src ${JSON.stringify(src)} contains a character that cannot appear in the ` +
|
|
189
|
+
'data-x-entry attribute it is emitted into',
|
|
190
|
+
`rename the module to a plain path and pass src: './<name>${ISLAND_EXTENSION}'`,
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
if (!src.endsWith(ISLAND_EXTENSION)) {
|
|
194
|
+
const stem = src.replace(/\.[jt]sx?$/, '');
|
|
195
|
+
throw new IslandInvalidError(
|
|
196
|
+
`island src ${JSON.stringify(src)} is not an island file: a module ships to the browser ` +
|
|
197
|
+
`only if its name says so, and ${ISLAND_EXTENSION} is the one spelling that says it`,
|
|
198
|
+
`git mv -- ${src} ${stem}${ISLAND_EXTENSION}, then pass src: '${stem}${ISLAND_EXTENSION}'`,
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const moduleId = islandModuleId(src);
|
|
203
|
+
if (moduleId.length === 0) {
|
|
204
|
+
throw new IslandInvalidError(
|
|
205
|
+
`island src ${JSON.stringify(src)} has no name left once ${ISLAND_EXTENSION} is removed`,
|
|
206
|
+
`name the module: src: './<name>${ISLAND_EXTENSION}'`,
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return {
|
|
211
|
+
moduleId,
|
|
212
|
+
src,
|
|
213
|
+
propKeys: declaration.props ?? [],
|
|
214
|
+
tag: declaration.tag ?? 'div',
|
|
215
|
+
...(declaration.events === undefined ? {} : { events: declaration.events }),
|
|
216
|
+
...(declaration.rootMargin === undefined ? {} : { rootMargin: declaration.rootMargin }),
|
|
217
|
+
};
|
|
218
|
+
}
|
package/src/islands.ts
CHANGED
|
@@ -74,7 +74,14 @@ export function routeJsBytes(
|
|
|
74
74
|
directives: readonly IslandDirective[] = [],
|
|
75
75
|
): RouteBytes {
|
|
76
76
|
const graph = graphFor(entry.surface, islands);
|
|
77
|
-
|
|
77
|
+
// Two sources, unioned: `entry.islands` is what registration declared, and the directives are
|
|
78
|
+
// what the page actually rendered. Reading only the first is how the runtime bytes of an island
|
|
79
|
+
// could be charged while its chunk was not — a budget that counts the wrapper and not the code.
|
|
80
|
+
const onRoute = graph.islands.filter(
|
|
81
|
+
(island) =>
|
|
82
|
+
entry.islands.includes(island.id) ||
|
|
83
|
+
directives.some((directive) => (directive.moduleId ?? directive.islandId) === island.id),
|
|
84
|
+
);
|
|
78
85
|
const islandBytes = onRoute.reduce((sum, island) => sum + island.bytes, 0);
|
|
79
86
|
const runtimeBytes = directives.length > 0 ? hydrateRuntimeBytes(directives) : 0;
|
|
80
87
|
const baseline = islandBytes === 0 && runtimeBytes === 0 ? 0 : graph.baselineBytes;
|
package/src/jsx.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The server JSX factory. `h` builds an inert node — no DOM, no reactivity, no `solid-js` — so a
|
|
3
|
+
* page component stays a pure function from props to a tree, and `render-html.ts` is the only
|
|
4
|
+
* thing that decides what a tree means. This is what the `.tsx` loader compiles every element to.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** Registered on the global symbol registry: two copies of this module must agree on a node. */
|
|
8
|
+
export const JSX_NODE: unique symbol = Symbol.for('ultimate.render.jsx') as never;
|
|
9
|
+
|
|
10
|
+
export type JsxProps = Readonly<Record<string, unknown>>;
|
|
11
|
+
|
|
12
|
+
/** A component is any function of props. Async is allowed: `render-html.ts` awaits it. */
|
|
13
|
+
export type JsxComponent = (props: JsxProps) => unknown;
|
|
14
|
+
|
|
15
|
+
export interface JsxNode {
|
|
16
|
+
readonly [JSX_NODE]: true;
|
|
17
|
+
/** A lowercase string is an element; a function is a component. */
|
|
18
|
+
readonly type: string | JsxComponent;
|
|
19
|
+
readonly props: JsxProps;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function isJsxNode(value: unknown): value is JsxNode {
|
|
23
|
+
return typeof value === 'object' && value !== null && JSX_NODE in value;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Children live in `props.children`, the shape Solid and every JSX author already writes — so a
|
|
28
|
+
* component reading `props.children` behaves the same whether its children came from the classic
|
|
29
|
+
* factory's rest arguments or from an explicit `children` prop.
|
|
30
|
+
*/
|
|
31
|
+
export function h(
|
|
32
|
+
type: string | JsxComponent,
|
|
33
|
+
props: JsxProps | null,
|
|
34
|
+
...children: readonly unknown[]
|
|
35
|
+
): JsxNode {
|
|
36
|
+
const base = props ?? {};
|
|
37
|
+
if (children.length === 0) return { [JSX_NODE]: true, type, props: base };
|
|
38
|
+
return {
|
|
39
|
+
[JSX_NODE]: true,
|
|
40
|
+
type,
|
|
41
|
+
props: { ...base, children: children.length === 1 ? children[0] : children },
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** `<>…</>`. A fragment is its children and nothing else — no wrapper element in the output. */
|
|
46
|
+
export const Fragment: JsxComponent = (props) => (props as { children?: unknown }).children;
|
package/src/modes.ts
CHANGED
|
@@ -14,10 +14,15 @@ import { SURFACE_SPECS, surfaceAllows } from './surfaces';
|
|
|
14
14
|
export const RENDER_MODES = ['static', 'isr', 'ssr', 'stream', 'spa'] as const;
|
|
15
15
|
|
|
16
16
|
/**
|
|
17
|
-
* Everything about a route except its
|
|
18
|
-
* omitting
|
|
17
|
+
* Everything about a route except the two keys carrying its data generic. Mode invariants never
|
|
18
|
+
* read metadata and never load anything, and omitting both keeps these checks free of `TData`.
|
|
19
|
+
*
|
|
20
|
+
* Not a convenience — a requirement. Both are function-typed *properties*, so they are checked
|
|
21
|
+
* contravariantly: keeping either here makes `RouteConfig<TData>` unassignable to `RouteShape`
|
|
22
|
+
* for every `TData` other than the default, and `assertModeShape(config)` stops compiling inside
|
|
23
|
+
* `defineRoute` itself. Same variance trap that `Invariant.holds` hit in `@ultimat3/entity`.
|
|
19
24
|
*/
|
|
20
|
-
export type RouteShape = Omit<RouteConfig, 'meta'>;
|
|
25
|
+
export type RouteShape = Omit<RouteConfig, 'meta' | 'load'>;
|
|
21
26
|
|
|
22
27
|
export interface ModeSpec {
|
|
23
28
|
readonly mode: RenderMode;
|
|
@@ -206,3 +211,23 @@ export function assertModeInvariants(config: RouteShape, ctx: ModeCheckContext):
|
|
|
206
211
|
export function defaultHydrate(surface: Surface): HydrateStrategy {
|
|
207
212
|
return surface === 'site' ? 'never' : 'idle';
|
|
208
213
|
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* What an island route may spend ABOVE its surface's baseline when it declares no `budget.js`.
|
|
217
|
+
*
|
|
218
|
+
* A number, not "unlimited", because the guard means nothing otherwise — and 4kb because that is
|
|
219
|
+
* roughly twice what the reference app's real island costs (875 B of chunk plus a 1,019 B runtime),
|
|
220
|
+
* which is enough for a second small island and not enough to hide a library. A declared
|
|
221
|
+
* `budget.js` still wins, and exceeding this one still fails with the island named.
|
|
222
|
+
*/
|
|
223
|
+
export const DEFAULT_ISLAND_JS_BYTES = 4096;
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* The derived ceiling for a route whose hydration came from an island. Relative to the surface
|
|
227
|
+
* baseline, never absolute: `site/` starts at 0kb and `app/` at 14kb, so one number would be
|
|
228
|
+
* either a ceiling `app/` fails on arrival or one `site/` can never reach.
|
|
229
|
+
*/
|
|
230
|
+
export function defaultIslandBudget(surface: Surface): string {
|
|
231
|
+
const bytes = SURFACE_SPECS[surface].jsBaselineBytes + DEFAULT_ISLAND_JS_BYTES;
|
|
232
|
+
return `${bytes / 1024}kb`;
|
|
233
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The two loaders that make an app's source runnable: `.tsx` → the server JSX factory, and
|
|
3
|
+
* `.scss` → CSS plus a class-name map. Both are Bun runtime plugins, so `x dev`, `x build` and
|
|
4
|
+
* `bun test` all load a component the same way and there is no separate "bundled" behaviour.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { compileStylesheet, isGlobalStylesheet } from './css-modules';
|
|
8
|
+
import { PrerenderFailedError } from './errors';
|
|
9
|
+
import type { Surface } from './surfaces';
|
|
10
|
+
import { surfaceOf } from './surfaces';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Why a transform at all: `tsconfig.json` says `jsx: 'preserve'`, which makes Bun fall back to the
|
|
14
|
+
* CLASSIC React factory and ignore `jsxImportSource` entirely — every `.tsx` in an Ultimate app
|
|
15
|
+
* compiles to `React.createElement` against a global that does not exist. `jsxImportSource` stays
|
|
16
|
+
* pointed at `solid-js` because that is where the JSX *type* namespace lives (`class`, not
|
|
17
|
+
* `className`); the *runtime* factory is the framework's, and an app never configures one.
|
|
18
|
+
*/
|
|
19
|
+
const JSX_FACTORY = '__xh';
|
|
20
|
+
const JSX_FRAGMENT = '__xFragment';
|
|
21
|
+
|
|
22
|
+
/** No newline: the prelude shares line 1 with the file's own first line, so stack traces still point at it. */
|
|
23
|
+
const JSX_PRELUDE = `import { h as ${JSX_FACTORY}, Fragment as ${JSX_FRAGMENT} } from '@ultimat3/render';`;
|
|
24
|
+
|
|
25
|
+
const transpiler = new Bun.Transpiler({
|
|
26
|
+
loader: 'tsx',
|
|
27
|
+
tsconfig: {
|
|
28
|
+
compilerOptions: {
|
|
29
|
+
jsx: 'react',
|
|
30
|
+
jsxFactory: JSX_FACTORY,
|
|
31
|
+
jsxFragmentFactory: JSX_FRAGMENT,
|
|
32
|
+
},
|
|
33
|
+
} as never,
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
export interface Stylesheet {
|
|
37
|
+
/** Absolute path of the source file. */
|
|
38
|
+
readonly file: string;
|
|
39
|
+
/** The surface that owns it, or `null` for a package stylesheet shared by both graphs. */
|
|
40
|
+
readonly surface: Surface | null;
|
|
41
|
+
/** A plain (non-module) stylesheet: the tokens and the reset, which the cascade needs first. */
|
|
42
|
+
readonly global: boolean;
|
|
43
|
+
readonly css: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const stylesheets = new Map<string, Stylesheet>();
|
|
47
|
+
|
|
48
|
+
export function registeredStylesheets(): readonly Stylesheet[] {
|
|
49
|
+
return [...stylesheets.values()];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Test seam: the registry is process-global because the module cache it mirrors is too. */
|
|
53
|
+
export function clearStylesheets(): void {
|
|
54
|
+
stylesheets.clear();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The CSS a document on `surface` must carry: the global layer first, then the modules, each group
|
|
59
|
+
* in load order. A `site/` page never receives `app/` CSS — that is axiom 6 applied to bytes the
|
|
60
|
+
* browser parses, not just bytes it executes.
|
|
61
|
+
*
|
|
62
|
+
* `shared/` is carried by both graphs by definition — it is the one directory both surfaces import
|
|
63
|
+
* from, and it is where an app's own global stylesheet lives. Filtering it out (which this did)
|
|
64
|
+
* meant an app could put its tokens in the one place the convention names and have every document
|
|
65
|
+
* silently drop them.
|
|
66
|
+
*
|
|
67
|
+
* Globals sort ahead of modules rather than riding load order: the reset styles bare elements at
|
|
68
|
+
* the lowest specificity there is, so a reset that happened to register after a module rule wins
|
|
69
|
+
* ties it must lose. Insertion order alone made that depend on which page a request hit first.
|
|
70
|
+
*/
|
|
71
|
+
export function stylesFor(surface: Surface | null): string {
|
|
72
|
+
const carried = [...stylesheets.values()].filter(
|
|
73
|
+
(sheet) => sheet.surface === null || sheet.surface === 'shared' || sheet.surface === surface,
|
|
74
|
+
);
|
|
75
|
+
return [...carried.filter((sheet) => sheet.global), ...carried.filter((sheet) => !sheet.global)]
|
|
76
|
+
.map((sheet) => sheet.css)
|
|
77
|
+
.join('');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* `.tsx` source → JS calling the server factory. Exported so the transform is testable as a pure
|
|
82
|
+
* function: the plugin below is the six lines of glue that hand it a file.
|
|
83
|
+
*/
|
|
84
|
+
export function transformTsx(source: string): string {
|
|
85
|
+
// No newline between: the prelude shares line 1 with the file's own first line, so every stack
|
|
86
|
+
// trace and every reported error still points at the line the author wrote.
|
|
87
|
+
return JSX_PRELUDE + transpiler.transformSync(source);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* `.scss` source → the module body an `import styles from …` receives, registering the CSS.
|
|
92
|
+
*
|
|
93
|
+
* Keyed by absolute path, which is what makes the global layer emit ONCE however many app modules
|
|
94
|
+
* import it. Sass dedupes `@use` within a single compilation, and every file here is its own
|
|
95
|
+
* compilation — so a token file `@use`d by twenty `page.module.scss` would inline its `:root` block
|
|
96
|
+
* twenty times. One file, imported for its side effect, is the shape that cannot do that.
|
|
97
|
+
*/
|
|
98
|
+
export function loadStylesheet(path: string, source: string): string {
|
|
99
|
+
const compiled = compileStylesheet(path, source);
|
|
100
|
+
if (compiled.css.length > 0) {
|
|
101
|
+
stylesheets.set(path, {
|
|
102
|
+
file: path,
|
|
103
|
+
surface: surfaceOf(path),
|
|
104
|
+
global: isGlobalStylesheet(path),
|
|
105
|
+
css: compiled.css,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
return `export default ${JSON.stringify(compiled.classes)};`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
let installed = false;
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Idempotent: importing `@ultimat3/render` from anywhere installs it once, which is the only
|
|
115
|
+
* placement that covers `x dev`, `x build`, the production `server.ts` and `bun test` without each
|
|
116
|
+
* of them remembering to. A plugin only affects modules loaded AFTER it, and every route module is.
|
|
117
|
+
*/
|
|
118
|
+
export function installRenderLoader(): void {
|
|
119
|
+
if (installed) return;
|
|
120
|
+
installed = true;
|
|
121
|
+
|
|
122
|
+
Bun.plugin({
|
|
123
|
+
name: 'ultimate-render',
|
|
124
|
+
setup(build): void {
|
|
125
|
+
build.onLoad({ filter: /\.tsx$/ }, async ({ path }) => ({
|
|
126
|
+
contents: transformTsx(await Bun.file(path).text()),
|
|
127
|
+
loader: 'js',
|
|
128
|
+
}));
|
|
129
|
+
|
|
130
|
+
build.onLoad({ filter: /\.(?:s?css)$/ }, async ({ path }) => {
|
|
131
|
+
const source = await Bun.file(path).text();
|
|
132
|
+
try {
|
|
133
|
+
return { contents: loadStylesheet(path, source), loader: 'js' };
|
|
134
|
+
} catch (error) {
|
|
135
|
+
if (error instanceof PrerenderFailedError) throw error;
|
|
136
|
+
throw new PrerenderFailedError(
|
|
137
|
+
`${path} could not be loaded: ${error instanceof Error ? error.message : String(error)}`,
|
|
138
|
+
`open ${path} and fix the stylesheet it @use-s`,
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
},
|
|
143
|
+
});
|
|
144
|
+
}
|