@tinvar/renderer 0.0.0-stage → 0.4.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/bin/dev-renderer.js +117 -0
- package/bin/renderer-probe.js +52 -0
- package/bin/renderer.js +327 -0
- package/package.json +44 -4
- package/src/asset-origin.js +306 -0
- package/src/build-profile.js +108 -0
- package/src/can-branch.js +1666 -0
- package/src/classify.js +157 -0
- package/src/client-chunks.js +54 -0
- package/src/client-props.js +540 -0
- package/src/codec.js +297 -0
- package/src/dev-app.js +45 -0
- package/src/dev-guard.js +33 -0
- package/src/dev-server.js +460 -0
- package/src/eslint.js +469 -0
- package/src/head.js +524 -0
- package/src/html-stream.js +479 -0
- package/src/log.js +131 -0
- package/src/mint-client.js +142 -0
- package/src/panel-styles.js +254 -0
- package/src/pool.js +664 -0
- package/src/protocol.js +71 -0
- package/src/query-client.js +198 -0
- package/src/query-map.js +116 -0
- package/src/render-session.js +580 -0
- package/src/sandbox.js +710 -0
- package/src/server.js +466 -0
- package/src/socket-group.js +72 -0
- package/src/vite.js +2416 -0
- package/src/vitest.d.ts +20 -0
- package/src/vitest.js +179 -0
- package/src/warm.d.ts +9 -0
- package/src/warm.js +60 -0
- package/src/workbench.d.ts +146 -0
- package/src/workbench.js +754 -0
- package/README.md +0 -3
package/src/head.js
ADDED
|
@@ -0,0 +1,524 @@
|
|
|
1
|
+
// One title, and one meta tag and one link of each key, in the document's
|
|
2
|
+
// head.
|
|
3
|
+
//
|
|
4
|
+
// tinvar-spec.md, "Operations and tooling": "React 19 hoists <title> and
|
|
5
|
+
// <meta> from components; add key-based dedupe, one title, a global title
|
|
6
|
+
// template, and PHP-rendered fallback tags marked for replacement."
|
|
7
|
+
//
|
|
8
|
+
// React writes every hoisted tag into <head> as the shell flushes. The
|
|
9
|
+
// components in @tinvar/react/head mark each with its key and its depth in
|
|
10
|
+
// the route; this keeps, for each key, the deepest -- the last among equals,
|
|
11
|
+
// as React rendered them -- and writes PHP's fallback tags first, marked,
|
|
12
|
+
// for any of them to replace. Only the head is held to do it, never the
|
|
13
|
+
// body: the shell is flushed whole, so the head is never waiting on more
|
|
14
|
+
// than the rest of its own flush.
|
|
15
|
+
//
|
|
16
|
+
// A link <HeadLink> marked takes part by its key. One nothing marked -- a
|
|
17
|
+
// raw <link> a component or the document wrote -- takes part at the
|
|
18
|
+
// document's depth, as a raw <title> does, keyed as <HeadLink> would key
|
|
19
|
+
// it, unless it is one of React's own resources: a stylesheet, a preload,
|
|
20
|
+
// a module preload, a preconnect, a DNS prefetch or a prefetch. Those React
|
|
21
|
+
// writes for itself, dedupes by href itself and finds again by href when it
|
|
22
|
+
// hydrates, so they are left exactly as written.
|
|
23
|
+
//
|
|
24
|
+
// Only here, on the server, where a crawler reads the head. In the browser
|
|
25
|
+
// a raw link is React's to hoist and to remove; one dropped here is put
|
|
26
|
+
// back as the page hydrates, and a page a navigation mounts adds its own
|
|
27
|
+
// beside the layout's. A link that changes with the page belongs in a
|
|
28
|
+
// <HeadLink>, which agrees with the others in the browser too.
|
|
29
|
+
|
|
30
|
+
const encoder = new TextEncoder();
|
|
31
|
+
const decoder = new TextDecoder();
|
|
32
|
+
|
|
33
|
+
/** Mirrors @tinvar/react/head and Tinvar\Protocol\HeadTag. */
|
|
34
|
+
const FALLBACK = 'data-tinvar-head="fallback"';
|
|
35
|
+
const KEY_ATTRIBUTE = 'data-tinvar-key';
|
|
36
|
+
const DEPTH_ATTRIBUTE = 'data-tinvar-depth';
|
|
37
|
+
|
|
38
|
+
/** How much of a document is held looking for the end of its head before it is sent as it is. */
|
|
39
|
+
const MAX_HEAD_BYTES = 512 * 1024;
|
|
40
|
+
|
|
41
|
+
/** The most fallback tags one render writes; PHP sends a handful. */
|
|
42
|
+
const MAX_FALLBACKS = 32;
|
|
43
|
+
|
|
44
|
+
const END_OF_HEAD = encoder.encode('</head>');
|
|
45
|
+
|
|
46
|
+
/** @param {string} value */
|
|
47
|
+
function escape(value) {
|
|
48
|
+
return value.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* PHP's fallback tags, as markup marked for replacement. Only a title or a
|
|
53
|
+
* meta tag, only attribute names of an attribute's shape and never a
|
|
54
|
+
* handler, every value escaped: the frame is PHP's, but what reaches the
|
|
55
|
+
* document is decided where the markup is written.
|
|
56
|
+
*
|
|
57
|
+
* @param {unknown} head the render frame's `head`
|
|
58
|
+
* @returns {string}
|
|
59
|
+
*/
|
|
60
|
+
export function fallbackTags(head) {
|
|
61
|
+
if (!Array.isArray(head)) return '';
|
|
62
|
+
let out = '';
|
|
63
|
+
for (const entry of head.slice(0, MAX_FALLBACKS)) {
|
|
64
|
+
if (entry === null || typeof entry !== 'object') continue;
|
|
65
|
+
if (entry.tag === 'title' && typeof entry.text === 'string') {
|
|
66
|
+
out += `<title ${FALLBACK}>${escape(entry.text)}</title>`;
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
if (entry.tag !== 'meta' || entry.attributes === null || typeof entry.attributes !== 'object') continue;
|
|
70
|
+
let attributes = '';
|
|
71
|
+
for (const [name, value] of Object.entries(entry.attributes)) {
|
|
72
|
+
if (!/^[a-z][a-z0-9:_.-]*$/.test(name) || name.startsWith('on') || typeof value !== 'string') continue;
|
|
73
|
+
attributes += ` ${name}="${escape(value)}"`;
|
|
74
|
+
}
|
|
75
|
+
out += `<meta${attributes} ${FALLBACK}>`;
|
|
76
|
+
}
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** @param {string} tag the opening tag @returns {Record<string, string>} */
|
|
81
|
+
function attributesOf(tag) {
|
|
82
|
+
const attributes = {};
|
|
83
|
+
const pattern = /([^\s"'<>/=]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))?/g;
|
|
84
|
+
// Past the tag's name.
|
|
85
|
+
const body = tag.replace(/^<\s*[a-zA-Z]+/, '').replace(/\/?>$/, '');
|
|
86
|
+
for (const match of body.matchAll(pattern)) {
|
|
87
|
+
attributes[match[1].toLowerCase()] = match[2] ?? match[3] ?? match[4] ?? '';
|
|
88
|
+
}
|
|
89
|
+
return attributes;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Rels of the links React writes for its own resources, which a raw link
|
|
94
|
+
* of any of them is taken to be. Mirrors nothing in @tinvar/react/head:
|
|
95
|
+
* <HeadLink> renders a stylesheet unkeyed for the same reason.
|
|
96
|
+
*/
|
|
97
|
+
const RESOURCE_RELS = new Set([
|
|
98
|
+
'stylesheet',
|
|
99
|
+
'preload',
|
|
100
|
+
'modulepreload',
|
|
101
|
+
'preconnect',
|
|
102
|
+
'dns-prefetch',
|
|
103
|
+
'prefetch',
|
|
104
|
+
]);
|
|
105
|
+
|
|
106
|
+
/** Rels whose links are the page's icon at a size, as @tinvar/react/head's linkKey() has them. */
|
|
107
|
+
const ICON_RELS = new Set([
|
|
108
|
+
'icon',
|
|
109
|
+
'shortcut icon',
|
|
110
|
+
'apple-touch-icon',
|
|
111
|
+
'apple-touch-icon-precomposed',
|
|
112
|
+
'mask-icon',
|
|
113
|
+
]);
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The key a raw link is deduplicated by: @tinvar/react/head's linkKey() for
|
|
117
|
+
* the same attributes, so a raw canonical and a <HeadLink> canonical are
|
|
118
|
+
* one key. Null for a link that takes no part: one of React's resources
|
|
119
|
+
* (any rel naming one, `alternate stylesheet` too), or one with nothing to
|
|
120
|
+
* key it by.
|
|
121
|
+
*
|
|
122
|
+
* @param {Record<string, string>} attributes
|
|
123
|
+
* @returns {string | null}
|
|
124
|
+
*/
|
|
125
|
+
export function rawLinkKey(attributes) {
|
|
126
|
+
const rel = (attributes.rel ?? '').trim().toLowerCase().replace(/\s+/g, ' ');
|
|
127
|
+
if (rel === '' || rel.split(' ').some((token) => RESOURCE_RELS.has(token))) return null;
|
|
128
|
+
if (rel === 'canonical') return 'link:canonical';
|
|
129
|
+
|
|
130
|
+
const hrefLang = attributes.hreflang ?? '';
|
|
131
|
+
if (rel === 'alternate' && hrefLang !== '') return `link:alternate:${hrefLang.toLowerCase()}`;
|
|
132
|
+
if (ICON_RELS.has(rel)) {
|
|
133
|
+
const sizes = (attributes.sizes ?? '').trim().toLowerCase();
|
|
134
|
+
const media = (attributes.media ?? '').trim();
|
|
135
|
+
const type = (attributes.type ?? '').trim().toLowerCase();
|
|
136
|
+
return `link:${rel}:${sizes}:${media}:${type}`;
|
|
137
|
+
}
|
|
138
|
+
// The href as React wrote it, escaped as it escapes a marked link's key.
|
|
139
|
+
return attributes.href ? `link:${rel}:${attributes.href}` : null;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* The key a head tag is deduplicated by, and how deep in the route it was
|
|
144
|
+
* rendered: a fallback below everything, a tag no component marked -- the
|
|
145
|
+
* document's own, a bare <title>, a raw link -- at the document's depth, 0.
|
|
146
|
+
*
|
|
147
|
+
* @returns {{ key: string, depth: number } | null}
|
|
148
|
+
*/
|
|
149
|
+
function identify(name, attributes) {
|
|
150
|
+
const fallback = attributes['data-tinvar-head'] === 'fallback';
|
|
151
|
+
const depth = fallback ? -Infinity : Number(attributes[DEPTH_ATTRIBUTE] ?? 0) || 0;
|
|
152
|
+
if (name === 'title') return { key: 'title', depth };
|
|
153
|
+
if (attributes[KEY_ATTRIBUTE]) return { key: attributes[KEY_ATTRIBUTE], depth };
|
|
154
|
+
if (name === 'link') {
|
|
155
|
+
const key = rawLinkKey(attributes);
|
|
156
|
+
return key === null ? null : { key, depth: 0 };
|
|
157
|
+
}
|
|
158
|
+
if (attributes.name !== undefined) return { key: `name:${attributes.name}`, depth };
|
|
159
|
+
if (attributes.property !== undefined) return { key: `property:${attributes.property}`, depth };
|
|
160
|
+
if (attributes['http-equiv'] !== undefined) {
|
|
161
|
+
return { key: `http-equiv:${attributes['http-equiv'].toLowerCase()}`, depth };
|
|
162
|
+
}
|
|
163
|
+
if (attributes.charset !== undefined) return { key: 'charset', depth };
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Elements whose body is text, never tags: raw text and RCDATA, as a
|
|
169
|
+
* browser with scripting reads them, and plaintext, whose body is the rest
|
|
170
|
+
* of the document.
|
|
171
|
+
*/
|
|
172
|
+
const TEXT_BODIES = new Set([
|
|
173
|
+
'script',
|
|
174
|
+
'style',
|
|
175
|
+
'noscript',
|
|
176
|
+
'textarea',
|
|
177
|
+
'title',
|
|
178
|
+
'xmp',
|
|
179
|
+
'iframe',
|
|
180
|
+
'noembed',
|
|
181
|
+
'noframes',
|
|
182
|
+
'plaintext',
|
|
183
|
+
]);
|
|
184
|
+
|
|
185
|
+
/** Elements with no end tag, which leave the head's top level where it was. */
|
|
186
|
+
const VOID = new Set([
|
|
187
|
+
'area',
|
|
188
|
+
'base',
|
|
189
|
+
'br',
|
|
190
|
+
'col',
|
|
191
|
+
'embed',
|
|
192
|
+
'hr',
|
|
193
|
+
'img',
|
|
194
|
+
'input',
|
|
195
|
+
'keygen',
|
|
196
|
+
'link',
|
|
197
|
+
'meta',
|
|
198
|
+
'param',
|
|
199
|
+
'source',
|
|
200
|
+
'track',
|
|
201
|
+
'wbr',
|
|
202
|
+
]);
|
|
203
|
+
|
|
204
|
+
const WHITESPACE = /[\t\n\f\r ]/;
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Lower case as HTML has it: ASCII letters only. String#toLowerCase()
|
|
208
|
+
* lowers others too -- the Kelvin sign to a `k`, `İ` to two code units --
|
|
209
|
+
* and would read as `link` a name no browser does.
|
|
210
|
+
*
|
|
211
|
+
* @param {string} value
|
|
212
|
+
*/
|
|
213
|
+
function asciiLower(value) {
|
|
214
|
+
return value.replace(/[A-Z]+/g, (letters) => letters.toLowerCase());
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* A tag from just past its name, as the HTML tokenizer reads it: to the
|
|
219
|
+
* first `>` outside a quoted attribute value. Null if the head ends first.
|
|
220
|
+
*
|
|
221
|
+
* @param {string} html
|
|
222
|
+
* @param {number} at just past the tag's name
|
|
223
|
+
* @returns {{ attributes: Record<string, string>, end: number } | null}
|
|
224
|
+
*/
|
|
225
|
+
function readTag(html, at) {
|
|
226
|
+
/** @type {Record<string, string>} */
|
|
227
|
+
const attributes = {};
|
|
228
|
+
let i = at;
|
|
229
|
+
for (;;) {
|
|
230
|
+
while (i < html.length && (WHITESPACE.test(html[i]) || html[i] === '/')) i++;
|
|
231
|
+
if (i >= html.length) return null;
|
|
232
|
+
if (html[i] === '>') return { attributes, end: i + 1 };
|
|
233
|
+
|
|
234
|
+
const nameStart = i++;
|
|
235
|
+
while (i < html.length && !WHITESPACE.test(html[i]) && !'/>='.includes(html[i])) i++;
|
|
236
|
+
const name = asciiLower(html.slice(nameStart, i));
|
|
237
|
+
while (i < html.length && WHITESPACE.test(html[i])) i++;
|
|
238
|
+
let value = '';
|
|
239
|
+
if (html[i] === '=') {
|
|
240
|
+
i++;
|
|
241
|
+
while (i < html.length && WHITESPACE.test(html[i])) i++;
|
|
242
|
+
const quote = html[i];
|
|
243
|
+
if (quote === '"' || quote === "'") {
|
|
244
|
+
const close = html.indexOf(quote, i + 1);
|
|
245
|
+
if (close === -1) return null;
|
|
246
|
+
value = html.slice(i + 1, close);
|
|
247
|
+
i = close + 1;
|
|
248
|
+
} else {
|
|
249
|
+
const valueStart = i;
|
|
250
|
+
while (i < html.length && !WHITESPACE.test(html[i]) && html[i] !== '>') i++;
|
|
251
|
+
value = html.slice(valueStart, i);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
// The first of two attributes of one name is the one a browser keeps.
|
|
255
|
+
if (!Object.hasOwn(attributes, name)) attributes[name] = value;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Where a tag's name ends: at whitespace, a `/` or a `>`. */
|
|
260
|
+
function endOfName(html, at) {
|
|
261
|
+
let i = at;
|
|
262
|
+
while (i < html.length && !WHITESPACE.test(html[i]) && !'/>'.includes(html[i])) i++;
|
|
263
|
+
return i;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Where the end tag that closes a text body ends: the first `</name`, in
|
|
268
|
+
* any ASCII case, followed by what can end a tag's name. Matched in the
|
|
269
|
+
* head itself, never a lowered copy, whose indices need not be the head's.
|
|
270
|
+
* -1 if the body runs to the end of the head.
|
|
271
|
+
*
|
|
272
|
+
* @param {string} html
|
|
273
|
+
* @param {string} name a name of TEXT_BODIES
|
|
274
|
+
* @param {number} from where the body starts
|
|
275
|
+
*/
|
|
276
|
+
function endOfTextBody(html, name, from) {
|
|
277
|
+
// No `u` flag: case-insensitive then folds no character outside ASCII
|
|
278
|
+
// into one inside it, as HTML's ASCII case-insensitivity does not.
|
|
279
|
+
const end = new RegExp(`</${name}(?=[\\t\\n\\f\\r />]|$)`, 'gi');
|
|
280
|
+
end.lastIndex = from;
|
|
281
|
+
const match = end.exec(html);
|
|
282
|
+
if (match === null) return -1;
|
|
283
|
+
return readTag(html, match.index + match[0].length)?.end ?? -1;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* The titles, metas and links at the head's top level, each whole, as a
|
|
288
|
+
* browser tokenises the head: never one inside a comment, an attribute's
|
|
289
|
+
* value, the text body of a style, a script or the like, or an element
|
|
290
|
+
* still open around it.
|
|
291
|
+
*
|
|
292
|
+
* Null wherever this reading could differ from a browser's, and the head
|
|
293
|
+
* is then left exactly as it came: cutting a tag out of a head read wrongly
|
|
294
|
+
* can put the text either side of it together into markup. So a script
|
|
295
|
+
* whose body holds `<!--` (its escaped states, where `</script>` need not
|
|
296
|
+
* end it), svg or math (foreign content, whose style is no text body), a
|
|
297
|
+
* text body, comment or tag the head ends inside, plaintext, and a bare
|
|
298
|
+
* `<`, which React never writes.
|
|
299
|
+
*
|
|
300
|
+
* @param {string} html what is between <head> and </head>
|
|
301
|
+
* @returns {{ name: string, attributes: Record<string, string>, start: number, end: number }[] | null}
|
|
302
|
+
*/
|
|
303
|
+
function topLevelTags(html) {
|
|
304
|
+
const tags = [];
|
|
305
|
+
let depth = 0;
|
|
306
|
+
let i = 0;
|
|
307
|
+
while (i < html.length) {
|
|
308
|
+
const start = html.indexOf('<', i);
|
|
309
|
+
if (start === -1) break;
|
|
310
|
+
const next = html[start + 1] ?? '';
|
|
311
|
+
|
|
312
|
+
if (html.startsWith('<!--', start)) {
|
|
313
|
+
if (html.startsWith('>', start + 4)) i = start + 5;
|
|
314
|
+
else if (html.startsWith('->', start + 4)) i = start + 6;
|
|
315
|
+
else {
|
|
316
|
+
const close = /--!?>/g;
|
|
317
|
+
close.lastIndex = start + 4;
|
|
318
|
+
const match = close.exec(html);
|
|
319
|
+
if (match === null) return null;
|
|
320
|
+
i = match.index + match[0].length;
|
|
321
|
+
}
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
if (next === '!' || next === '?' || (next === '/' && !/[a-zA-Z>]/.test(html[start + 2] ?? ''))) {
|
|
325
|
+
// A bogus comment, to the first `>`.
|
|
326
|
+
const close = html.indexOf('>', start + 2);
|
|
327
|
+
if (close === -1) return null;
|
|
328
|
+
i = close + 1;
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
if (next === '/') {
|
|
332
|
+
if (html[start + 2] === '>') {
|
|
333
|
+
i = start + 3;
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
336
|
+
const nameEnd = endOfName(html, start + 2);
|
|
337
|
+
const tag = readTag(html, nameEnd);
|
|
338
|
+
if (tag === null) return null;
|
|
339
|
+
if (!VOID.has(asciiLower(html.slice(start + 2, nameEnd)))) depth = Math.max(0, depth - 1);
|
|
340
|
+
i = tag.end;
|
|
341
|
+
continue;
|
|
342
|
+
}
|
|
343
|
+
if (!/[a-zA-Z]/.test(next)) return null;
|
|
344
|
+
|
|
345
|
+
const nameEnd = endOfName(html, start + 1);
|
|
346
|
+
const name = asciiLower(html.slice(start + 1, nameEnd));
|
|
347
|
+
const tag = readTag(html, nameEnd);
|
|
348
|
+
if (tag === null || name === 'plaintext' || name === 'svg' || name === 'math') return null;
|
|
349
|
+
|
|
350
|
+
if (TEXT_BODIES.has(name)) {
|
|
351
|
+
const end = endOfTextBody(html, name, tag.end);
|
|
352
|
+
if (end === -1) return null;
|
|
353
|
+
if (name === 'script' && html.slice(tag.end, end).includes('<!--')) return null;
|
|
354
|
+
if (name === 'title' && depth === 0) tags.push({ name, attributes: tag.attributes, start, end });
|
|
355
|
+
i = end;
|
|
356
|
+
continue;
|
|
357
|
+
}
|
|
358
|
+
if (name === 'meta' || name === 'link') {
|
|
359
|
+
if (depth === 0) tags.push({ name, attributes: tag.attributes, start, end: tag.end });
|
|
360
|
+
} else if (!VOID.has(name)) {
|
|
361
|
+
depth++;
|
|
362
|
+
}
|
|
363
|
+
i = tag.end;
|
|
364
|
+
}
|
|
365
|
+
return tags;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* The head's markup with one tag of each key: the deepest, the last among
|
|
370
|
+
* equals. A head read with any doubt (topLevelTags()) is left as it came.
|
|
371
|
+
*
|
|
372
|
+
* @param {string} head what is between <head> and </head>
|
|
373
|
+
* @returns {string}
|
|
374
|
+
*/
|
|
375
|
+
export function dedupeHead(head) {
|
|
376
|
+
const found = topLevelTags(head);
|
|
377
|
+
if (found === null) return head;
|
|
378
|
+
const tags = [];
|
|
379
|
+
for (const tag of found) {
|
|
380
|
+
const id = identify(tag.name, tag.attributes);
|
|
381
|
+
if (id !== null) tags.push({ ...id, start: tag.start, end: tag.end });
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/** @type {Map<string, typeof tags[number]>} */
|
|
385
|
+
const winners = new Map();
|
|
386
|
+
for (const tag of tags) {
|
|
387
|
+
const best = winners.get(tag.key);
|
|
388
|
+
if (best === undefined || tag.depth >= best.depth) winners.set(tag.key, tag);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
let out = '';
|
|
392
|
+
let at = 0;
|
|
393
|
+
for (const tag of tags) {
|
|
394
|
+
if (winners.get(tag.key) === tag) continue;
|
|
395
|
+
out += head.slice(at, tag.start);
|
|
396
|
+
at = tag.end;
|
|
397
|
+
}
|
|
398
|
+
return out + head.slice(at);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* The document with PHP's fallbacks first in its head, and one tag of
|
|
403
|
+
* each key there.
|
|
404
|
+
*
|
|
405
|
+
* @param {string} document the document up to and including `</head>`
|
|
406
|
+
* @param {string} fallbacks from fallbackTags()
|
|
407
|
+
*/
|
|
408
|
+
export function resolveHead(document, fallbacks) {
|
|
409
|
+
const open = /<head(?:\s[^>]*)?>/i.exec(document);
|
|
410
|
+
const close = asciiLower(document).lastIndexOf('</head>');
|
|
411
|
+
if (open === null || close < open.index) return document;
|
|
412
|
+
|
|
413
|
+
// After the charset, when the head starts with it: a browser looks for
|
|
414
|
+
// the encoding in the document's first bytes, so nothing goes ahead of it.
|
|
415
|
+
const start = open.index + open[0].length;
|
|
416
|
+
const charset = /^<meta\s+charset\s*=[^>]*>/i.exec(document.slice(start, close));
|
|
417
|
+
const at = start + (charset ? charset[0].length : 0);
|
|
418
|
+
return document.slice(0, at) + dedupeHead(fallbacks + document.slice(at, close)) + document.slice(close);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/** Where `</head>` ends in `bytes`, or -1. ASCII, so found in the bytes, never cutting a character. */
|
|
422
|
+
function endOfHead(bytes) {
|
|
423
|
+
outer: for (let i = 0; i + END_OF_HEAD.length <= bytes.length; i++) {
|
|
424
|
+
for (let j = 0; j < END_OF_HEAD.length; j++) {
|
|
425
|
+
const byte = bytes[i + j] >= 0x41 && bytes[i + j] <= 0x5a ? bytes[i + j] + 0x20 : bytes[i + j];
|
|
426
|
+
if (byte !== END_OF_HEAD[j]) continue outer;
|
|
427
|
+
}
|
|
428
|
+
return i + END_OF_HEAD.length;
|
|
429
|
+
}
|
|
430
|
+
return -1;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* The modules a document's head preloads, in its order: the client entry,
|
|
435
|
+
* then the chunks of the client components the page renders, as React
|
|
436
|
+
* wrote them from the payload's client references.
|
|
437
|
+
*
|
|
438
|
+
* @param {string} document
|
|
439
|
+
* @returns {string[]}
|
|
440
|
+
*/
|
|
441
|
+
export function modulePreloads(document) {
|
|
442
|
+
const head = document.slice(0, Math.max(0, asciiLower(document).lastIndexOf('</head>')));
|
|
443
|
+
const out = [];
|
|
444
|
+
for (const match of head.matchAll(/<link\b[^>]*>/gi)) {
|
|
445
|
+
const attributes = attributesOf(match[0]);
|
|
446
|
+
if (attributes.rel?.toLowerCase() === 'modulepreload' && attributes.href) out.push(attributes.href);
|
|
447
|
+
}
|
|
448
|
+
return [...new Set(out)];
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* The HTML stream with its head resolved: held until `</head>` arrives,
|
|
453
|
+
* then sent with the fallbacks written and the duplicates dropped, and
|
|
454
|
+
* everything after it passed through untouched.
|
|
455
|
+
*
|
|
456
|
+
* @param {ReadableStream<Uint8Array>} html
|
|
457
|
+
* @param {unknown} head the render frame's `head`
|
|
458
|
+
* @param {(resolved: { preload: string[] }) => void} [onResolved] told, once,
|
|
459
|
+
* what the resolved head preloads -- for PHP's Link header
|
|
460
|
+
* @returns {ReadableStream<Uint8Array>}
|
|
461
|
+
*/
|
|
462
|
+
export function withHead(html, head, onResolved) {
|
|
463
|
+
const fallbacks = fallbackTags(head);
|
|
464
|
+
// What is held, as it came: joined once, when `</head>` is found, and
|
|
465
|
+
// searched only where the newest chunk could finish it -- copying and
|
|
466
|
+
// rescanning the whole on every chunk made a long head quadratic.
|
|
467
|
+
/** @type {Uint8Array[]} */
|
|
468
|
+
let held = [];
|
|
469
|
+
let heldBytes = 0;
|
|
470
|
+
let tail = new Uint8Array(0);
|
|
471
|
+
let resolved = false;
|
|
472
|
+
|
|
473
|
+
const join = () => {
|
|
474
|
+
const joined = new Uint8Array(heldBytes);
|
|
475
|
+
let at = 0;
|
|
476
|
+
for (const piece of held) {
|
|
477
|
+
joined.set(piece, at);
|
|
478
|
+
at += piece.length;
|
|
479
|
+
}
|
|
480
|
+
held = [];
|
|
481
|
+
heldBytes = 0;
|
|
482
|
+
return joined;
|
|
483
|
+
};
|
|
484
|
+
|
|
485
|
+
return html.pipeThrough(
|
|
486
|
+
new TransformStream({
|
|
487
|
+
transform(chunk, controller) {
|
|
488
|
+
if (resolved) {
|
|
489
|
+
controller.enqueue(chunk);
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
const window = new Uint8Array(tail.length + chunk.length);
|
|
493
|
+
window.set(tail, 0);
|
|
494
|
+
window.set(chunk, tail.length);
|
|
495
|
+
const windowStart = heldBytes - tail.length;
|
|
496
|
+
held.push(chunk);
|
|
497
|
+
heldBytes += chunk.length;
|
|
498
|
+
|
|
499
|
+
const found = endOfHead(window);
|
|
500
|
+
if (found === -1) {
|
|
501
|
+
tail = window.subarray(Math.max(0, window.length - (END_OF_HEAD.length - 1)));
|
|
502
|
+
// No head in what a head could be: a document of some other
|
|
503
|
+
// shape, sent as it came.
|
|
504
|
+
if (heldBytes > MAX_HEAD_BYTES) {
|
|
505
|
+
controller.enqueue(join());
|
|
506
|
+
resolved = true;
|
|
507
|
+
}
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
const bytes = join();
|
|
512
|
+
const end = windowStart + found;
|
|
513
|
+
const resolvedHead = resolveHead(decoder.decode(bytes.subarray(0, end)), fallbacks);
|
|
514
|
+
onResolved?.({ preload: modulePreloads(resolvedHead) });
|
|
515
|
+
controller.enqueue(encoder.encode(resolvedHead));
|
|
516
|
+
if (end < bytes.length) controller.enqueue(bytes.subarray(end));
|
|
517
|
+
resolved = true;
|
|
518
|
+
},
|
|
519
|
+
flush(controller) {
|
|
520
|
+
if (heldBytes > 0) controller.enqueue(join());
|
|
521
|
+
},
|
|
522
|
+
}),
|
|
523
|
+
);
|
|
524
|
+
}
|