@uniflowed/router 0.0.0-alpha.8 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/action.js +324 -0
- package/client.js +261 -7
- package/handler.js +113 -99
- package/http-client.js +104 -0
- package/index.js +49 -6
- package/instrumentation.js +92 -0
- package/internal/action-endpoint.js +438 -0
- package/internal/action-wire.js +608 -0
- package/internal/base-path.js +175 -0
- package/internal/boundaries.js +481 -0
- package/internal/boundary-data.js +88 -0
- package/internal/compose.js +490 -0
- package/internal/deployment.js +160 -0
- package/internal/devtools.js +131 -0
- package/internal/diagnostics.js +169 -0
- package/internal/error-view.js +193 -0
- package/internal/flight-browser.js +242 -0
- package/internal/flight-chunks.js +205 -0
- package/internal/flight-rows.js +135 -0
- package/internal/flight-ssr.js +91 -0
- package/internal/flight.js +192 -0
- package/internal/head.js +219 -0
- package/internal/hydration.js +1085 -0
- package/internal/inspector.js +626 -0
- package/internal/native-links.js +67 -0
- package/internal/native-tree.js +89 -0
- package/internal/navigation-cache.js +181 -0
- package/internal/payload-rows.js +270 -0
- package/internal/payload.js +685 -0
- package/internal/prepare-document.js +54 -0
- package/internal/react-version.js +77 -0
- package/internal/resolve.js +1617 -0
- package/internal/resolved-summary.js +199 -0
- package/internal/routing.js +478 -0
- package/internal/runtime.js +1597 -1329
- package/internal/server-instrumentation.js +12 -0
- package/internal/server-route.js +58 -0
- package/internal/shell.js +125 -0
- package/internal/stream.js +754 -21
- package/middleware.js +161 -22
- package/native-navigation.js +217 -0
- package/native.js +416 -0
- package/package.json +48 -7
- package/routing.js +51 -0
- package/rsc-client.js +120 -0
- package/rsc-ssr.js +637 -0
- package/rsc.js +402 -0
- package/server-components.js +159 -0
- package/server.js +254 -106
|
@@ -0,0 +1,626 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// Internal to `@uniflowed/router`: what left, in what order, and what each
|
|
4
|
+
// chunk built.
|
|
5
|
+
//
|
|
6
|
+
// ubugeeei-prod/uf#520 asks for two things. The first — which DOM subtree each
|
|
7
|
+
// of the three boundaries owns — is `./boundaries.js` for Suspense and error
|
|
8
|
+
// and ubugeeei-prod/uf#636 for client/server. The second is *an inspector for
|
|
9
|
+
// the payload*: what arrived, in what order, and which part of the tree each
|
|
10
|
+
// chunk built. This is that one.
|
|
11
|
+
//
|
|
12
|
+
// # There is no Flight payload, and this is not pretending there is
|
|
13
|
+
//
|
|
14
|
+
// uf does not have one. `../client.js` hydrates by re-rendering the matched
|
|
15
|
+
// tree from the same modules the server rendered it from, `internal/rsc.js`
|
|
16
|
+
// says so from the other side, and ubugeeei-prod/uf#519 is where the payload
|
|
17
|
+
// is being worked out. Nothing here reads one, invents one, or decides
|
|
18
|
+
// anything about its shape.
|
|
19
|
+
//
|
|
20
|
+
// What uf *does* stream, today, on every `uf dev` request, is a document whose
|
|
21
|
+
// Suspense boundaries resolve independently — the shell goes out with the
|
|
22
|
+
// fallbacks in it and each boundary's content follows in its own chunk when it
|
|
23
|
+
// is ready. The three questions the issue asks are questions about that
|
|
24
|
+
// stream, they are unanswered today, and they are answerable from the bytes
|
|
25
|
+
// that are already going out. So they are answered about the stream that
|
|
26
|
+
// exists rather than about the payload that does not, and when the payload
|
|
27
|
+
// lands this is the recorder it is fed to: `inspectStream` takes chunks and
|
|
28
|
+
// knows nothing about who produced them.
|
|
29
|
+
//
|
|
30
|
+
// # It reads React's markers, plus uf's row marker
|
|
31
|
+
//
|
|
32
|
+
// Fizz writes a suspended boundary into the shell as an empty
|
|
33
|
+
// `<template id="B:0">` followed by the fallback and closed by a `<!--/$-->`
|
|
34
|
+
// comment, and completes it later with the content inside a hidden element
|
|
35
|
+
// carrying `id="S:0"` and a `$RC("B:0","S:0")` call that pairs the two. Both
|
|
36
|
+
// halves name the boundary, so a chunk can be attributed to the boundary it
|
|
37
|
+
// filled without uf marking anything, without a second render, and without any
|
|
38
|
+
// agreement with the client.
|
|
39
|
+
//
|
|
40
|
+
// Reading them is reading React's markup, which is the same licence `hoisted`
|
|
41
|
+
// takes in `./stream.js` and rests on the same fact: React escapes `>` in an
|
|
42
|
+
// attribute value and `<` in text, so the first `>` after an opening tag ends
|
|
43
|
+
// it. This is not an HTML parser and must never be handed markup from anywhere
|
|
44
|
+
// else. It is also written as hand-rolled `indexOf` scans rather than regular
|
|
45
|
+
// expressions — `docs/security.md`'s rule for text uf did not write, and the
|
|
46
|
+
// content of these chunks is the application's own — with a step limit on every
|
|
47
|
+
// loop for the same reason `./boundaries.js` has a `WALK_LIMIT`.
|
|
48
|
+
//
|
|
49
|
+
// The one uf-owned marker read here is `data-uf-row` on deferred payload row
|
|
50
|
+
// scripts. The inspector does not parse the row JSON or attach semantics to
|
|
51
|
+
// the value; it only keeps the terminal report from saying a row boundary built
|
|
52
|
+
// an anonymous `script`.
|
|
53
|
+
//
|
|
54
|
+
// A marker that a chunk boundary happened to split is not attributed rather
|
|
55
|
+
// than guessed at: the chunk is still counted, still timed, and says so. The
|
|
56
|
+
// alternative is a report that is confidently wrong about which part of the
|
|
57
|
+
// tree arrived, which is worse than one that is quiet about it.
|
|
58
|
+
//
|
|
59
|
+
// # Where the report goes, and how loud it is
|
|
60
|
+
//
|
|
61
|
+
// The terminal, on the same `diagnostic` channel `./boundaries.js` and
|
|
62
|
+
// `./hydration.js` use — ubugeeei-prod/uf#583's argument, made again by
|
|
63
|
+
// ubugeeei-prod/uf#636: a diagnostic that exists only in a browser window has
|
|
64
|
+
// to be noticed by somebody who does not know to look. Unlike those two it is
|
|
65
|
+
// produced on the server, because that is where the chunks are; `uf dev` hands
|
|
66
|
+
// `renderDocument` a reporter and nothing else does, so a built application
|
|
67
|
+
// neither reports nor records.
|
|
68
|
+
//
|
|
69
|
+
// It speaks the first time a path streams and whenever the *shape* of its
|
|
70
|
+
// stream changes, and is silent otherwise. The first time is the interesting
|
|
71
|
+
// one: you have just written the `$loading.js`, or the `await` that made the
|
|
72
|
+
// page suspend, and "it streamed, in three parts" is the answer to the question
|
|
73
|
+
// you asked by writing it. Reloading the same page unchanged is not that
|
|
74
|
+
// question, and `./boundaries.js` is right that a map printed on every reload
|
|
75
|
+
// is a banner nobody reads.
|
|
76
|
+
//
|
|
77
|
+
// The shape deliberately excludes the timings. A boundary that got slower has
|
|
78
|
+
// not changed shape, and putting the milliseconds in the signature would make
|
|
79
|
+
// this fire on every reload — which is the same trade `./boundaries.js` makes
|
|
80
|
+
// by leaving what a boundary owns out of its own signature.
|
|
81
|
+
//
|
|
82
|
+
// # A document that did not stream says nothing at all
|
|
83
|
+
//
|
|
84
|
+
// Most do not. A page with no suspended boundary is one chunk, there is no
|
|
85
|
+
// order to report and no part of the tree that arrived separately, and the
|
|
86
|
+
// honest report is silence. That is also what keeps this from being a request
|
|
87
|
+
// log: it fires for the pages the feature is about and for no others.
|
|
88
|
+
|
|
89
|
+
import { PAYLOAD_ROW_ATTRIBUTE } from "./payload.js";
|
|
90
|
+
|
|
91
|
+
/** How many chunks of one document are recorded before the rest are counted. */
|
|
92
|
+
const CHUNK_LIMIT = 64;
|
|
93
|
+
|
|
94
|
+
/** How far a scan will walk through one chunk's markup before giving up. */
|
|
95
|
+
const SCAN_LIMIT = 4096;
|
|
96
|
+
|
|
97
|
+
/** How many elements one boundary's line names before it counts them. */
|
|
98
|
+
const NAMED_LIMIT = 3;
|
|
99
|
+
|
|
100
|
+
/** The longest prefix of a chunk the boundary labels are read out of. */
|
|
101
|
+
const LABEL_SCAN_BYTES = 262144;
|
|
102
|
+
|
|
103
|
+
/** How many characters of a label survive into a line of the report. */
|
|
104
|
+
const LABEL_LIMIT = 60;
|
|
105
|
+
|
|
106
|
+
/** How many paths the "has this changed" memory keeps. */
|
|
107
|
+
const MEMORY_LIMIT = 64;
|
|
108
|
+
|
|
109
|
+
/** What Fizz opens a suspended boundary with; the id follows. */
|
|
110
|
+
const OPEN_MARKER = '<template id="B:';
|
|
111
|
+
|
|
112
|
+
/** What closes the fallback of a suspended boundary. */
|
|
113
|
+
const CLOSE_MARKER = "<!--/$-->";
|
|
114
|
+
|
|
115
|
+
/** What Fizz calls to swap a completed boundary in; the two ids follow. */
|
|
116
|
+
const COMPLETE_MARKER = '$RC("';
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* One boundary, as the chunk that completed it named it.
|
|
120
|
+
*
|
|
121
|
+
* `id` is React's — `"B:0"` — and not one of `./boundaries.js`'s. The two
|
|
122
|
+
* vocabularies are not joinable: React numbers boundaries as it meets them
|
|
123
|
+
* while rendering, `suspenseId` numbers a route's `$loading.js` entries, and
|
|
124
|
+
* a `<Suspense>` a component wrote itself is in the first sequence and not the
|
|
125
|
+
* second. Printing React's id and refusing to translate it is the honest
|
|
126
|
+
* option; the two labels below are what actually identifies the boundary for a
|
|
127
|
+
* reader, and both come off the page rather than out of a table.
|
|
128
|
+
*
|
|
129
|
+
* `fallback` is what the shell showed in its place and `built` what the
|
|
130
|
+
* completion put there, both as the short selectors `./boundaries.js` names an
|
|
131
|
+
* element with. `fallback` is `null` for a boundary whose opening marker this
|
|
132
|
+
* never saw — one past [`LABEL_SCAN_BYTES`], or split across two chunks.
|
|
133
|
+
*/
|
|
134
|
+
export type StreamedBoundary = {|
|
|
135
|
+
readonly id: string,
|
|
136
|
+
readonly fallback: ?string,
|
|
137
|
+
readonly built: $ReadOnlyArray<string>,
|
|
138
|
+
readonly more: number,
|
|
139
|
+
|};
|
|
140
|
+
|
|
141
|
+
/** One chunk of one document, as it went out. */
|
|
142
|
+
export type StreamedChunk = {|
|
|
143
|
+
/** Its place in the order, from one. */
|
|
144
|
+
readonly index: number,
|
|
145
|
+
/** Milliseconds after the first chunk. Zero for the first. */
|
|
146
|
+
readonly at: number,
|
|
147
|
+
/** Its size in bytes of UTF-8, which is what the socket carried. */
|
|
148
|
+
readonly bytes: number,
|
|
149
|
+
/** The boundaries it completed, in the order it completed them. */
|
|
150
|
+
readonly boundaries: $ReadOnlyArray<StreamedBoundary>,
|
|
151
|
+
|};
|
|
152
|
+
|
|
153
|
+
/** One document, as a stream. */
|
|
154
|
+
export type StreamRecord = {|
|
|
155
|
+
readonly chunks: $ReadOnlyArray<StreamedChunk>,
|
|
156
|
+
/** Chunks past [`CHUNK_LIMIT`], which are counted and not described. */
|
|
157
|
+
readonly more: number,
|
|
158
|
+
readonly bytes: number,
|
|
159
|
+
|};
|
|
160
|
+
|
|
161
|
+
/** A recorder, over one document. */
|
|
162
|
+
export type StreamRecorder = {|
|
|
163
|
+
readonly chunk: (text: string) => void,
|
|
164
|
+
readonly finish: () => StreamRecord,
|
|
165
|
+
|};
|
|
166
|
+
|
|
167
|
+
/** What the reporter hands `uf dev` to print. */
|
|
168
|
+
export type StreamDiagnostic = {|
|
|
169
|
+
readonly message: string,
|
|
170
|
+
readonly detail: $ReadOnlyArray<string>,
|
|
171
|
+
|};
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* A recorder for one document.
|
|
175
|
+
*
|
|
176
|
+
* `now` is a parameter rather than a reach for `performance.now`, so a test can
|
|
177
|
+
* assert the numbers in the report instead of asserting that there are some.
|
|
178
|
+
* The same reason `boundaryFindings` takes a document.
|
|
179
|
+
*/
|
|
180
|
+
export function inspectStream(now: () => number): StreamRecorder {
|
|
181
|
+
const chunks: Array<StreamedChunk> = [];
|
|
182
|
+
const labels: Map<string, string> = new Map();
|
|
183
|
+
const encoder = new TextEncoder();
|
|
184
|
+
let started: number | null = null;
|
|
185
|
+
let count = 0;
|
|
186
|
+
let bytes = 0;
|
|
187
|
+
|
|
188
|
+
return {
|
|
189
|
+
chunk(text: string): void {
|
|
190
|
+
const at = now();
|
|
191
|
+
// Read into a local before the branch below can assign it, so the
|
|
192
|
+
// subtraction is over a `number` rather than over a binding the checker
|
|
193
|
+
// still has to consider null.
|
|
194
|
+
const first = started;
|
|
195
|
+
if (first == null) {
|
|
196
|
+
started = at;
|
|
197
|
+
// The shell, and the only chunk the fallbacks are read out of: it is
|
|
198
|
+
// where React writes every boundary it has suspended, and a scan that
|
|
199
|
+
// continued into the completions would be scanning the page.
|
|
200
|
+
readFallbacks(text, labels);
|
|
201
|
+
}
|
|
202
|
+
count += 1;
|
|
203
|
+
const size = encoder.encode(text).length;
|
|
204
|
+
bytes += size;
|
|
205
|
+
if (chunks.length >= CHUNK_LIMIT) {
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
chunks.push({
|
|
209
|
+
index: count,
|
|
210
|
+
at: first == null ? 0 : Math.max(0, Math.round(at - first)),
|
|
211
|
+
bytes: size,
|
|
212
|
+
boundaries: completedIn(text, labels),
|
|
213
|
+
});
|
|
214
|
+
},
|
|
215
|
+
finish(): StreamRecord {
|
|
216
|
+
return { chunks, more: Math.max(0, count - chunks.length), bytes };
|
|
217
|
+
},
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* `chunks`, unchanged, with `report` told what went past.
|
|
223
|
+
*
|
|
224
|
+
* A pass-through generator rather than a hook inside `./stream.js`'s assembly,
|
|
225
|
+
* because what this is about is the bytes that actually left — after the head
|
|
226
|
+
* has been put in and after `transformHead` has had the opening chunk. A
|
|
227
|
+
* recorder further up would be describing a document nobody received.
|
|
228
|
+
*
|
|
229
|
+
* `finally`, so a render that was abandoned still reports what it managed to
|
|
230
|
+
* send. A consumer that gives up is one of the things worth seeing.
|
|
231
|
+
*/
|
|
232
|
+
export async function* inspected(
|
|
233
|
+
chunks: AsyncGenerator<string, void, void>,
|
|
234
|
+
report: (record: StreamRecord) => void,
|
|
235
|
+
now: () => number,
|
|
236
|
+
): AsyncGenerator<string, void, void> {
|
|
237
|
+
const recorder = inspectStream(now);
|
|
238
|
+
try {
|
|
239
|
+
for await (const chunk of chunks) {
|
|
240
|
+
recorder.chunk(chunk);
|
|
241
|
+
yield chunk;
|
|
242
|
+
}
|
|
243
|
+
} finally {
|
|
244
|
+
report(recorder.finish());
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* The fallback each suspended boundary in the shell is showing.
|
|
250
|
+
*
|
|
251
|
+
* Fills `labels` in place, keyed by React's boundary id. Bounded twice — by
|
|
252
|
+
* [`LABEL_SCAN_BYTES`] of the chunk and by [`SCAN_LIMIT`] boundaries — because
|
|
253
|
+
* the thing being scanned is a page.
|
|
254
|
+
*/
|
|
255
|
+
function readFallbacks(chunk: string, labels: Map<string, string>): void {
|
|
256
|
+
const text = chunk.length > LABEL_SCAN_BYTES ? chunk.slice(0, LABEL_SCAN_BYTES) : chunk;
|
|
257
|
+
let from = 0;
|
|
258
|
+
for (let step = 0; step < SCAN_LIMIT; step += 1) {
|
|
259
|
+
const open = text.indexOf(OPEN_MARKER, from);
|
|
260
|
+
if (open === -1) {
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
const idAt = open + OPEN_MARKER.length;
|
|
264
|
+
const idEnd = text.indexOf('"', idAt);
|
|
265
|
+
if (idEnd === -1) {
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
// The fallback begins after the empty `<template>` React opened with and
|
|
269
|
+
// ends at the comment that closes the boundary. A boundary nested inside
|
|
270
|
+
// this one closes first, so the run taken here can be short — which costs a
|
|
271
|
+
// less specific label and never a wrong one, since the label is only ever
|
|
272
|
+
// the first element of it.
|
|
273
|
+
const tagEnd = text.indexOf(">", idEnd);
|
|
274
|
+
if (tagEnd === -1) {
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
const close = text.indexOf(CLOSE_MARKER, tagEnd);
|
|
278
|
+
const fallback = text.slice(tagEnd + 1, close === -1 ? text.length : close);
|
|
279
|
+
const named = firstElement(fallback);
|
|
280
|
+
if (named != null) {
|
|
281
|
+
labels.set(`B:${text.slice(idAt, idEnd)}`, named);
|
|
282
|
+
}
|
|
283
|
+
from = close === -1 ? tagEnd + 1 : close + CLOSE_MARKER.length;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* The boundaries `chunk` completed, in the order it completed them.
|
|
289
|
+
*
|
|
290
|
+
* Driven by `$RC("B:0","S:0")` rather than by the hidden element, because that
|
|
291
|
+
* call is React's own statement that the two are the same boundary — and
|
|
292
|
+
* because it is the last thing written for a completion, so a chunk that
|
|
293
|
+
* contains it contains the content too.
|
|
294
|
+
*/
|
|
295
|
+
function completedIn(chunk: string, labels: Map<string, string>): $ReadOnlyArray<StreamedBoundary> {
|
|
296
|
+
const found: Array<StreamedBoundary> = [];
|
|
297
|
+
let from = 0;
|
|
298
|
+
for (let step = 0; step < SCAN_LIMIT; step += 1) {
|
|
299
|
+
const call = chunk.indexOf(COMPLETE_MARKER, from);
|
|
300
|
+
if (call === -1) {
|
|
301
|
+
return found;
|
|
302
|
+
}
|
|
303
|
+
from = call + COMPLETE_MARKER.length;
|
|
304
|
+
const ids = argumentPair(chunk, from);
|
|
305
|
+
if (ids == null) {
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
const built = builtBy(chunk, ids.content);
|
|
309
|
+
found.push({
|
|
310
|
+
id: ids.boundary,
|
|
311
|
+
fallback: labels.get(ids.boundary) ?? null,
|
|
312
|
+
built: built.slice(0, NAMED_LIMIT),
|
|
313
|
+
more: Math.max(0, built.length - NAMED_LIMIT),
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
return found;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** The two quoted ids of a `$RC(…)` call whose first quote has been passed. */
|
|
320
|
+
function argumentPair(
|
|
321
|
+
chunk: string,
|
|
322
|
+
from: number,
|
|
323
|
+
): {| readonly boundary: string, readonly content: string |} | null {
|
|
324
|
+
const boundaryEnd = chunk.indexOf('"', from);
|
|
325
|
+
if (boundaryEnd === -1 || !chunk.startsWith(',"', boundaryEnd + 1)) {
|
|
326
|
+
return null;
|
|
327
|
+
}
|
|
328
|
+
const contentAt = boundaryEnd + 3;
|
|
329
|
+
const contentEnd = chunk.indexOf('"', contentAt);
|
|
330
|
+
if (contentEnd === -1) {
|
|
331
|
+
return null;
|
|
332
|
+
}
|
|
333
|
+
return {
|
|
334
|
+
boundary: chunk.slice(from, boundaryEnd),
|
|
335
|
+
content: chunk.slice(contentAt, contentEnd),
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* The elements a completion put on the page, as short selectors.
|
|
341
|
+
*
|
|
342
|
+
* The content arrives inside a hidden element carrying the id `$RC`'s second
|
|
343
|
+
* argument names, and what the boundary owns afterwards is that element's own
|
|
344
|
+
* children — so this finds the element by its id, walks past its opening tag,
|
|
345
|
+
* and collects the tags that sit at depth zero under it.
|
|
346
|
+
*/
|
|
347
|
+
function builtBy(chunk: string, contentId: string): $ReadOnlyArray<string> {
|
|
348
|
+
const marker = `id="${contentId}"`;
|
|
349
|
+
const at = chunk.indexOf(marker);
|
|
350
|
+
if (at === -1) {
|
|
351
|
+
return [];
|
|
352
|
+
}
|
|
353
|
+
const tagEnd = chunk.indexOf(">", at + marker.length);
|
|
354
|
+
if (tagEnd === -1) {
|
|
355
|
+
return [];
|
|
356
|
+
}
|
|
357
|
+
return topLevelElements(chunk, tagEnd + 1);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* The elements at depth zero of the run beginning at `from`, until it closes.
|
|
362
|
+
*
|
|
363
|
+
* A depth walk rather than "every tag", because a boundary that built one
|
|
364
|
+
* `<article>` with forty `<p>` in it built one thing, and naming the forty
|
|
365
|
+
* would say less than naming the one. Void elements are self-closed by React,
|
|
366
|
+
* which is what makes a single counter enough here — see the header.
|
|
367
|
+
*/
|
|
368
|
+
function topLevelElements(markup: string, from: number): $ReadOnlyArray<string> {
|
|
369
|
+
const found: Array<string> = [];
|
|
370
|
+
let depth = 0;
|
|
371
|
+
let index = from;
|
|
372
|
+
for (let step = 0; step < SCAN_LIMIT; step += 1) {
|
|
373
|
+
const open = markup.indexOf("<", index);
|
|
374
|
+
if (open === -1) {
|
|
375
|
+
return found;
|
|
376
|
+
}
|
|
377
|
+
if (markup.startsWith("<!--", open)) {
|
|
378
|
+
const end = markup.indexOf("-->", open);
|
|
379
|
+
if (end === -1) {
|
|
380
|
+
return found;
|
|
381
|
+
}
|
|
382
|
+
index = end + 3;
|
|
383
|
+
continue;
|
|
384
|
+
}
|
|
385
|
+
const end = markup.indexOf(">", open);
|
|
386
|
+
if (end === -1) {
|
|
387
|
+
return found;
|
|
388
|
+
}
|
|
389
|
+
index = end + 1;
|
|
390
|
+
if (markup.startsWith("</", open)) {
|
|
391
|
+
// The close of the element the walk began inside: the run is over.
|
|
392
|
+
if (depth === 0) {
|
|
393
|
+
return found;
|
|
394
|
+
}
|
|
395
|
+
depth -= 1;
|
|
396
|
+
continue;
|
|
397
|
+
}
|
|
398
|
+
if (depth === 0) {
|
|
399
|
+
found.push(describeTag(markup.slice(open, end + 1)));
|
|
400
|
+
}
|
|
401
|
+
if (markup[end - 1] !== "/") {
|
|
402
|
+
depth += 1;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
return found;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* One opening tag, short enough to sit in a line of a report.
|
|
410
|
+
*
|
|
411
|
+
* The same three rules `describeElement` in `./boundaries.js` applies to a live
|
|
412
|
+
* element — the id if it has one, otherwise the first class, otherwise the tag
|
|
413
|
+
* — because the two reports are about the same page and a reader should not
|
|
414
|
+
* have to learn that `article.post` there and `article.post` here mean the same
|
|
415
|
+
* thing. They cannot share an implementation: that one is handed a DOM node and
|
|
416
|
+
* this one a string of markup, in a module that has no DOM. `inspector.test.js`
|
|
417
|
+
* holds the two spellings against each other over the same elements.
|
|
418
|
+
*/
|
|
419
|
+
export function describeTag(tag: string): string {
|
|
420
|
+
const space = tag.search(/[\s/>]/);
|
|
421
|
+
const name = tag.slice(1, space === -1 ? tag.length : space).toLowerCase();
|
|
422
|
+
const row = name === "script" ? attribute(tag, PAYLOAD_ROW_ATTRIBUTE) : null;
|
|
423
|
+
if (row != null && row !== "") {
|
|
424
|
+
return `deferred payload row ${row}`;
|
|
425
|
+
}
|
|
426
|
+
const id = attribute(tag, "id");
|
|
427
|
+
if (id != null && id !== "") {
|
|
428
|
+
return `${name}#${id}`;
|
|
429
|
+
}
|
|
430
|
+
const className = attribute(tag, "class");
|
|
431
|
+
const first = className == null ? "" : className.trim().split(/\s+/)[0];
|
|
432
|
+
return first === "" ? name : `${name}.${first}`;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/** One double-quoted attribute of an opening tag, which is how React writes them. */
|
|
436
|
+
function attribute(tag: string, name: string): ?string {
|
|
437
|
+
const marker = ` ${name}="`;
|
|
438
|
+
const at = tag.indexOf(marker);
|
|
439
|
+
if (at === -1) {
|
|
440
|
+
return null;
|
|
441
|
+
}
|
|
442
|
+
const from = at + marker.length;
|
|
443
|
+
const end = tag.indexOf('"', from);
|
|
444
|
+
return end === -1 ? null : tag.slice(from, end);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/** The first element of a run of markup, as a short selector. */
|
|
448
|
+
function firstElement(markup: string): ?string {
|
|
449
|
+
for (let index = 0, step = 0; step < SCAN_LIMIT; step += 1) {
|
|
450
|
+
const open = markup.indexOf("<", index);
|
|
451
|
+
if (open === -1) {
|
|
452
|
+
return null;
|
|
453
|
+
}
|
|
454
|
+
if (markup.startsWith("<!--", open)) {
|
|
455
|
+
const end = markup.indexOf("-->", open);
|
|
456
|
+
if (end === -1) {
|
|
457
|
+
return null;
|
|
458
|
+
}
|
|
459
|
+
index = end + 3;
|
|
460
|
+
continue;
|
|
461
|
+
}
|
|
462
|
+
const end = markup.indexOf(">", open);
|
|
463
|
+
if (end === -1) {
|
|
464
|
+
return null;
|
|
465
|
+
}
|
|
466
|
+
if (markup.startsWith("</", open)) {
|
|
467
|
+
index = end + 1;
|
|
468
|
+
continue;
|
|
469
|
+
}
|
|
470
|
+
return describeTag(markup.slice(open, end + 1));
|
|
471
|
+
}
|
|
472
|
+
return null;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* Whether this document streamed at all.
|
|
477
|
+
*
|
|
478
|
+
* One chunk is a document that was finished before it was sent, and there is
|
|
479
|
+
* nothing about order or arrival to say about it. See the header.
|
|
480
|
+
*/
|
|
481
|
+
export function didStream(record: StreamRecord): boolean {
|
|
482
|
+
return record.chunks.some((chunk) => chunk.boundaries.length > 0);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* The stream as one comparable string.
|
|
487
|
+
*
|
|
488
|
+
* Which boundaries completed, in which chunk, and what each replaced with what
|
|
489
|
+
* — everything a reader would notice — and not the timings or the byte counts,
|
|
490
|
+
* which differ on every request. See the header.
|
|
491
|
+
*/
|
|
492
|
+
export function streamSignature(record: StreamRecord): string {
|
|
493
|
+
const parts: Array<string> = [];
|
|
494
|
+
for (const chunk of record.chunks) {
|
|
495
|
+
for (const boundary of chunk.boundaries) {
|
|
496
|
+
parts.push(
|
|
497
|
+
`${chunk.index}:${boundary.id}:${boundary.fallback ?? ""}>${boundary.built.join(",")}`,
|
|
498
|
+
);
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
return parts.join("|");
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* The report, as the terminal will print it.
|
|
506
|
+
*
|
|
507
|
+
* Separated from the sending so a test can pin the wording, which is the part
|
|
508
|
+
* worth pinning: somebody reading this in a terminal has to be able to act on
|
|
509
|
+
* it without opening this file.
|
|
510
|
+
*/
|
|
511
|
+
export function formatStream(path: string, record: StreamRecord): StreamDiagnostic {
|
|
512
|
+
const described = record.chunks.filter((chunk) => chunk.boundaries.length > 0);
|
|
513
|
+
const count = described.reduce((total, chunk) => total + chunk.boundaries.length, 0);
|
|
514
|
+
const last = described[described.length - 1];
|
|
515
|
+
const total = record.chunks.length + record.more;
|
|
516
|
+
const detail: Array<string> = [
|
|
517
|
+
`the shell went out in ${size(record.chunks[0]?.bytes ?? 0)}, with ${
|
|
518
|
+
count === 1 ? "1 fallback" : `${count} fallbacks`
|
|
519
|
+
} in it`,
|
|
520
|
+
];
|
|
521
|
+
for (const chunk of described) {
|
|
522
|
+
for (const boundary of chunk.boundaries) {
|
|
523
|
+
detail.push(describeBoundary(chunk, boundary, total));
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
return {
|
|
527
|
+
// The boundaries and not the chunks, because the chunk count answers a
|
|
528
|
+
// question nobody has: a document is cut wherever React happened to flush,
|
|
529
|
+
// and two of the pieces are uf's own closing markup. What a reader wants
|
|
530
|
+
// from one line is that the page streamed, how much of it arrived late, and
|
|
531
|
+
// how late. The chunk each boundary rode in on is in its own line below.
|
|
532
|
+
message:
|
|
533
|
+
`${path} streamed ${count === 1 ? "1 boundary" : `${count} boundaries`} after its shell` +
|
|
534
|
+
`, the last ${last == null ? 0 : last.at} ms in`,
|
|
535
|
+
detail,
|
|
536
|
+
};
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/** One completed boundary as one line: which, when, how big, and what it built. */
|
|
540
|
+
function describeBoundary(chunk: StreamedChunk, boundary: StreamedBoundary, total: number): string {
|
|
541
|
+
// Every name on this line came off the page — a class an application chose,
|
|
542
|
+
// an id it generated — so every one of them goes through `label`.
|
|
543
|
+
const named =
|
|
544
|
+
boundary.built.length === 0 ? "nothing of its own" : boundary.built.map(label).join(", ");
|
|
545
|
+
const more = boundary.more === 0 ? "" : ` and ${boundary.more} more`;
|
|
546
|
+
const replaced = boundary.fallback == null ? "its fallback" : label(boundary.fallback);
|
|
547
|
+
return (
|
|
548
|
+
`+${chunk.at} ms ${boundary.id} replaced ${replaced} with ${named}${more}` +
|
|
549
|
+
` (chunk ${chunk.index} of ${total}, ${size(chunk.bytes)})`
|
|
550
|
+
);
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* A label off the page, short enough and quiet enough for a terminal.
|
|
555
|
+
*
|
|
556
|
+
* Nothing the application wrote may move a cursor or set a colour. The browser
|
|
557
|
+
* channel scrubs what it receives in `@uniflowed/vite`'s
|
|
558
|
+
* `internal/diagnostics.js`; this report never goes through it, so it scrubs
|
|
559
|
+
* its own.
|
|
560
|
+
*
|
|
561
|
+
* By code unit rather than by code point, which is the same answer and a
|
|
562
|
+
* cheaper one: every control character is below `0x20` or is `0x7f`, and both
|
|
563
|
+
* halves of a surrogate pair are far above either — so a pair is copied
|
|
564
|
+
* through unchanged, one half at a time.
|
|
565
|
+
*/
|
|
566
|
+
function label(text: string): string {
|
|
567
|
+
let out = "";
|
|
568
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
569
|
+
if (out.length >= LABEL_LIMIT) {
|
|
570
|
+
return `${out}…`;
|
|
571
|
+
}
|
|
572
|
+
const code = text.charCodeAt(index);
|
|
573
|
+
out += code < 0x20 || code === 0x7f ? " " : text[index];
|
|
574
|
+
}
|
|
575
|
+
return out;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/** A byte count, in the units a person reads. */
|
|
579
|
+
function size(bytes: number): string {
|
|
580
|
+
return bytes < 1024 ? `${bytes} B` : `${Math.round(bytes / 102.4) / 10} kB`;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/** The last stream shape seen for each path. */
|
|
584
|
+
const seen: Map<string, string> = new Map();
|
|
585
|
+
|
|
586
|
+
/**
|
|
587
|
+
* A reporter for one request, or `null` when nothing is listening.
|
|
588
|
+
*
|
|
589
|
+
* `uf dev` supplies `send`; every other host supplies nothing, and the whole of
|
|
590
|
+
* this module is then unreachable from the render — `./stream.js` never
|
|
591
|
+
* constructs a recorder, so a production stream is byte for byte and generator
|
|
592
|
+
* for generator what it was.
|
|
593
|
+
*/
|
|
594
|
+
export function streamReporter(
|
|
595
|
+
path: string,
|
|
596
|
+
send: (diagnostic: StreamDiagnostic) => void,
|
|
597
|
+
): (record: StreamRecord) => void {
|
|
598
|
+
return (record: StreamRecord) => {
|
|
599
|
+
if (!didStream(record)) {
|
|
600
|
+
return;
|
|
601
|
+
}
|
|
602
|
+
const next = streamSignature(record);
|
|
603
|
+
const previous = seen.get(path);
|
|
604
|
+
if (seen.size >= MEMORY_LIMIT && previous === undefined) {
|
|
605
|
+
const oldest = seen.keys().next();
|
|
606
|
+
if (!oldest.done) {
|
|
607
|
+
seen.delete(oldest.value);
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
seen.set(path, next);
|
|
611
|
+
if (previous === next) {
|
|
612
|
+
return;
|
|
613
|
+
}
|
|
614
|
+
send(formatStream(path, record));
|
|
615
|
+
};
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/**
|
|
619
|
+
* Forget every path this module has seen.
|
|
620
|
+
*
|
|
621
|
+
* For tests, which share one module registry across files and would otherwise
|
|
622
|
+
* inherit a path's history from whichever file rendered it first.
|
|
623
|
+
*/
|
|
624
|
+
export function forgetStreams(): void {
|
|
625
|
+
seen.clear();
|
|
626
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
import type { RouteTable } from "./routing.js";
|
|
3
|
+
import { hasClientPage, matchRoute } from "./routing.js";
|
|
4
|
+
|
|
5
|
+
type Table = RouteTable<mixed, mixed, mixed, mixed, mixed>;
|
|
6
|
+
|
|
7
|
+
/** One parser for cold starts, warm deliveries and programmatic navigation. */
|
|
8
|
+
export function nativeLinkHref(table: Table, to: string): string | null {
|
|
9
|
+
const links = table.nativeLinks;
|
|
10
|
+
if (links == null || !/^https:\/\//i.test(to) || /[\\\u0000-\u0020]/.test(to)) return null;
|
|
11
|
+
try {
|
|
12
|
+
const url = new URL(to);
|
|
13
|
+
if (url.username !== "" || url.password !== "" || url.hash !== "") return null;
|
|
14
|
+
// URL does not reject malformed percent escapes. Encoded separators must
|
|
15
|
+
// not change the route when another layer decodes the path a second time.
|
|
16
|
+
decodeURIComponent(url.pathname + url.search);
|
|
17
|
+
if (/%(?:2f|5c)/i.test(url.pathname)) return null;
|
|
18
|
+
if (!links.origins.some((origin) => new URL(origin).origin === url.origin)) return null;
|
|
19
|
+
const match = matchRoute(table.routes, url.pathname);
|
|
20
|
+
if (match == null || !hasClientPage(match.route) || !links.routes.includes(match.route.path))
|
|
21
|
+
return null;
|
|
22
|
+
return url.pathname + url.search;
|
|
23
|
+
} catch {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type NativeLinkSource = {|
|
|
29
|
+
readonly getInitialURL: () => Promise<string | null>,
|
|
30
|
+
readonly addEventListener: (
|
|
31
|
+
event: "url",
|
|
32
|
+
listener: (event: {| url: string |}) => void,
|
|
33
|
+
) => {| remove: () => void |},
|
|
34
|
+
|};
|
|
35
|
+
|
|
36
|
+
/** Pass React Native's Linking; rejected links stay with the OS/browser. */
|
|
37
|
+
export function createNativeLinking(
|
|
38
|
+
table: Table,
|
|
39
|
+
source: NativeLinkSource,
|
|
40
|
+
): {|
|
|
41
|
+
getInitialURL: () => Promise<string | null>,
|
|
42
|
+
subscribe: (listener: (href: string) => void) => () => void,
|
|
43
|
+
|} {
|
|
44
|
+
let last: string | null = null;
|
|
45
|
+
let deliveredAt = -Infinity;
|
|
46
|
+
function receive(url: string): string | null {
|
|
47
|
+
const href = nativeLinkHref(table, url);
|
|
48
|
+
const now = Date.now();
|
|
49
|
+
if (href == null || (href === last && now - deliveredAt < 1000)) return null;
|
|
50
|
+
last = href;
|
|
51
|
+
deliveredAt = now;
|
|
52
|
+
return href;
|
|
53
|
+
}
|
|
54
|
+
return {
|
|
55
|
+
async getInitialURL() {
|
|
56
|
+
const url = await source.getInitialURL();
|
|
57
|
+
return url == null ? null : receive(url);
|
|
58
|
+
},
|
|
59
|
+
subscribe(listener) {
|
|
60
|
+
const subscription = source.addEventListener("url", ({ url }) => {
|
|
61
|
+
const href = receive(url);
|
|
62
|
+
if (href != null) listener(href);
|
|
63
|
+
});
|
|
64
|
+
return () => subscription.remove();
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
}
|