@bleedingdev/modern-js-renderer-core 0.0.0-bootstrap → 3.9.3-ultramodern.1
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/LICENSE +21 -0
- package/dist/cjs/abort.js +65 -0
- package/dist/cjs/adapter.js +62 -0
- package/dist/cjs/data/body.js +78 -0
- package/dist/cjs/data/client.js +409 -0
- package/dist/cjs/data/codec.js +168 -0
- package/dist/cjs/data/index.js +125 -0
- package/dist/cjs/data/private.js +77 -0
- package/dist/cjs/data/routes.js +149 -0
- package/dist/cjs/data/server.js +791 -0
- package/dist/cjs/data/static.js +110 -0
- package/dist/cjs/data/tree.js +231 -0
- package/dist/cjs/data/types.js +56 -0
- package/dist/cjs/document.js +184 -0
- package/dist/cjs/entry-client.js +142 -0
- package/dist/cjs/entry-server.js +253 -0
- package/dist/cjs/federation.js +80 -0
- package/dist/cjs/identity.js +120 -0
- package/dist/cjs/index.js +46 -0
- package/dist/cjs/localization.js +48 -0
- package/dist/cjs/router.js +293 -0
- package/dist/cjs/server/assets.js +56 -0
- package/dist/cjs/server/dispatch.js +351 -0
- package/dist/cjs/server/index.js +57 -0
- package/dist/cjs/server/types.js +18 -0
- package/dist/cjs/server/worker.js +73 -0
- package/dist/cjs/session/cache.js +172 -0
- package/dist/cjs/session/index.js +63 -0
- package/dist/cjs/session/request.js +269 -0
- package/dist/cjs/session/types.js +18 -0
- package/dist/esm/abort.mjs +27 -0
- package/dist/esm/adapter.mjs +21 -0
- package/dist/esm/data/body.mjs +40 -0
- package/dist/esm/data/client.mjs +368 -0
- package/dist/esm/data/codec.mjs +112 -0
- package/dist/esm/data/index.mjs +7 -0
- package/dist/esm/data/private.mjs +39 -0
- package/dist/esm/data/routes.mjs +108 -0
- package/dist/esm/data/server.mjs +723 -0
- package/dist/esm/data/static.mjs +51 -0
- package/dist/esm/data/tree.mjs +193 -0
- package/dist/esm/data/types.mjs +6 -0
- package/dist/esm/document.mjs +131 -0
- package/dist/esm/entry-client.mjs +104 -0
- package/dist/esm/entry-server.mjs +215 -0
- package/dist/esm/federation.mjs +36 -0
- package/dist/esm/identity.mjs +63 -0
- package/dist/esm/index.mjs +1 -0
- package/dist/esm/localization.mjs +10 -0
- package/dist/esm/router.mjs +225 -0
- package/dist/esm/server/assets.mjs +15 -0
- package/dist/esm/server/dispatch.mjs +307 -0
- package/dist/esm/server/index.mjs +3 -0
- package/dist/esm/server/types.mjs +0 -0
- package/dist/esm/server/worker.mjs +35 -0
- package/dist/esm/session/cache.mjs +116 -0
- package/dist/esm/session/index.mjs +3 -0
- package/dist/esm/session/request.mjs +231 -0
- package/dist/esm/session/types.mjs +0 -0
- package/dist/esm-node/abort.mjs +28 -0
- package/dist/esm-node/adapter.mjs +22 -0
- package/dist/esm-node/data/body.mjs +41 -0
- package/dist/esm-node/data/client.mjs +369 -0
- package/dist/esm-node/data/codec.mjs +113 -0
- package/dist/esm-node/data/index.mjs +8 -0
- package/dist/esm-node/data/private.mjs +40 -0
- package/dist/esm-node/data/routes.mjs +109 -0
- package/dist/esm-node/data/server.mjs +724 -0
- package/dist/esm-node/data/static.mjs +52 -0
- package/dist/esm-node/data/tree.mjs +194 -0
- package/dist/esm-node/data/types.mjs +7 -0
- package/dist/esm-node/document.mjs +132 -0
- package/dist/esm-node/entry-client.mjs +105 -0
- package/dist/esm-node/entry-server.mjs +216 -0
- package/dist/esm-node/federation.mjs +37 -0
- package/dist/esm-node/identity.mjs +64 -0
- package/dist/esm-node/index.mjs +2 -0
- package/dist/esm-node/localization.mjs +11 -0
- package/dist/esm-node/router.mjs +226 -0
- package/dist/esm-node/server/assets.mjs +16 -0
- package/dist/esm-node/server/dispatch.mjs +308 -0
- package/dist/esm-node/server/index.mjs +4 -0
- package/dist/esm-node/server/types.mjs +1 -0
- package/dist/esm-node/server/worker.mjs +36 -0
- package/dist/esm-node/session/cache.mjs +117 -0
- package/dist/esm-node/session/index.mjs +4 -0
- package/dist/esm-node/session/request.mjs +232 -0
- package/dist/esm-node/session/types.mjs +1 -0
- package/dist/types/abort.d.ts +6 -0
- package/dist/types/adapter.d.ts +195 -0
- package/dist/types/data/body.d.ts +2 -0
- package/dist/types/data/client.d.ts +17 -0
- package/dist/types/data/codec.d.ts +14 -0
- package/dist/types/data/index.d.ts +8 -0
- package/dist/types/data/private.d.ts +6 -0
- package/dist/types/data/routes.d.ts +28 -0
- package/dist/types/data/server.d.ts +59 -0
- package/dist/types/data/static.d.ts +31 -0
- package/dist/types/data/tree.d.ts +3 -0
- package/dist/types/data/types.d.ts +104 -0
- package/dist/types/document.d.ts +76 -0
- package/dist/types/entry-client.d.ts +53 -0
- package/dist/types/entry-server.d.ts +63 -0
- package/dist/types/federation.d.ts +56 -0
- package/dist/types/identity.d.ts +27 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/localization.d.ts +58 -0
- package/dist/types/router.d.ts +201 -0
- package/dist/types/server/assets.d.ts +14 -0
- package/dist/types/server/dispatch.d.ts +11 -0
- package/dist/types/server/index.d.ts +4 -0
- package/dist/types/server/types.d.ts +64 -0
- package/dist/types/server/worker.d.ts +25 -0
- package/dist/types/session/cache.d.ts +25 -0
- package/dist/types/session/index.d.ts +4 -0
- package/dist/types/session/request.d.ts +8 -0
- package/dist/types/session/types.d.ts +48 -0
- package/package.json +159 -4
- package/src/abort.ts +40 -0
- package/src/adapter.ts +255 -0
- package/src/data/body.ts +43 -0
- package/src/data/client.ts +501 -0
- package/src/data/codec.ts +247 -0
- package/src/data/index.ts +8 -0
- package/src/data/private.ts +68 -0
- package/src/data/routes.ts +201 -0
- package/src/data/server.ts +977 -0
- package/src/data/static.ts +105 -0
- package/src/data/tree.ts +244 -0
- package/src/data/types.ts +98 -0
- package/src/document.ts +338 -0
- package/src/entry-client.ts +236 -0
- package/src/entry-server.ts +393 -0
- package/src/federation.ts +111 -0
- package/src/identity.ts +128 -0
- package/src/index.ts +7 -0
- package/src/localization.ts +77 -0
- package/src/router.ts +475 -0
- package/src/server/assets.ts +60 -0
- package/src/server/dispatch.ts +523 -0
- package/src/server/index.ts +16 -0
- package/src/server/types.ts +94 -0
- package/src/server/worker.ts +94 -0
- package/src/session/cache.ts +177 -0
- package/src/session/index.ts +11 -0
- package/src/session/request.ts +324 -0
- package/src/session/types.ts +60 -0
package/src/document.ts
ADDED
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
import { escapeInlineDataJSON } from './data/codec';
|
|
2
|
+
import { type RendererIdentity, readRendererIdentity } from './identity';
|
|
3
|
+
|
|
4
|
+
export interface DocumentAsset {
|
|
5
|
+
readonly kind: 'stylesheet' | 'modulepreload' | 'script';
|
|
6
|
+
readonly href: string;
|
|
7
|
+
readonly integrity?: string;
|
|
8
|
+
readonly crossOrigin?: 'anonymous' | 'use-credentials';
|
|
9
|
+
readonly scriptType?: 'module' | 'classic';
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function escapeAttribute(value: string): string {
|
|
13
|
+
return value
|
|
14
|
+
.replaceAll('&', '&')
|
|
15
|
+
.replaceAll('"', '"')
|
|
16
|
+
.replaceAll('<', '<')
|
|
17
|
+
.replaceAll('>', '>');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function validateAsset(asset: DocumentAsset): void {
|
|
21
|
+
if (!['stylesheet', 'modulepreload', 'script'].includes(asset.kind)) {
|
|
22
|
+
throw new TypeError('A document asset has an unknown kind.');
|
|
23
|
+
}
|
|
24
|
+
if (
|
|
25
|
+
typeof asset.href !== 'string' ||
|
|
26
|
+
asset.href.length === 0 ||
|
|
27
|
+
asset.href.trim() !== asset.href ||
|
|
28
|
+
/[\u0000-\u0020\u007f]/u.test(asset.href)
|
|
29
|
+
) {
|
|
30
|
+
throw new TypeError(
|
|
31
|
+
'A document asset requires a nonempty URL without whitespace or control characters.',
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
const url = new URL(asset.href, 'https://ultramodern.invalid/');
|
|
35
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
|
36
|
+
throw new TypeError(
|
|
37
|
+
'A document asset must use an HTTP URL or relative URL.',
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
if (
|
|
41
|
+
asset.crossOrigin !== undefined &&
|
|
42
|
+
asset.crossOrigin !== 'anonymous' &&
|
|
43
|
+
asset.crossOrigin !== 'use-credentials'
|
|
44
|
+
) {
|
|
45
|
+
throw new TypeError('A document asset has an unknown crossOrigin value.');
|
|
46
|
+
}
|
|
47
|
+
if (
|
|
48
|
+
asset.scriptType !== undefined &&
|
|
49
|
+
(asset.kind !== 'script' ||
|
|
50
|
+
!['module', 'classic'].includes(asset.scriptType))
|
|
51
|
+
) {
|
|
52
|
+
throw new TypeError(
|
|
53
|
+
'Only a script asset can declare module or classic scriptType.',
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Preserve the native adapter's order. Equal asset claims collapse; conflicts fail. */
|
|
59
|
+
export function collectDocumentAssets(
|
|
60
|
+
assets: readonly DocumentAsset[],
|
|
61
|
+
): readonly DocumentAsset[] {
|
|
62
|
+
const collected = new Map<string, DocumentAsset>();
|
|
63
|
+
for (const asset of assets) {
|
|
64
|
+
validateAsset(asset);
|
|
65
|
+
const key = JSON.stringify([asset.kind, asset.href]);
|
|
66
|
+
const existing = collected.get(key);
|
|
67
|
+
if (existing) {
|
|
68
|
+
if (
|
|
69
|
+
existing.integrity !== asset.integrity ||
|
|
70
|
+
existing.crossOrigin !== asset.crossOrigin ||
|
|
71
|
+
(existing.scriptType ?? 'module') !== (asset.scriptType ?? 'module')
|
|
72
|
+
) {
|
|
73
|
+
throw new Error(
|
|
74
|
+
`Conflicting document asset metadata for ${asset.href}.`,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
collected.set(key, Object.freeze({ ...asset }));
|
|
80
|
+
}
|
|
81
|
+
return Object.freeze([...collected.values()]);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function nonceAttribute(nonce: string | undefined): string {
|
|
85
|
+
if (nonce === undefined) return '';
|
|
86
|
+
if (typeof nonce !== 'string' || nonce.length === 0) {
|
|
87
|
+
throw new TypeError('A document nonce must be a nonempty string.');
|
|
88
|
+
}
|
|
89
|
+
return ` nonce="${escapeAttribute(nonce)}"`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Serialize asset metadata only. The native renderer decides its document position. */
|
|
93
|
+
export function serializeDocumentAsset(
|
|
94
|
+
asset: DocumentAsset,
|
|
95
|
+
nonce?: string,
|
|
96
|
+
options: { readonly async?: boolean; readonly defer?: boolean } = {},
|
|
97
|
+
): string {
|
|
98
|
+
validateAsset(asset);
|
|
99
|
+
if (
|
|
100
|
+
options.async !== undefined &&
|
|
101
|
+
(asset.kind !== 'script' || typeof options.async !== 'boolean')
|
|
102
|
+
) {
|
|
103
|
+
throw new TypeError(
|
|
104
|
+
'Async scheduling is only available for a script asset.',
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
if (
|
|
108
|
+
options.defer !== undefined &&
|
|
109
|
+
(asset.kind !== 'script' || typeof options.defer !== 'boolean')
|
|
110
|
+
) {
|
|
111
|
+
throw new TypeError(
|
|
112
|
+
'Deferred scheduling is only available for a script asset.',
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
if (options.async === true && options.defer === true) {
|
|
116
|
+
throw new TypeError('A script asset cannot be both async and deferred.');
|
|
117
|
+
}
|
|
118
|
+
const url = escapeAttribute(asset.href);
|
|
119
|
+
const integrity =
|
|
120
|
+
asset.integrity === undefined
|
|
121
|
+
? ''
|
|
122
|
+
: ` integrity="${escapeAttribute(asset.integrity)}"`;
|
|
123
|
+
const crossOrigin =
|
|
124
|
+
asset.crossOrigin === undefined
|
|
125
|
+
? ''
|
|
126
|
+
: ` crossorigin="${asset.crossOrigin}"`;
|
|
127
|
+
const attributes = `${integrity}${crossOrigin}${nonceAttribute(nonce)}`;
|
|
128
|
+
if (asset.kind === 'script') {
|
|
129
|
+
const scriptType = asset.scriptType ?? 'module';
|
|
130
|
+
const type = scriptType === 'module' ? ' type="module"' : '';
|
|
131
|
+
const scheduling = options.async
|
|
132
|
+
? ' async'
|
|
133
|
+
: options.defer === true ||
|
|
134
|
+
(scriptType === 'classic' && options.defer !== false)
|
|
135
|
+
? ' defer'
|
|
136
|
+
: '';
|
|
137
|
+
return `<script${type}${scheduling} src="${url}"${attributes}></script>`;
|
|
138
|
+
}
|
|
139
|
+
return `<link rel="${asset.kind}" href="${url}"${attributes}>`;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Frame JSON text as an inert `<script type="application/json">` element.
|
|
144
|
+
* This is the one place inline document data is escaped for HTML.
|
|
145
|
+
*/
|
|
146
|
+
export function serializeInlineData(input: {
|
|
147
|
+
readonly id: string;
|
|
148
|
+
/** JSON text, such as `serializePublicData` output or `JSON.stringify`. */
|
|
149
|
+
readonly payload: string;
|
|
150
|
+
readonly nonce?: string;
|
|
151
|
+
}): string {
|
|
152
|
+
if (typeof input.id !== 'string' || input.id.length === 0) {
|
|
153
|
+
throw new TypeError('An inline data element requires a nonempty id.');
|
|
154
|
+
}
|
|
155
|
+
const payload = escapeInlineDataJSON(input.payload);
|
|
156
|
+
return `<script type="application/json" id="${escapeAttribute(input.id)}"${nonceAttribute(input.nonce)}>${payload}</script>`;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Element id of the renderer bootstrap every native client reads first. */
|
|
160
|
+
export const RENDERER_BOOTSTRAP_ID = '__ULTRAMODERN_RENDERER__';
|
|
161
|
+
|
|
162
|
+
/** What the server tells the client about the document before it starts. */
|
|
163
|
+
export interface DocumentBootstrap {
|
|
164
|
+
readonly identity: Readonly<RendererIdentity>;
|
|
165
|
+
/** The renderer's hydration/render id for this response. */
|
|
166
|
+
readonly documentId: string;
|
|
167
|
+
readonly hydrating: boolean;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Public JSON a client module reads from the document before it starts. */
|
|
171
|
+
export interface DocumentInlineData {
|
|
172
|
+
readonly id: string;
|
|
173
|
+
/** JSON text; `serializeInlineData` escapes it for HTML. */
|
|
174
|
+
readonly payload: string;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export interface DocumentNonce {
|
|
178
|
+
readonly script?: string;
|
|
179
|
+
readonly style?: string;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export interface DocumentOptions {
|
|
183
|
+
readonly rootId?: string;
|
|
184
|
+
readonly lang?: string;
|
|
185
|
+
readonly nonce?: DocumentNonce;
|
|
186
|
+
readonly assets?: readonly DocumentAsset[];
|
|
187
|
+
/** Placed right after the renderer bootstrap, before entry scripts. */
|
|
188
|
+
readonly inlineData?: readonly DocumentInlineData[];
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** HTML fragments a native adapter places in its own document shell. */
|
|
192
|
+
export interface DocumentParts {
|
|
193
|
+
/** Escaped attribute values. */
|
|
194
|
+
readonly rootId: string;
|
|
195
|
+
readonly lang: string;
|
|
196
|
+
/** Stylesheets and module preloads. */
|
|
197
|
+
readonly headAssets: string;
|
|
198
|
+
/** The renderer bootstrap followed by the inline data elements. */
|
|
199
|
+
readonly bootstrap: string;
|
|
200
|
+
/** Entry scripts, scheduled so hydration starts before the stream ends. */
|
|
201
|
+
readonly entryScripts: string;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function documentAttribute(value: unknown, name: string): string {
|
|
205
|
+
if (typeof value !== 'string' || !value.trim()) {
|
|
206
|
+
throw new TypeError(`A native document requires a nonempty ${name}.`);
|
|
207
|
+
}
|
|
208
|
+
return escapeAttribute(value);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Prepare the renderer-neutral parts of a native HTML document: assets, the
|
|
213
|
+
* renderer bootstrap and inline data. `extra` adds renderer-specific bootstrap
|
|
214
|
+
* fields, which the client reads with `readDocumentBootstrap`.
|
|
215
|
+
*/
|
|
216
|
+
export function prepareDocument(
|
|
217
|
+
bootstrap: DocumentBootstrap & Readonly<Record<string, unknown>>,
|
|
218
|
+
options: DocumentOptions = {},
|
|
219
|
+
): DocumentParts {
|
|
220
|
+
if (
|
|
221
|
+
typeof bootstrap.documentId !== 'string' ||
|
|
222
|
+
!bootstrap.documentId.trim()
|
|
223
|
+
) {
|
|
224
|
+
throw new TypeError('A native document requires a nonempty document id.');
|
|
225
|
+
}
|
|
226
|
+
if (typeof bootstrap.hydrating !== 'boolean') {
|
|
227
|
+
throw new TypeError(
|
|
228
|
+
'A native document must identify whether its root requires hydration.',
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
readRendererIdentity(bootstrap.identity, bootstrap.identity);
|
|
232
|
+
const rootId = options.rootId ?? 'root';
|
|
233
|
+
const root = documentAttribute(rootId, 'rootId');
|
|
234
|
+
const lang = documentAttribute(options.lang ?? 'en', 'lang');
|
|
235
|
+
if (rootId === RENDERER_BOOTSTRAP_ID) {
|
|
236
|
+
throw new TypeError('A document root cannot reuse the bootstrap id.');
|
|
237
|
+
}
|
|
238
|
+
const { script, style } = options.nonce ?? {};
|
|
239
|
+
const inlineIds = new Set<string>();
|
|
240
|
+
const inlineData = (options.inlineData ?? []).map(item => {
|
|
241
|
+
if (item.id === RENDERER_BOOTSTRAP_ID || item.id === rootId) {
|
|
242
|
+
throw new TypeError(
|
|
243
|
+
'Document inline data cannot reuse the root or bootstrap id.',
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
// Readers use getElementById, so a repeated id hides one payload.
|
|
247
|
+
if (inlineIds.has(item.id))
|
|
248
|
+
throw new TypeError(`Document inline data id ${item.id} is repeated.`);
|
|
249
|
+
inlineIds.add(item.id);
|
|
250
|
+
return serializeInlineData({ ...item, nonce: script });
|
|
251
|
+
});
|
|
252
|
+
const assets = collectDocumentAssets(options.assets ?? []);
|
|
253
|
+
return {
|
|
254
|
+
rootId: root,
|
|
255
|
+
lang,
|
|
256
|
+
headAssets: assets
|
|
257
|
+
.filter(asset => asset.kind !== 'script')
|
|
258
|
+
.map(asset =>
|
|
259
|
+
serializeDocumentAsset(
|
|
260
|
+
asset,
|
|
261
|
+
asset.kind === 'stylesheet' ? style : script,
|
|
262
|
+
),
|
|
263
|
+
)
|
|
264
|
+
.join(''),
|
|
265
|
+
bootstrap:
|
|
266
|
+
serializeInlineData({
|
|
267
|
+
id: RENDERER_BOOTSTRAP_ID,
|
|
268
|
+
payload: JSON.stringify(bootstrap),
|
|
269
|
+
nonce: script,
|
|
270
|
+
}) + inlineData.join(''),
|
|
271
|
+
entryScripts: assets
|
|
272
|
+
.filter(asset => asset.kind === 'script')
|
|
273
|
+
.map(asset =>
|
|
274
|
+
serializeDocumentAsset(
|
|
275
|
+
asset,
|
|
276
|
+
script,
|
|
277
|
+
// Hydration starts while deferred native fragments keep the stream
|
|
278
|
+
// open. Classic chunks keep their manifest (parser) order.
|
|
279
|
+
!bootstrap.hydrating
|
|
280
|
+
? {}
|
|
281
|
+
: asset.scriptType === 'classic'
|
|
282
|
+
? { defer: false }
|
|
283
|
+
: { async: true },
|
|
284
|
+
),
|
|
285
|
+
)
|
|
286
|
+
.join(''),
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Read the server's bootstrap before creating a native router or owner. The
|
|
292
|
+
* identity must equal the client's build identity. Renderer-specific fields
|
|
293
|
+
* must be named in `extraFields` and are returned for the adapter to check.
|
|
294
|
+
*/
|
|
295
|
+
export function readDocumentBootstrap(
|
|
296
|
+
document: Pick<Document, 'querySelectorAll'>,
|
|
297
|
+
expectedIdentity: RendererIdentity,
|
|
298
|
+
extraFields: readonly string[] = [],
|
|
299
|
+
): DocumentBootstrap & Readonly<Record<string, unknown>> {
|
|
300
|
+
const elements = document.querySelectorAll(`[id="${RENDERER_BOOTSTRAP_ID}"]`);
|
|
301
|
+
if (elements.length !== 1) {
|
|
302
|
+
throw new Error(
|
|
303
|
+
'A native document requires exactly one renderer bootstrap.',
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
const element = elements.item(0);
|
|
307
|
+
if (
|
|
308
|
+
!element ||
|
|
309
|
+
element.tagName !== 'SCRIPT' ||
|
|
310
|
+
element.getAttribute('type') !== 'application/json'
|
|
311
|
+
) {
|
|
312
|
+
throw new Error('The native document is missing its renderer bootstrap.');
|
|
313
|
+
}
|
|
314
|
+
const payload: unknown = JSON.parse(element.textContent ?? '');
|
|
315
|
+
if (
|
|
316
|
+
!payload ||
|
|
317
|
+
typeof payload !== 'object' ||
|
|
318
|
+
Array.isArray(payload) ||
|
|
319
|
+
Object.keys(payload).some(
|
|
320
|
+
key =>
|
|
321
|
+
!['identity', 'documentId', 'hydrating', ...extraFields].includes(key),
|
|
322
|
+
)
|
|
323
|
+
) {
|
|
324
|
+
throw new Error('The native renderer bootstrap is malformed.');
|
|
325
|
+
}
|
|
326
|
+
const fields = payload as Record<string, unknown>;
|
|
327
|
+
const identity = readRendererIdentity(fields.identity, expectedIdentity);
|
|
328
|
+
if (typeof fields.documentId !== 'string' || !fields.documentId.trim()) {
|
|
329
|
+
throw new Error('The native renderer bootstrap requires a document id.');
|
|
330
|
+
}
|
|
331
|
+
if (typeof fields.hydrating !== 'boolean') {
|
|
332
|
+
throw new Error(
|
|
333
|
+
'The native renderer bootstrap must say whether its root hydrates.',
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
return Object.freeze({ ...fields, identity }) as DocumentBootstrap &
|
|
337
|
+
Readonly<Record<string, unknown>>;
|
|
338
|
+
}
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import { createDataClient, invokeRouteData } from './data';
|
|
2
|
+
import { type DocumentBootstrap, RENDERER_BOOTSTRAP_ID } from './document';
|
|
3
|
+
import type { NativeFederationBinding } from './federation';
|
|
4
|
+
import type { RendererIdentity } from './identity';
|
|
5
|
+
import {
|
|
6
|
+
type NativeEntryI18n,
|
|
7
|
+
type NativeI18nInstance,
|
|
8
|
+
type NativeI18nView,
|
|
9
|
+
nativeI18nView,
|
|
10
|
+
} from './localization';
|
|
11
|
+
import {
|
|
12
|
+
isRoutedApplication,
|
|
13
|
+
type NativeApplicationModule,
|
|
14
|
+
type NativeRoutedApplication,
|
|
15
|
+
type NativeRouterOptions,
|
|
16
|
+
} from './router';
|
|
17
|
+
|
|
18
|
+
export type * from './localization';
|
|
19
|
+
export type {
|
|
20
|
+
NativeApplicationModule,
|
|
21
|
+
NativeRoutedApplication,
|
|
22
|
+
NativeRouterOptions,
|
|
23
|
+
} from './router';
|
|
24
|
+
|
|
25
|
+
import { untilAborted } from './abort';
|
|
26
|
+
|
|
27
|
+
/** What a generated client entry passes to its renderer. */
|
|
28
|
+
export interface NativeClientEntryOptions<
|
|
29
|
+
Instance extends NativeI18nInstance = NativeI18nInstance,
|
|
30
|
+
LocalisedUrls = unknown,
|
|
31
|
+
> {
|
|
32
|
+
readonly identity: RendererIdentity;
|
|
33
|
+
readonly federation?: NativeFederationBinding;
|
|
34
|
+
/** The generated application module, imported after startup begins. */
|
|
35
|
+
readonly load: () => Promise<NativeApplicationModule>;
|
|
36
|
+
readonly i18n?: NativeEntryI18n<Instance, LocalisedUrls>;
|
|
37
|
+
/** The entry module's bundler HMR object, `import.meta.webpackHot`. */
|
|
38
|
+
readonly hot?: unknown;
|
|
39
|
+
/** The mount element id; must match the server entry's `rootId`. */
|
|
40
|
+
readonly rootId?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The application view a renderer mounts or hydrates. */
|
|
44
|
+
export type NativeClientView<Router, Instance, LocalisedUrls> = {
|
|
45
|
+
readonly i18n?: NativeI18nView<Instance, LocalisedUrls>;
|
|
46
|
+
} & (
|
|
47
|
+
| { readonly kind: 'component'; readonly component: unknown }
|
|
48
|
+
| { readonly kind: 'router'; readonly router: Router }
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
export interface NativeClientStart<
|
|
52
|
+
Bootstrap extends DocumentBootstrap,
|
|
53
|
+
Router,
|
|
54
|
+
Instance,
|
|
55
|
+
LocalisedUrls,
|
|
56
|
+
> {
|
|
57
|
+
readonly root: HTMLElement;
|
|
58
|
+
readonly identity: Readonly<RendererIdentity>;
|
|
59
|
+
readonly bootstrap: Bootstrap | undefined;
|
|
60
|
+
/** Aborts when the entry is disposed during startup. */
|
|
61
|
+
readonly signal: AbortSignal;
|
|
62
|
+
/** Import the application and prepare its router for this document. */
|
|
63
|
+
load(): Promise<NativeClientView<Router, Instance, LocalisedUrls>>;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The renderer's own document, router and root lifecycle. */
|
|
67
|
+
export interface NativeClientAdapter<
|
|
68
|
+
Bootstrap extends DocumentBootstrap,
|
|
69
|
+
Router extends object,
|
|
70
|
+
Instance extends NativeI18nInstance,
|
|
71
|
+
LocalisedUrls,
|
|
72
|
+
> {
|
|
73
|
+
readBootstrap(
|
|
74
|
+
document: Document,
|
|
75
|
+
identity: Readonly<RendererIdentity>,
|
|
76
|
+
): Bootstrap;
|
|
77
|
+
createRouter(
|
|
78
|
+
application: NativeRoutedApplication,
|
|
79
|
+
options: NativeRouterOptions,
|
|
80
|
+
): Router;
|
|
81
|
+
/** Load a fresh router, or adopt the server's matches when hydrating. */
|
|
82
|
+
prepareRouter(
|
|
83
|
+
router: Router,
|
|
84
|
+
hydrating: boolean,
|
|
85
|
+
signal: AbortSignal,
|
|
86
|
+
): Promise<void>;
|
|
87
|
+
/** Mount or hydrate the root; resolves its disposal. */
|
|
88
|
+
start(
|
|
89
|
+
input: NativeClientStart<Bootstrap, Router, Instance, LocalisedUrls>,
|
|
90
|
+
): Promise<() => void>;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
interface NativeEntryHot {
|
|
94
|
+
dispose(callback: () => void): void;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function isNativeEntryHot(value: unknown): value is NativeEntryHot {
|
|
98
|
+
return (
|
|
99
|
+
typeof value === 'object' &&
|
|
100
|
+
value !== null &&
|
|
101
|
+
'dispose' in value &&
|
|
102
|
+
typeof value.dispose === 'function'
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Browser route data: local client loaders, or the server's data endpoint. */
|
|
107
|
+
function clientRouteLoader(
|
|
108
|
+
application: NativeRoutedApplication,
|
|
109
|
+
identity: RendererIdentity,
|
|
110
|
+
): NativeRouterOptions['loadRoute'] {
|
|
111
|
+
const serverDataRoutes = new Set(application.serverDataRoutes);
|
|
112
|
+
return async (route, input) => {
|
|
113
|
+
const loader = application.dataModules[route.id]?.loader;
|
|
114
|
+
if (loader)
|
|
115
|
+
return invokeRouteData(loader, input, {
|
|
116
|
+
production: process.env.NODE_ENV === 'production',
|
|
117
|
+
});
|
|
118
|
+
if (!serverDataRoutes.has(route.id))
|
|
119
|
+
return { kind: 'success', value: undefined, status: 200 };
|
|
120
|
+
return createDataClient(route.id, identity).loader({
|
|
121
|
+
request: input.request,
|
|
122
|
+
});
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Start a generated browser entry: find the mount element, read the server
|
|
128
|
+
* bootstrap, own HMR disposal and startup cancellation, and surface startup
|
|
129
|
+
* failures. The renderer adapter owns its router and root.
|
|
130
|
+
*/
|
|
131
|
+
export function startNativeClientEntry<
|
|
132
|
+
Bootstrap extends DocumentBootstrap,
|
|
133
|
+
Router extends object,
|
|
134
|
+
Instance extends NativeI18nInstance,
|
|
135
|
+
LocalisedUrls,
|
|
136
|
+
>(
|
|
137
|
+
options: NativeClientEntryOptions<Instance, LocalisedUrls>,
|
|
138
|
+
adapter: NativeClientAdapter<Bootstrap, Router, Instance, LocalisedUrls>,
|
|
139
|
+
): void {
|
|
140
|
+
const identity = Object.freeze({ ...options.identity });
|
|
141
|
+
const root = document.getElementById(options.rootId ?? 'root');
|
|
142
|
+
if (!root) throw new Error('The native application mount element is missing');
|
|
143
|
+
const bootstrap = document.getElementById(RENDERER_BOOTSTRAP_ID)
|
|
144
|
+
? adapter.readBootstrap(document, identity)
|
|
145
|
+
: undefined;
|
|
146
|
+
const hydrating = bootstrap?.hydrating ?? false;
|
|
147
|
+
const controller = new AbortController();
|
|
148
|
+
const { signal } = controller;
|
|
149
|
+
let disposed = false;
|
|
150
|
+
let dispose: (() => void) | undefined;
|
|
151
|
+
if (isNativeEntryHot(options.hot))
|
|
152
|
+
options.hot.dispose(() => {
|
|
153
|
+
disposed = true;
|
|
154
|
+
controller.abort(
|
|
155
|
+
new DOMException('The application entry was disposed', 'AbortError'),
|
|
156
|
+
);
|
|
157
|
+
dispose?.();
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
const load = async (): Promise<
|
|
161
|
+
NativeClientView<Router, Instance, LocalisedUrls>
|
|
162
|
+
> => {
|
|
163
|
+
// Each startup stage races the signal, so a replaced entry is released
|
|
164
|
+
// even while its application import or translations never settle.
|
|
165
|
+
const application = await untilAborted(options.load(), signal);
|
|
166
|
+
const { i18n } = options;
|
|
167
|
+
let instance: Instance | undefined;
|
|
168
|
+
if (i18n) {
|
|
169
|
+
// The server's language and bundles arrive in the document: no flash, no refetch.
|
|
170
|
+
const handoff = i18n.clientHandoff();
|
|
171
|
+
instance = await untilAborted(
|
|
172
|
+
i18n.create(handoff.language, handoff.resources),
|
|
173
|
+
signal,
|
|
174
|
+
);
|
|
175
|
+
// Component-only and routed views both persist language changes; a
|
|
176
|
+
// replaced or failed entry stops.
|
|
177
|
+
const stopPersist = i18n.persist?.(instance);
|
|
178
|
+
if (stopPersist)
|
|
179
|
+
signal.addEventListener('abort', stopPersist, { once: true });
|
|
180
|
+
}
|
|
181
|
+
const current = instance;
|
|
182
|
+
const localization =
|
|
183
|
+
i18n && current ? { i18n: nativeI18nView(i18n, current) } : {};
|
|
184
|
+
if (!isRoutedApplication(application))
|
|
185
|
+
return {
|
|
186
|
+
kind: 'component',
|
|
187
|
+
component: application.default,
|
|
188
|
+
...localization,
|
|
189
|
+
};
|
|
190
|
+
const router = adapter.createRouter(application, {
|
|
191
|
+
identity,
|
|
192
|
+
loadRoute: clientRouteLoader(application, identity),
|
|
193
|
+
// Disposal retires the router's route loads, including the initial
|
|
194
|
+
// load the adapter is about to start.
|
|
195
|
+
signal,
|
|
196
|
+
...(i18n && current
|
|
197
|
+
? { rewrite: i18n.rewrite(() => current.language) }
|
|
198
|
+
: {}),
|
|
199
|
+
});
|
|
200
|
+
// HMR disposal aborts the signal; a failed start stops the sync too.
|
|
201
|
+
const stopSync =
|
|
202
|
+
i18n && current ? i18n.syncWithRouter(router, current) : undefined;
|
|
203
|
+
if (stopSync) signal.addEventListener('abort', stopSync, { once: true });
|
|
204
|
+
try {
|
|
205
|
+
// An initial load whose loaders ignore the signal must not keep a
|
|
206
|
+
// replaced entry and its router alive.
|
|
207
|
+
await untilAborted(
|
|
208
|
+
adapter.prepareRouter(router, hydrating, signal),
|
|
209
|
+
signal,
|
|
210
|
+
);
|
|
211
|
+
} catch (error) {
|
|
212
|
+
stopSync?.();
|
|
213
|
+
throw error;
|
|
214
|
+
}
|
|
215
|
+
return {
|
|
216
|
+
kind: 'router',
|
|
217
|
+
router,
|
|
218
|
+
...localization,
|
|
219
|
+
};
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
void adapter
|
|
223
|
+
.start({ root, identity, bootstrap, signal, load })
|
|
224
|
+
.then(release => {
|
|
225
|
+
dispose = release;
|
|
226
|
+
if (disposed) release();
|
|
227
|
+
})
|
|
228
|
+
.catch(error => {
|
|
229
|
+
// Release what a failed start left attached, such as i18n sync.
|
|
230
|
+
if (!signal.aborted) controller.abort(error);
|
|
231
|
+
if (!disposed)
|
|
232
|
+
queueMicrotask(() => {
|
|
233
|
+
throw error;
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
}
|