crawlforge-extractors 1.9.0 → 1.11.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/src/flight.js ADDED
@@ -0,0 +1,412 @@
1
+ /**
2
+ * flight.js — React Server Components ("flight") streams: split the stream into
3
+ * rows, resolve the references between rows, and index the rows that carry data.
4
+ *
5
+ * Pure: strings and parsed JSON in, plain objects out. Every walk below is
6
+ * iterative and every output is bounded, because the stream is page content —
7
+ * an attacker can make it arbitrarily deep, long, or self-referential.
8
+ */
9
+
10
+ /**
11
+ * Split a concatenated RSC flight stream into its rows.
12
+ *
13
+ * The stream is a sequence of `<hexId>:<payload>\n` rows. Four payload shapes
14
+ * matter:
15
+ * - `T<hexByteLength>,` — a length-prefixed text blob. The length is in
16
+ * UTF-8 BYTES and INCLUDES the row's terminating newline, so the cursor
17
+ * advances exactly that many bytes and no further. (Advancing one extra
18
+ * character for a newline silently eats the first hex digit of the next
19
+ * row id, turning row "14" into row "4" and overwriting an unrelated row —
20
+ * verified against the live Healthgrades capture, where it produced seven
21
+ * colliding ids.)
22
+ * - `I[...]` / `HL[...]` — module and hint references. Not JSON; kept as the
23
+ * raw string so the caller can still see which components a page loads.
24
+ * - `E{...}` — a server error row; parsed to `{ $error: <parsed> }`.
25
+ * - anything else — JSON, parsed.
26
+ *
27
+ * @param {string} stream
28
+ * @returns {Record<string, unknown>} row id -> value
29
+ */
30
+ export function parseFlightRows(stream) {
31
+ const rows = {};
32
+ let cursor = 0;
33
+
34
+ while (cursor < stream.length) {
35
+ const newline = stream.indexOf('\n', cursor);
36
+ const lineEnd = newline === -1 ? stream.length : newline;
37
+ const header = stream.slice(cursor, lineEnd).match(/^([0-9a-f]+):/i);
38
+ if (!header) {
39
+ // Not a row start: a chunk boundary landed mid-row, or the stream was
40
+ // truncated. Resync on the next line rather than giving up.
41
+ cursor = lineEnd + 1;
42
+ continue;
43
+ }
44
+
45
+ const id = header[1];
46
+ const payloadStart = cursor + header[0].length;
47
+ const payload = stream.slice(payloadStart, lineEnd);
48
+
49
+ const textRow = payload.match(/^T([0-9a-f]+),/i);
50
+ if (textRow) {
51
+ const blobStart = payloadStart + textRow[0].length;
52
+ const byteLen = parseInt(textRow[1], 16);
53
+ // Decode only up to byteLen bytes. Slicing the whole remaining stream on
54
+ // every text row makes this O(N) per row -> O(N^2) for an
55
+ // attacker-controlled stream of many small text rows. byteLen bytes span
56
+ // at most byteLen characters, so bounding the slice to that many chars
57
+ // keeps the work linear and yields the identical decoded blob.
58
+ const text = Buffer.from(stream.slice(blobStart, blobStart + byteLen), 'utf8')
59
+ .subarray(0, byteLen)
60
+ .toString('utf8');
61
+ rows[id] = text;
62
+ cursor = blobStart + text.length;
63
+ continue;
64
+ }
65
+
66
+ if (payload[0] === 'E') {
67
+ try {
68
+ rows[id] = { $error: JSON.parse(payload.slice(1)) };
69
+ } catch {
70
+ rows[id] = payload;
71
+ }
72
+ cursor = lineEnd + 1;
73
+ continue;
74
+ }
75
+
76
+ try {
77
+ rows[id] = JSON.parse(payload);
78
+ } catch {
79
+ rows[id] = payload;
80
+ }
81
+ cursor = lineEnd + 1;
82
+ }
83
+
84
+ return rows;
85
+ }
86
+
87
+ // A model reference: `$<hex>` (outlined model), `$@<hex>` (promise), either
88
+ // with an optional `:prop:prop…` property path into the referenced value.
89
+ // Row ids are lowercase hex (React writes them with toString(16)); the
90
+ // uppercase and non-hex tags — `$L` lazy, `$S` symbol, `$D` date, `$F` server
91
+ // reference, `$n` bigint, `$u`ndefined, … — never match.
92
+ const REF_RE = /^\$@?([0-9a-f]+)((?::[^:]*)*)$/;
93
+
94
+ // React writes an element as ["$", type, key, props] and addresses its parts
95
+ // by the element object's field names. A path walking the raw tuple maps them.
96
+ const ELEMENT_FIELDS = { type: 1, key: 2, props: 3 };
97
+
98
+ const isElement = (value) => Array.isArray(value) && value.length >= 4 && value[0] === '$';
99
+
100
+ const IN_PROGRESS = -1;
101
+
102
+ const scalarBytes = (value) => {
103
+ let json;
104
+ try {
105
+ json = JSON.stringify(value);
106
+ } catch {
107
+ json = undefined;
108
+ }
109
+ return Buffer.byteLength(json ?? 'null');
110
+ };
111
+
112
+ /**
113
+ * The UTF-8 size of a value's JSON serialization, computed without
114
+ * serializing it. `memo` caches every container's size, so a value whose
115
+ * subtrees are shared (as resolved rows are) costs one visit per distinct
116
+ * container — never the size of its expansion. Iterative, so depth is free; a
117
+ * cycle counts as 4 bytes instead of looping.
118
+ *
119
+ * @param {unknown} root
120
+ * @param {WeakMap<object, number>} memo
121
+ * @returns {number}
122
+ */
123
+ function serializedSize(root, memo) {
124
+ if (root === null || typeof root !== 'object') return scalarBytes(root);
125
+ const stack = [root];
126
+ while (stack.length > 0) {
127
+ const node = stack[stack.length - 1];
128
+ const state = memo.get(node);
129
+ if (state === undefined) {
130
+ memo.set(node, IN_PROGRESS);
131
+ for (const key of Object.keys(node)) {
132
+ const child = node[key];
133
+ if (child !== null && typeof child === 'object' && memo.get(child) === undefined) stack.push(child);
134
+ }
135
+ continue;
136
+ }
137
+ stack.pop();
138
+ if (state !== IN_PROGRESS) continue;
139
+
140
+ const keys = Object.keys(node);
141
+ const isArray = Array.isArray(node);
142
+ let size = 2 + Math.max(0, keys.length - 1);
143
+ for (const key of keys) {
144
+ const child = node[key];
145
+ if (!isArray) size += Buffer.byteLength(JSON.stringify(key)) + 1;
146
+ if (child !== null && typeof child === 'object') {
147
+ const childSize = memo.get(child);
148
+ size += childSize === IN_PROGRESS ? 4 : childSize;
149
+ } else {
150
+ size += scalarBytes(child);
151
+ }
152
+ }
153
+ memo.set(node, size);
154
+ }
155
+ return memo.get(root);
156
+ }
157
+
158
+ // Own-property write; `dst.__proto__ = v` would set the prototype instead.
159
+ const setOwn = (dst, key, value) => {
160
+ if (key === '__proto__') {
161
+ Object.defineProperty(dst, key, { value, enumerable: true, writable: true, configurable: true });
162
+ } else {
163
+ dst[key] = value;
164
+ }
165
+ };
166
+
167
+ /**
168
+ * Resolve the model references between flight rows.
169
+ *
170
+ * A string `$<hex>`, `$@<hex>` or `$<hex>:<prop>:…` is replaced by the
171
+ * referenced row's resolved value (or the property it names); `$$…` inside a
172
+ * row is the escaped literal `$…`. Every other `$` token — `$L` lazy
173
+ * components, `$S`, `$undefined`, the element marker `"$"` — is left as is.
174
+ * A reference to an unknown row, or a property path that does not resolve,
175
+ * stays the original string.
176
+ *
177
+ * React deduplicates an object it has already written in the same row as a
178
+ * path reference to its first occurrence (`$10:1:2:props:providerModel` inside
179
+ * row 10). The row is copied in document order, so that first occurrence is
180
+ * complete by the time the reference is read, and it resolves against the
181
+ * copy. A reference to something still being built — a row reachable from
182
+ * itself, or a container that encloses the reference — becomes
183
+ * `{ $ref: "<id>" }`.
184
+ *
185
+ * Inlined values are shared, not copied, so memory stays linear — but their
186
+ * serialization is not. `budgetBytes` caps the bytes inlining may add to the
187
+ * serialized output (default: the serialized size of `rows`, so resolution at
188
+ * most doubles the payload); a reference that would exceed it stays a string
189
+ * and counts in `overBudget`. That is what keeps a `row n = [$n-1, $n-1]`
190
+ * chain from expanding to 2^n.
191
+ *
192
+ * A top-level string row (an `I`/`HL`/`T` row, or a JSON string row) is only
193
+ * resolved when the whole string is a reference — `e:"$9:metadata"` — and is
194
+ * never unescaped, since text rows carry no escaping.
195
+ *
196
+ * @param {Record<string, unknown>} rows row id -> value, from parseFlightRows
197
+ * @param {{ budgetBytes?: number }} [options]
198
+ * @returns {{ rows: Record<string, unknown>, resolved: number, cycles: number, overBudget: number }}
199
+ */
200
+ export function resolveFlightRows(rows, { budgetBytes } = {}) {
201
+ const out = {};
202
+ const stats = { resolved: 0, cycles: 0, overBudget: 0 };
203
+ if (rows === null || typeof rows !== 'object') return { rows: out, ...stats };
204
+
205
+ const ids = Object.keys(rows);
206
+ const sizes = new WeakMap();
207
+ let budget = typeof budgetBytes === 'number' && budgetBytes >= 0 ? budgetBytes : serializedSize(rows, sizes);
208
+
209
+ const ON_STACK = 1;
210
+ const DONE = 2;
211
+ const state = new Map();
212
+
213
+ const refTarget = (string) => {
214
+ const match = REF_RE.exec(string);
215
+ return match && Object.hasOwn(rows, match[1]) ? match : null;
216
+ };
217
+
218
+ // The other rows a row references, in document order, deduplicated.
219
+ const dependencies = (id) => {
220
+ const value = rows[id];
221
+ const found = new Set();
222
+ const note = (string) => {
223
+ const match = string[0] === '$' ? refTarget(string) : null;
224
+ if (match && match[1] !== id) found.add(match[1]);
225
+ };
226
+ if (typeof value === 'string') note(value);
227
+ if (value === null || typeof value !== 'object') return [...found];
228
+ const seen = new WeakSet([value]);
229
+ const stack = [value];
230
+ while (stack.length > 0) {
231
+ const node = stack.pop();
232
+ for (const key of Object.keys(node)) {
233
+ const child = node[key];
234
+ if (typeof child === 'string') {
235
+ note(child);
236
+ } else if (child !== null && typeof child === 'object' && !seen.has(child)) {
237
+ seen.add(child);
238
+ stack.push(child);
239
+ }
240
+ }
241
+ }
242
+ return [...found];
243
+ };
244
+
245
+ /**
246
+ * @param {string} string
247
+ * @param {{ id: string, root: object, open: Set<object> } | null} row the
248
+ * row being copied, when the string sits inside one
249
+ */
250
+ const resolveString = (string, row) => {
251
+ if (string[0] !== '$') return string;
252
+ if (row && string[1] === '$') return string.slice(1);
253
+ const match = refTarget(string);
254
+ if (!match) return string;
255
+ const id = match[1];
256
+ // A string row is markup, not model data: a `T` text blob (Healthgrades
257
+ // inlines two of ~340 KB each through dangerouslySetInnerHTML), an `I`/`HL`
258
+ // module reference or `$Sreact.fragment`. Inlining them spent the whole
259
+ // budget before any data reference was reached, so they stay references.
260
+ if (typeof rows[id] === 'string') return string;
261
+
262
+ let value;
263
+ if (row && id === row.id) {
264
+ value = row.root;
265
+ } else if (state.get(id) === DONE) {
266
+ value = out[id];
267
+ } else {
268
+ // Every dependency finishes before its dependant, so a target that is
269
+ // not done yet is an ancestor on the DFS stack: a cycle.
270
+ stats.cycles++;
271
+ return { $ref: id };
272
+ }
273
+
274
+ if (match[2]) {
275
+ for (const prop of match[2].slice(1).split(':')) {
276
+ const key = isElement(value) && Object.hasOwn(ELEMENT_FIELDS, prop) ? ELEMENT_FIELDS[prop] : prop;
277
+ if (value === null || typeof value !== 'object' || !Object.hasOwn(value, key)) return string;
278
+ value = value[key];
279
+ }
280
+ }
281
+ if (row && row.open.has(value)) {
282
+ stats.cycles++;
283
+ return { $ref: id };
284
+ }
285
+
286
+ const added = serializedSize(value, sizes) - Buffer.byteLength(JSON.stringify(string));
287
+ if (added > budget) {
288
+ stats.overBudget++;
289
+ return string;
290
+ }
291
+ if (added > 0) budget -= added;
292
+ stats.resolved++;
293
+ return value;
294
+ };
295
+
296
+ // Copy a row in document order (pre-order), resolving references as they
297
+ // are met. `open` holds the containers whose subtree is not finished yet; a
298
+ // reference landing on one of them would enclose itself.
299
+ const resolveRow = (id) => {
300
+ const raw = rows[id];
301
+ if (typeof raw === 'string') return resolveString(raw, null);
302
+ if (raw === null || typeof raw !== 'object') return raw;
303
+
304
+ const root = Array.isArray(raw) ? [] : {};
305
+ const row = { id, root, open: new Set([root]) };
306
+ const copies = new Map([[raw, root]]);
307
+ const stack = [{ close: root }];
308
+ const pushChildren = (src, dst) => {
309
+ const keys = Object.keys(src);
310
+ for (let i = keys.length - 1; i >= 0; i--) stack.push({ src: src[keys[i]], dst, key: keys[i] });
311
+ };
312
+ pushChildren(raw, root);
313
+
314
+ while (stack.length > 0) {
315
+ const entry = stack.pop();
316
+ if (entry.close) {
317
+ row.open.delete(entry.close);
318
+ continue;
319
+ }
320
+ const { src, dst, key } = entry;
321
+ if (typeof src === 'string') {
322
+ setOwn(dst, key, resolveString(src, row));
323
+ } else if (src !== null && typeof src === 'object') {
324
+ let copy = copies.get(src);
325
+ if (copy === undefined) {
326
+ copy = Array.isArray(src) ? [] : {};
327
+ copies.set(src, copy);
328
+ row.open.add(copy);
329
+ stack.push({ close: copy });
330
+ pushChildren(src, copy);
331
+ } else if (row.open.has(copy)) {
332
+ // Only a cyclic in-memory input gets here; JSON never does.
333
+ stats.cycles++;
334
+ copy = { $ref: id };
335
+ }
336
+ setOwn(dst, key, copy);
337
+ } else {
338
+ setOwn(dst, key, src);
339
+ }
340
+ }
341
+ return root;
342
+ };
343
+
344
+ // Keep the input's key order in the output.
345
+ for (const id of ids) setOwn(out, id, undefined);
346
+
347
+ // Iterative post-order DFS over the reference graph: a row is resolved once
348
+ // every row it references is, so a 100k-long chain needs no call stack.
349
+ for (const start of ids) {
350
+ if (state.has(start)) continue;
351
+ state.set(start, ON_STACK);
352
+ const stack = [{ id: start, deps: dependencies(start), next: 0 }];
353
+ while (stack.length > 0) {
354
+ const frame = stack[stack.length - 1];
355
+ if (frame.next < frame.deps.length) {
356
+ const dep = frame.deps[frame.next++];
357
+ if (!state.has(dep)) {
358
+ state.set(dep, ON_STACK);
359
+ stack.push({ id: dep, deps: dependencies(dep), next: 0 });
360
+ }
361
+ continue;
362
+ }
363
+ stack.pop();
364
+ setOwn(out, frame.id, resolveRow(frame.id));
365
+ state.set(frame.id, DONE);
366
+ }
367
+ }
368
+
369
+ return { rows: out, ...stats };
370
+ }
371
+
372
+ /**
373
+ * Index the rows that carry data rather than markup: object or array rows that
374
+ * are not a React element tree (`["$", type, key, props]`) nor a list of
375
+ * element children, ranked by serialized size. String rows (`I`/`HL`/`T`) are
376
+ * not data rows. An index, not a copy — a caller reads a row with the path
377
+ * `next_f.<id>`.
378
+ *
379
+ * An array counts as element children when every item is an element or a
380
+ * primitive (a `$L` lazy reference, a text node, `false`, `null`) and at least
381
+ * one item is an element — the shape of a fragment's children.
382
+ *
383
+ * @param {Record<string, unknown>} rows
384
+ * @returns {Array<{ id: string, bytes: number, keys: string[] | null, length: number | null }>}
385
+ */
386
+ export function flightDataRows(rows) {
387
+ const out = [];
388
+ if (rows === null || typeof rows !== 'object') return out;
389
+ const sizes = new WeakMap();
390
+
391
+ const isChildren = (array) => {
392
+ let elements = 0;
393
+ for (const item of array) {
394
+ if (isElement(item)) elements++;
395
+ else if (item !== null && typeof item === 'object') return false;
396
+ }
397
+ return elements > 0;
398
+ };
399
+
400
+ for (const id of Object.keys(rows)) {
401
+ const value = rows[id];
402
+ if (value === null || typeof value !== 'object') continue;
403
+ if (Array.isArray(value)) {
404
+ if (isElement(value) || isChildren(value)) continue;
405
+ out.push({ id, bytes: serializedSize(value, sizes), keys: null, length: value.length });
406
+ } else {
407
+ out.push({ id, bytes: serializedSize(value, sizes), keys: Object.keys(value).slice(0, 20), length: null });
408
+ }
409
+ }
410
+
411
+ return out.sort((a, b) => b.bytes - a.bytes);
412
+ }