@ultimat3/render 1.1.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
package/src/head.ts
CHANGED
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
|
|
10
10
|
import type { RouteMeta } from '@ultimat3/seo';
|
|
11
11
|
import { BudgetExceededError } from './errors';
|
|
12
|
+
// `html.ts` is this package's one escaper — a second one is how a character ends up missing.
|
|
13
|
+
import { escapeAttribute, escapeJsonContent, escapeRawTextContent, escapeText } from './html';
|
|
12
14
|
|
|
13
15
|
export type HeadTagKind = 'title' | 'base' | 'meta' | 'link' | 'script' | 'style';
|
|
14
16
|
|
|
@@ -27,7 +29,13 @@ export type LdRenderer = (meta: RouteMeta) => string | null;
|
|
|
27
29
|
export interface HeadRenderers {
|
|
28
30
|
/** `@ultimat3/seo`'s `renderMeta`, adapted to `HeadTag[]`. */
|
|
29
31
|
readonly renderMeta: MetaRenderer;
|
|
30
|
-
/**
|
|
32
|
+
/**
|
|
33
|
+
* A host's own JSON-LD body, or null. Deliberately unbound by `seoRenderers()` — `renderMeta`
|
|
34
|
+
* already emits `meta.ld` as one script per node, and a second source for the same tags is how
|
|
35
|
+
* a document ends up with two copies of its graph. It never was `@ultimat3/seo`'s `renderLd`
|
|
36
|
+
* either: that took nodes and returned a `HeadTag`, so it could not satisfy this signature. It
|
|
37
|
+
* was deleted in 1.3.0 for being that second source.
|
|
38
|
+
*/
|
|
31
39
|
readonly renderLd?: LdRenderer;
|
|
32
40
|
}
|
|
33
41
|
|
|
@@ -53,12 +61,35 @@ export function mergeHead(...sources: readonly (readonly HeadTag[])[]): readonly
|
|
|
53
61
|
}
|
|
54
62
|
|
|
55
63
|
/** Build the head for a route from its `meta` output plus explicit overrides. */
|
|
64
|
+
/**
|
|
65
|
+
* The tags every HTML document needs and no route should have to declare. Merged FIRST, so a
|
|
66
|
+
* route that sets one of these keys still wins.
|
|
67
|
+
*
|
|
68
|
+
* Absent until now, and the omission was not cosmetic: with no `viewport`, a phone lays the page
|
|
69
|
+
* out at ~980px and scales it down, so every deployed Ultimate app rendered zoomed-out on mobile
|
|
70
|
+
* whatever its CSS said. `color-scheme` is the other half of the token layer's dark mode — without
|
|
71
|
+
* it the browser paints its own form controls and scrollbars light under a dark page.
|
|
72
|
+
*/
|
|
73
|
+
export const documentBaseline = (): readonly HeadTag[] => [
|
|
74
|
+
{ kind: 'meta', key: 'meta:charset', attrs: { charset: 'utf-8' } },
|
|
75
|
+
{
|
|
76
|
+
kind: 'meta',
|
|
77
|
+
key: 'meta:viewport',
|
|
78
|
+
attrs: { name: 'viewport', content: 'width=device-width, initial-scale=1' },
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
kind: 'meta',
|
|
82
|
+
key: 'meta:color-scheme',
|
|
83
|
+
attrs: { name: 'color-scheme', content: 'light dark' },
|
|
84
|
+
},
|
|
85
|
+
];
|
|
86
|
+
|
|
56
87
|
export function headFromMeta(
|
|
57
88
|
meta: RouteMeta,
|
|
58
89
|
renderers: HeadRenderers,
|
|
59
90
|
overrides: readonly HeadTag[] = [],
|
|
60
91
|
): readonly HeadTag[] {
|
|
61
|
-
const seoTags = renderers.renderMeta(meta);
|
|
92
|
+
const seoTags = [...documentBaseline(), ...renderers.renderMeta(meta)];
|
|
62
93
|
const ld = renderers.renderLd?.(meta) ?? null;
|
|
63
94
|
const ldTags: readonly HeadTag[] =
|
|
64
95
|
ld === null
|
|
@@ -83,21 +114,30 @@ export function renderHead(tags: readonly HeadTag[]): string {
|
|
|
83
114
|
function renderTag(tag: HeadTag): string {
|
|
84
115
|
const attrs = Object.entries(tag.attrs ?? {})
|
|
85
116
|
.map(([name, value]) =>
|
|
86
|
-
value === true ? ` ${name}` : ` ${name}="${
|
|
117
|
+
value === true ? ` ${name}` : ` ${name}="${escapeAttribute(String(value))}"`,
|
|
87
118
|
)
|
|
88
119
|
.join('');
|
|
89
120
|
if (VOID_KINDS.has(tag.kind)) return `<${tag.kind}${attrs}>`;
|
|
90
121
|
const raw = tag.content ?? '';
|
|
91
|
-
|
|
92
|
-
return `<${tag.kind}${attrs}>${isRaw ? raw : escapeText(raw)}</${tag.kind}>`;
|
|
122
|
+
return `<${tag.kind}${attrs}>${contentOf(tag, raw)}</${tag.kind}>`;
|
|
93
123
|
}
|
|
94
124
|
|
|
95
|
-
|
|
96
|
-
|
|
125
|
+
/**
|
|
126
|
+
* Three contexts, three rules, and the one that was missing was the one attacker text reaches.
|
|
127
|
+
* `script`/`style` are raw text (`escapeRawTextContent`); a JSON-carrying script is data, so it
|
|
128
|
+
* takes the total JSON rule; everything else is HTML text, where a character reference IS decoded.
|
|
129
|
+
* `content` was emitted VERBATIM for script and style until `As of 2026-08`, which made any string
|
|
130
|
+
* reaching `meta.ld` — a title, a product name, a bio — able to close the element.
|
|
131
|
+
*/
|
|
132
|
+
function contentOf(tag: HeadTag, raw: string): string {
|
|
133
|
+
if (tag.kind !== 'script' && tag.kind !== 'style') return escapeText(raw);
|
|
134
|
+
return carriesJson(tag) ? escapeJsonContent(raw) : escapeRawTextContent(raw);
|
|
97
135
|
}
|
|
98
136
|
|
|
99
|
-
|
|
100
|
-
|
|
137
|
+
/** `application/ld+json`, `application/json`, any `…+json`: the body is data, not code. */
|
|
138
|
+
function carriesJson(tag: HeadTag): boolean {
|
|
139
|
+
const type = tag.attrs?.['type'];
|
|
140
|
+
return typeof type === 'string' && type.trim().toLowerCase().endsWith('json');
|
|
101
141
|
}
|
|
102
142
|
|
|
103
143
|
export interface ThemeScriptOptions {
|
package/src/html.ts
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTML text and attribute serialization for the server renderer. Escaping lives here and only
|
|
3
|
+
* here: a second escaper is how one of them ends up missing a character, and a missing character
|
|
4
|
+
* in an attribute is an injection.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { safeUrl, URL_ATTRIBUTES } from '@ultimat3/core';
|
|
8
|
+
import type { JsxProps } from './jsx';
|
|
9
|
+
|
|
10
|
+
/** Elements that never carry children, so the writer must not emit a closing tag. */
|
|
11
|
+
export const VOID_ELEMENTS: ReadonlySet<string> = new Set([
|
|
12
|
+
'area',
|
|
13
|
+
'base',
|
|
14
|
+
'br',
|
|
15
|
+
'col',
|
|
16
|
+
'embed',
|
|
17
|
+
'hr',
|
|
18
|
+
'img',
|
|
19
|
+
'input',
|
|
20
|
+
'link',
|
|
21
|
+
'meta',
|
|
22
|
+
'param',
|
|
23
|
+
'source',
|
|
24
|
+
'track',
|
|
25
|
+
'wbr',
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
export function escapeText(value: string): string {
|
|
29
|
+
return value.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function escapeAttribute(value: string): string {
|
|
33
|
+
return escapeText(value).replaceAll('"', '"');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* `<script>` and `<style>` hold RAW TEXT: a character reference is not decoded inside them, so
|
|
38
|
+
* `escapeText` there would ship `<` to a JS or CSS parser and corrupt the code without closing
|
|
39
|
+
* the hole. What actually ends the element is `</` followed by its tag name, and — inside a script
|
|
40
|
+
* only — `<!--` switches the tokenizer into the escaped state where the element's own `</script>`
|
|
41
|
+
* no longer closes it and the rest of the document becomes script text.
|
|
42
|
+
*
|
|
43
|
+
* So the two sequences are made unwritable instead. `\/` and `\!` are the identity escape in a JS
|
|
44
|
+
* string, a JS regex, a CSS string and a CSS url(), which is where a `</` in authored code lives;
|
|
45
|
+
* outside a string neither `</` nor `<!--` is valid code in either language.
|
|
46
|
+
*/
|
|
47
|
+
export function escapeRawTextContent(value: string): string {
|
|
48
|
+
return value.replaceAll('</', '<\\/').replaceAll('<!--', '<\\!--');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The same element, when its content is JSON rather than code — `application/ld+json`, which is
|
|
53
|
+
* built from route data and is therefore the path attacker text takes. JSON gets the total rule:
|
|
54
|
+
* `<` is the same character to `JSON.parse`, so nothing survives that could spell `</script`
|
|
55
|
+
* or `<!--`, and the body stays byte-for-byte valid JSON.
|
|
56
|
+
*
|
|
57
|
+
* Safe by construction because `<`, `>`, `&`, U+2028 and U+2029 can only occur INSIDE a JSON
|
|
58
|
+
* string — no JSON structural token contains one — so every replacement lands where `\u` means
|
|
59
|
+
* an escape. U+2028/U+2029 are legal in a JSON string and illegal in a JS one, and this content
|
|
60
|
+
* is read back by both.
|
|
61
|
+
*/
|
|
62
|
+
export function escapeJsonContent(json: string): string {
|
|
63
|
+
return json
|
|
64
|
+
.replaceAll('<', '\\u003c')
|
|
65
|
+
.replaceAll('>', '\\u003e')
|
|
66
|
+
.replaceAll('&', '\\u0026')
|
|
67
|
+
.replaceAll('\u2028', '\\u2028')
|
|
68
|
+
.replaceAll('\u2029', '\\u2029');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* JSX prop name → attribute name. Solid authors write the HTML spelling (`class`, `for`), but the
|
|
73
|
+
* React spellings compile too, and an author who writes one and gets no attribute has a bug with
|
|
74
|
+
* no error message.
|
|
75
|
+
*/
|
|
76
|
+
const ATTRIBUTE_ALIASES: Readonly<Record<string, string>> = {
|
|
77
|
+
className: 'class',
|
|
78
|
+
htmlFor: 'for',
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
/** Props the tree consumes rather than emits. `innerHTML` is emitted as content, not an attribute. */
|
|
82
|
+
const NON_ATTRIBUTES: ReadonlySet<string> = new Set([
|
|
83
|
+
'children',
|
|
84
|
+
'ref',
|
|
85
|
+
'key',
|
|
86
|
+
'innerHTML',
|
|
87
|
+
'textContent',
|
|
88
|
+
]);
|
|
89
|
+
|
|
90
|
+
const cssProperty = (name: string): string =>
|
|
91
|
+
name.startsWith('--') ? name : name.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`);
|
|
92
|
+
|
|
93
|
+
/** `{ marginTop: '1rem' }` → `margin-top:1rem`. A string style passes through untouched. */
|
|
94
|
+
export function styleValue(value: unknown): string | null {
|
|
95
|
+
if (typeof value === 'string') return value;
|
|
96
|
+
if (typeof value !== 'object' || value === null) return null;
|
|
97
|
+
const parts = Object.entries(value as Record<string, unknown>)
|
|
98
|
+
.filter(([, item]) => item !== undefined && item !== null && item !== false)
|
|
99
|
+
.map(([name, item]) => `${cssProperty(name)}:${String(item)}`);
|
|
100
|
+
return parts.length === 0 ? null : parts.join(';');
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* One prop → one attribute string, or `null` when it emits nothing. Event handlers are dropped
|
|
105
|
+
* rather than stringified: the server has no listeners, and `onclick="function(){…}"` would ship
|
|
106
|
+
* a broken inline handler that looks like it works.
|
|
107
|
+
*/
|
|
108
|
+
export function attributePair(name: string, value: unknown): string | null {
|
|
109
|
+
if (NON_ATTRIBUTES.has(name)) return null;
|
|
110
|
+
if (value === undefined || value === null || value === false) return null;
|
|
111
|
+
if (typeof value === 'function') return null;
|
|
112
|
+
if (name.startsWith('on') && name.length > 2) return null;
|
|
113
|
+
|
|
114
|
+
const attribute = ATTRIBUTE_ALIASES[name] ?? name;
|
|
115
|
+
if (value === true) return attribute;
|
|
116
|
+
if (attribute === 'style') {
|
|
117
|
+
const style = styleValue(value);
|
|
118
|
+
return style === null ? null : `style="${escapeAttribute(style)}"`;
|
|
119
|
+
}
|
|
120
|
+
const text = String(value);
|
|
121
|
+
// Escaping makes a value inert inside the quotes; it cannot make a SCHEME inert, because
|
|
122
|
+
// `href="javascript:alert(1)"` never leaves them. One choke point for both, here, because this
|
|
123
|
+
// module is the single place injection is prevented and an href off a database row is the shape
|
|
124
|
+
// every app writes. A refused URL emits no attribute at all — an anchor with no `href` is inert
|
|
125
|
+
// and still renders its text, where a blanked one is a live link nobody checked.
|
|
126
|
+
if (URL_ATTRIBUTES.includes(attribute.toLowerCase())) {
|
|
127
|
+
const url = safeUrl(text, attribute.toLowerCase());
|
|
128
|
+
if (url === null) return null;
|
|
129
|
+
return `${attribute}="${escapeAttribute(url)}"`;
|
|
130
|
+
}
|
|
131
|
+
return `${attribute}="${escapeAttribute(text)}"`;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function renderAttributes(props: JsxProps): string {
|
|
135
|
+
const parts: string[] = [];
|
|
136
|
+
for (const [name, value] of Object.entries(props)) {
|
|
137
|
+
const pair = attributePair(name, value);
|
|
138
|
+
if (pair !== null) parts.push(pair);
|
|
139
|
+
}
|
|
140
|
+
return parts.length === 0 ? '' : ` ${parts.join(' ')}`;
|
|
141
|
+
}
|
package/src/hydrate.ts
CHANGED
|
@@ -5,10 +5,17 @@
|
|
|
5
5
|
* answered instead of swallowed.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import { escapeJsonContent } from './html';
|
|
8
9
|
import type { HydrateStrategy } from './route';
|
|
9
10
|
|
|
10
11
|
export interface IslandDirective {
|
|
12
|
+
/** Unique per INSTANCE: two of the same island on a page need two prop bags to find. */
|
|
11
13
|
readonly islandId: string;
|
|
14
|
+
/**
|
|
15
|
+
* The client entry this instance came from — the unit a bundle is measured in and a budget
|
|
16
|
+
* counts. Optional only because it arrived after `islandId`; `island()` always sets it.
|
|
17
|
+
*/
|
|
18
|
+
readonly moduleId?: string;
|
|
12
19
|
readonly strategy: HydrateStrategy;
|
|
13
20
|
/** Build-id-immutable module URL for this island's chunk. */
|
|
14
21
|
readonly entry: string;
|
|
@@ -40,12 +47,16 @@ export function emitIslandAttributes(directive: IslandDirective): string {
|
|
|
40
47
|
return attrs.join(' ');
|
|
41
48
|
}
|
|
42
49
|
|
|
43
|
-
/**
|
|
50
|
+
/**
|
|
51
|
+
* Props travel as a typed JSON script tag, never as an attribute (quoting hazards). The body is
|
|
52
|
+
* escaped by `html.ts`'s one JSON escaper — this file had its own partial copy, and a second
|
|
53
|
+
* escaper is how one of them ends up missing a character.
|
|
54
|
+
*/
|
|
44
55
|
export function emitIslandProps(directive: IslandDirective): string {
|
|
45
56
|
if (directive.props === undefined || directive.strategy === 'never') return '';
|
|
46
57
|
return (
|
|
47
58
|
`<script type="application/json" data-x-props="${directive.islandId}">` +
|
|
48
|
-
`${JSON.stringify(directive.props)
|
|
59
|
+
`${escapeJsonContent(JSON.stringify(directive.props))}</script>`
|
|
49
60
|
);
|
|
50
61
|
}
|
|
51
62
|
|
|
@@ -60,13 +71,18 @@ export function requiredStrategies(
|
|
|
60
71
|
return set;
|
|
61
72
|
}
|
|
62
73
|
|
|
74
|
+
// `el.__x` holds the boot PROMISE, not a boolean flag: it is still "already booting" for the
|
|
75
|
+
// once-only guard, and a second caller now waits for the same mount instead of being handed a
|
|
76
|
+
// resolved promise. As a flag, a second click during the chunk's load short-circuited to
|
|
77
|
+
// `Promise.resolve()`, and the interaction runtime flushed its replay queue into an island that
|
|
78
|
+
// had not mounted — the events went to nothing and the listeners were already removed.
|
|
63
79
|
const RUNTIME_PRELUDE = `
|
|
64
80
|
var Q={};
|
|
65
81
|
function boot(el){var e=el.getAttribute('data-x-entry');
|
|
66
|
-
if(!e
|
|
82
|
+
if(!e)return Promise.resolve();if(el.__x)return el.__x;
|
|
67
83
|
var p=document.querySelector('script[data-x-props="'+el.getAttribute('data-x-island')+'"]');
|
|
68
84
|
var props=p?JSON.parse(p.textContent||'{}'):{};
|
|
69
|
-
return import(e).then(function(m){return m.mount(el,props)})}
|
|
85
|
+
return el.__x=import(e).then(function(m){return m.mount(el,props)})}
|
|
70
86
|
function each(s,f){Array.prototype.forEach.call(document.querySelectorAll(s),f)}
|
|
71
87
|
`.trim();
|
|
72
88
|
|
package/src/index.ts
CHANGED
|
@@ -1,13 +1,29 @@
|
|
|
1
1
|
/** Public API of `@ultimat3/render`: the `route` primitive, the five modes, the table. */
|
|
2
2
|
|
|
3
|
+
import { installRenderLoader } from './module-loader';
|
|
4
|
+
|
|
5
|
+
// A side effect on import, deliberately: a Bun plugin only transforms modules loaded AFTER it, and
|
|
6
|
+
// every consumer that will ever load a `.tsx` route or a `.scss` module imports this package first
|
|
7
|
+
// (an app's route file imports `defineRoute` from here before it imports anything else it owns).
|
|
8
|
+
// Any later hook — `x dev`, `x build`, `server.ts` — would each have to remember, which is four
|
|
9
|
+
// places one fact can be wrong instead of none.
|
|
10
|
+
installRenderLoader();
|
|
11
|
+
|
|
12
|
+
export type { CompiledStylesheet } from './css-modules';
|
|
13
|
+
export { compileStylesheet, isCssModule, isGlobalStylesheet, scopeClasses } from './css-modules';
|
|
3
14
|
export type { RenderErrorCode } from './errors';
|
|
4
15
|
export {
|
|
5
16
|
BudgetExceededError,
|
|
17
|
+
IslandInvalidError,
|
|
18
|
+
IslandNotHydratedError,
|
|
19
|
+
IslandPropsInvalidError,
|
|
6
20
|
PrerenderFailedError,
|
|
7
21
|
RENDER_ERROR_CODES,
|
|
8
22
|
RENDER_ERROR_TITLES,
|
|
9
23
|
RouteDuplicateError,
|
|
10
24
|
RouteFileInvalidError,
|
|
25
|
+
RouteLoadFailedError,
|
|
26
|
+
RouteLoadInvalidError,
|
|
11
27
|
RouteMetaMissingError,
|
|
12
28
|
RouteModeInvalidError,
|
|
13
29
|
RouteOfflineMissingError,
|
|
@@ -22,6 +38,7 @@ export type {
|
|
|
22
38
|
ThemeScriptOptions,
|
|
23
39
|
} from './head';
|
|
24
40
|
export {
|
|
41
|
+
documentBaseline,
|
|
25
42
|
headFromMeta,
|
|
26
43
|
mergeHead,
|
|
27
44
|
renderHead,
|
|
@@ -38,6 +55,19 @@ export {
|
|
|
38
55
|
hydrateRuntimeBytes,
|
|
39
56
|
requiredStrategies,
|
|
40
57
|
} from './hydrate';
|
|
58
|
+
export type { IslandComponent, IslandDeclaration, IslandNode, IslandSpec } from './island';
|
|
59
|
+
export {
|
|
60
|
+
ISLAND_EXTENSION,
|
|
61
|
+
ISLAND_NODE,
|
|
62
|
+
isEmittableSpecifier,
|
|
63
|
+
isIslandNode,
|
|
64
|
+
island,
|
|
65
|
+
islandModuleId,
|
|
66
|
+
} from './island';
|
|
67
|
+
export type { IslandCollector, IslandCollectorInput } from './island-collector';
|
|
68
|
+
export { createIslandCollector, islandModuleIds } from './island-collector';
|
|
69
|
+
export type { IslandProps, JsonValue } from './island-props';
|
|
70
|
+
export { checkIslandProps, ISLAND_PROPS_MAX_BYTES } from './island-props';
|
|
41
71
|
export type { BudgetReport, BundleGraph, GraphName, Island, RouteBytes } from './islands';
|
|
42
72
|
export {
|
|
43
73
|
assertBudget,
|
|
@@ -48,14 +78,27 @@ export {
|
|
|
48
78
|
parseByteBudget,
|
|
49
79
|
routeJsBytes,
|
|
50
80
|
} from './islands';
|
|
81
|
+
export type { JsxComponent, JsxNode, JsxProps } from './jsx';
|
|
82
|
+
export { Fragment, h, isJsxNode, JSX_NODE } from './jsx';
|
|
51
83
|
export type { ModeCheckContext, ModeSpec, RouteShape } from './modes';
|
|
52
84
|
export {
|
|
53
85
|
assertModeInvariants,
|
|
54
86
|
assertModeShape,
|
|
87
|
+
DEFAULT_ISLAND_JS_BYTES,
|
|
55
88
|
defaultHydrate,
|
|
89
|
+
defaultIslandBudget,
|
|
56
90
|
MODE_SPECS,
|
|
57
91
|
RENDER_MODES,
|
|
58
92
|
} from './modes';
|
|
93
|
+
export type { Stylesheet } from './module-loader';
|
|
94
|
+
export {
|
|
95
|
+
clearStylesheets,
|
|
96
|
+
installRenderLoader,
|
|
97
|
+
loadStylesheet,
|
|
98
|
+
registeredStylesheets,
|
|
99
|
+
stylesFor,
|
|
100
|
+
transformTsx,
|
|
101
|
+
} from './module-loader';
|
|
59
102
|
export type {
|
|
60
103
|
CompiledPattern,
|
|
61
104
|
RegisterRouteInput,
|
|
@@ -75,6 +118,8 @@ export {
|
|
|
75
118
|
routeFor,
|
|
76
119
|
routePathFromFile,
|
|
77
120
|
} from './registry';
|
|
121
|
+
export type { RenderHtmlOptions } from './render-html';
|
|
122
|
+
export { renderComponent, renderToHtml } from './render-html';
|
|
78
123
|
export type {
|
|
79
124
|
IsrController,
|
|
80
125
|
IsrControllerOptions,
|
|
@@ -83,10 +128,11 @@ export type {
|
|
|
83
128
|
IsrServeResult,
|
|
84
129
|
IsrState,
|
|
85
130
|
IsrStore,
|
|
131
|
+
MemoryIsrStoreOptions,
|
|
86
132
|
} from './render-isr';
|
|
87
|
-
|
|
88
133
|
export {
|
|
89
134
|
createIsrController,
|
|
135
|
+
DEFAULT_ISR_MAX_ENTRIES,
|
|
90
136
|
invalidateAndRevalidate,
|
|
91
137
|
memoryIsrStore,
|
|
92
138
|
parseTtlMs,
|
|
@@ -108,6 +154,7 @@ export {
|
|
|
108
154
|
export type { StreamHole, StreamOptions, StreamPlan } from './render-stream';
|
|
109
155
|
export {
|
|
110
156
|
collectStream,
|
|
157
|
+
DEFAULT_HOLE_TIMEOUT_MS,
|
|
111
158
|
holeId,
|
|
112
159
|
holeMarker,
|
|
113
160
|
REVEAL_SCRIPT,
|
|
@@ -117,6 +164,7 @@ export {
|
|
|
117
164
|
} from './render-stream';
|
|
118
165
|
export type {
|
|
119
166
|
HydrateStrategy,
|
|
167
|
+
LoadRequirement,
|
|
120
168
|
OfflineStrategy,
|
|
121
169
|
PrerenderFn,
|
|
122
170
|
RenderMode,
|
|
@@ -124,20 +172,28 @@ export type {
|
|
|
124
172
|
RevalidateConfig,
|
|
125
173
|
RouteBudget,
|
|
126
174
|
RouteConfig,
|
|
175
|
+
RouteContext,
|
|
127
176
|
RouteData,
|
|
128
177
|
RouteDefinition,
|
|
129
178
|
RouteGuard,
|
|
179
|
+
RouteLoadAsyncFn,
|
|
180
|
+
RouteLoadFn,
|
|
130
181
|
RouteMetaAsyncFn,
|
|
182
|
+
RouteMetaContext,
|
|
131
183
|
RouteMetaFn,
|
|
132
184
|
RouteParams,
|
|
133
185
|
} from './route';
|
|
134
186
|
export {
|
|
187
|
+
DEFAULT_ISLAND_HYDRATE,
|
|
135
188
|
defineRoute,
|
|
136
189
|
HYDRATE_STRATEGIES,
|
|
137
190
|
isRouteConfig,
|
|
138
191
|
OFFLINE_STRATEGIES,
|
|
139
192
|
tagKeys,
|
|
140
193
|
} from './route';
|
|
194
|
+
export type { RouteComponent } from './route-component';
|
|
195
|
+
export { pageComponentOf } from './route-component';
|
|
196
|
+
export { metaContextFor, routeDataFor } from './route-data';
|
|
141
197
|
export type {
|
|
142
198
|
NavigateOptions,
|
|
143
199
|
NavigationGuard,
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What one render pulled in. The collector is where an island's timing, its budget and its
|
|
3
|
+
* failure to have either come from the ROUTE rather than from the island — so `hydrate` stays the
|
|
4
|
+
* one place a route says it ships JavaScript, and the directives stay a per-render fact.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { IslandInvalidError, IslandNotHydratedError } from './errors';
|
|
8
|
+
import type { IslandDirective } from './hydrate';
|
|
9
|
+
import { DEFAULT_REPLAY_EVENTS } from './hydrate';
|
|
10
|
+
import type { IslandSpec } from './island';
|
|
11
|
+
import { isEmittableSpecifier, islandNeverDrained } from './island';
|
|
12
|
+
import type { IslandProps } from './island-props';
|
|
13
|
+
import { checkIslandProps } from './island-props';
|
|
14
|
+
import type { JsxProps } from './jsx';
|
|
15
|
+
import type { HydrateStrategy } from './route';
|
|
16
|
+
|
|
17
|
+
/** The distinct client entries a rendered page pulled in — one per module, however many instances. */
|
|
18
|
+
export function islandModuleIds(directives: readonly IslandDirective[]): readonly string[] {
|
|
19
|
+
const ids = new Set<string>();
|
|
20
|
+
for (const directive of directives) ids.add(directive.moduleId ?? directive.islandId);
|
|
21
|
+
return [...ids];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface IslandCollectorInput {
|
|
25
|
+
/** The route file, so every failure names the file an author has to open. */
|
|
26
|
+
readonly file: string;
|
|
27
|
+
/** The route's `hydrate`. The island inherits it; it never declares one of its own. */
|
|
28
|
+
readonly hydrate: HydrateStrategy;
|
|
29
|
+
/** Specifier → built chunk URL. Identity in dev and in tests; the build supplies the real one. */
|
|
30
|
+
readonly resolve?: (src: string) => string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Collects what a single render pulled in. Per render, never module-global: two requests render
|
|
35
|
+
* different params and a shared collector would bill one page for the other's islands.
|
|
36
|
+
*/
|
|
37
|
+
export interface IslandCollector {
|
|
38
|
+
readonly file: string;
|
|
39
|
+
readonly hydrate: HydrateStrategy;
|
|
40
|
+
readonly directives: readonly IslandDirective[];
|
|
41
|
+
/** Called by the tree walker once per island instance. Returns what the markup is built from. */
|
|
42
|
+
record(spec: IslandSpec, props: JsxProps): IslandDirective;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function createIslandCollector(input: IslandCollectorInput): IslandCollector {
|
|
46
|
+
const directives: IslandDirective[] = [];
|
|
47
|
+
const entries = new Map<string, string>();
|
|
48
|
+
const resolve = input.resolve ?? ((src: string) => src);
|
|
49
|
+
const strategy = input.hydrate;
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
file: input.file,
|
|
53
|
+
hydrate: strategy,
|
|
54
|
+
get directives(): readonly IslandDirective[] {
|
|
55
|
+
return directives;
|
|
56
|
+
},
|
|
57
|
+
record(spec: IslandSpec, props: JsxProps): IslandDirective {
|
|
58
|
+
assertHydrates(strategy, spec, input.file);
|
|
59
|
+
|
|
60
|
+
const entry = resolve(spec.src);
|
|
61
|
+
assertEntry(entry, spec, input.file);
|
|
62
|
+
const claimed = entries.get(spec.moduleId);
|
|
63
|
+
if (claimed !== undefined && claimed !== entry) {
|
|
64
|
+
throw new IslandInvalidError(
|
|
65
|
+
`two islands on ${input.file} both resolve to the id ${spec.moduleId} (${claimed} and ` +
|
|
66
|
+
`${entry}), so their props would be handed to whichever the browser found first`,
|
|
67
|
+
`rename one of the modules — the id is its filename, so two islands need two names`,
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
entries.set(spec.moduleId, entry);
|
|
71
|
+
|
|
72
|
+
const bag = checkIslandProps(props, spec.propKeys, input.file, spec.moduleId);
|
|
73
|
+
const instance = directives.filter((d) => d.moduleId === spec.moduleId).length + 1;
|
|
74
|
+
const directive = buildDirective(spec, strategy, entry, `${spec.moduleId}-${instance}`, bag);
|
|
75
|
+
directives.push(directive);
|
|
76
|
+
return directive;
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function buildDirective(
|
|
82
|
+
spec: IslandSpec,
|
|
83
|
+
strategy: HydrateStrategy,
|
|
84
|
+
entry: string,
|
|
85
|
+
islandId: string,
|
|
86
|
+
props: IslandProps,
|
|
87
|
+
): IslandDirective {
|
|
88
|
+
// Replay defaults to every event a shell can receive: losing the first keystroke in a search box
|
|
89
|
+
// is the same failure as losing the first click, and only the declaration knows which shell it is.
|
|
90
|
+
const events = strategy === 'interaction' ? (spec.events ?? DEFAULT_REPLAY_EVENTS) : spec.events;
|
|
91
|
+
return {
|
|
92
|
+
islandId,
|
|
93
|
+
moduleId: spec.moduleId,
|
|
94
|
+
strategy,
|
|
95
|
+
entry,
|
|
96
|
+
...(Object.keys(props).length === 0 ? {} : { props }),
|
|
97
|
+
...(events === undefined ? {} : { events }),
|
|
98
|
+
...(spec.rootMargin === undefined ? {} : { rootMargin: spec.rootMargin }),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* An island on a route that ships no JS is a button that does nothing — and it is also how the
|
|
104
|
+
* budget stops meaning anything, because `hydrate: 'never'` is what excuses a `site/` route from
|
|
105
|
+
* declaring `budget.js` at all.
|
|
106
|
+
*
|
|
107
|
+
* Still reachable with `hydrate` derived, and for exactly two reasons — so the `fix:` names ONE.
|
|
108
|
+
* `islandNeverDrained` is what tells them apart: a spec still pending at render time was declared
|
|
109
|
+
* where no `defineRoute` could see it, and a spec already drained means an author wrote `'never'`.
|
|
110
|
+
* Offering both edits would make half the instruction wrong for every reader, and leave working
|
|
111
|
+
* out which half is theirs as the reader's job — which is the opposite of axiom 4.
|
|
112
|
+
*/
|
|
113
|
+
function assertHydrates(strategy: HydrateStrategy, spec: IslandSpec, file: string): void {
|
|
114
|
+
if (strategy !== 'never') return;
|
|
115
|
+
// Only on the failure path: the happy path returned above and never touches the list.
|
|
116
|
+
const undrained = islandNeverDrained(spec);
|
|
117
|
+
const cause = undrained
|
|
118
|
+
? `no defineRoute in that module drained the ${spec.moduleId} declaration and the route ` +
|
|
119
|
+
"derived hydrate: 'never'"
|
|
120
|
+
: `the route declares hydrate: 'never'`;
|
|
121
|
+
throw new IslandNotHydratedError(
|
|
122
|
+
`${file} renders the ${spec.moduleId} island but ${cause}, so the browser would receive its ` +
|
|
123
|
+
'markup and never the JavaScript that makes it do anything',
|
|
124
|
+
undrained
|
|
125
|
+
? `move the island() call for ${spec.moduleId} above defineRoute in ${file} — a page that ` +
|
|
126
|
+
'declares an island hydrates on its own'
|
|
127
|
+
: `remove hydrate: 'never' from ${file} — a page that declares an island hydrates on its own`,
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function assertEntry(entry: string, spec: IslandSpec, file: string): void {
|
|
132
|
+
if (isEmittableSpecifier(entry)) return;
|
|
133
|
+
throw new IslandInvalidError(
|
|
134
|
+
`the resolver returned ${JSON.stringify(entry)} for the ${spec.moduleId} island in ${file}, ` +
|
|
135
|
+
'which cannot be emitted as a module URL',
|
|
136
|
+
'fix the resolve() passed to createIslandCollector — it must return a plain URL path',
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Thrown from the walker when an island is rendered with nothing to boot it. */
|
|
141
|
+
export function islandWithoutCollector(spec: IslandSpec): IslandNotHydratedError {
|
|
142
|
+
return new IslandNotHydratedError(
|
|
143
|
+
`the ${spec.moduleId} island was rendered outside a render that collects islands, so no ` +
|
|
144
|
+
'hydration runtime is emitted and its chunk is never requested',
|
|
145
|
+
'render the page through renderToHtml(tree, { islands: createIslandCollector({ file, ' +
|
|
146
|
+
'hydrate }) }) so the island is counted, booted and budgeted',
|
|
147
|
+
);
|
|
148
|
+
}
|