@tinvar/renderer 0.0.0-stage → 0.4.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.
@@ -0,0 +1,479 @@
1
+ // Interleaves the RSC payload into the HTML stream as inline scripts.
2
+ //
3
+ // tinvar-spec.md, "Rendered output", shows the shape this produces:
4
+ // <script>(self.__rsc = self.__rsc || []).push([0, "0:[\"$\",\"$L1\",..."])</script>
5
+ // and notes that `self.__rsc` is a framework choice, not part of React's format.
6
+ //
7
+ // The HTML and the payload are drained concurrently so a Suspense boundary's
8
+ // markup and the rows that hydrate it both reach the browser as they become
9
+ // ready, rather than all at the end -- the rows in the gaps between React's
10
+ // flushes, never inside one (see merge()).
11
+
12
+ const encoder = new TextEncoder();
13
+
14
+ const LINE_SEPARATOR = '
';
15
+ const PARAGRAPH_SEPARATOR = '
';
16
+
17
+ /**
18
+ * Escapes a payload row so it cannot close the script element carrying it.
19
+ *
20
+ * JSON.stringify handles quotes and control characters. It leaves `<` and the
21
+ * two Unicode line separators alone, and both are hazards inside a script tag.
22
+ */
23
+ function scriptSafe(text) {
24
+ return JSON.stringify(text)
25
+ .split('<')
26
+ .join('\\u003c')
27
+ .split(LINE_SEPARATOR)
28
+ .join('\\u2028')
29
+ .split(PARAGRAPH_SEPARATOR)
30
+ .join('\\u2029');
31
+ }
32
+
33
+ /** The id of the element carrying the page metadata; the router reads it. */
34
+ export const PAGE_METADATA_ID = 'tinvar-page';
35
+
36
+ /**
37
+ * The hosts PHP sent, as route() reads them, or null for none or for
38
+ * anything not their shape.
39
+ *
40
+ * @param {unknown} value
41
+ * @returns {{ scheme: string, port: string, roots: Record<string, string>, primary: string, current?: { pattern: string | null, params: Record<string, string> } } | null}
42
+ */
43
+ function hostsOf(value) {
44
+ const v = /** @type {any} */ (value);
45
+ if (
46
+ typeof v !== 'object' ||
47
+ v === null ||
48
+ typeof v.scheme !== 'string' ||
49
+ typeof v.port !== 'string' ||
50
+ typeof v.primary !== 'string' ||
51
+ typeof v.roots !== 'object' ||
52
+ v.roots === null
53
+ ) {
54
+ return null;
55
+ }
56
+ /** @type {Record<string, string>} */
57
+ const roots = {};
58
+ for (const [name, host] of Object.entries(v.roots)) {
59
+ if (typeof host === 'string') roots[name] = host;
60
+ }
61
+ // The host the page is drawn on: its pattern, and what it took.
62
+ const c = v.current;
63
+ if (typeof c === 'object' && c !== null && (c.pattern === null || typeof c.pattern === 'string')) {
64
+ /** @type {Record<string, string>} */
65
+ const params = {};
66
+ for (const [name, value] of Object.entries(Array.isArray(c.params) ? {} : (c.params ?? {}))) {
67
+ if (typeof value === 'string') params[name] = value;
68
+ }
69
+ return {
70
+ scheme: v.scheme,
71
+ port: v.port,
72
+ roots,
73
+ primary: v.primary,
74
+ current: { pattern: c.pattern, params },
75
+ };
76
+ }
77
+ return { scheme: v.scheme, port: v.port, roots, primary: v.primary };
78
+ }
79
+
80
+ /**
81
+ * The initial page metadata, as a data block.
82
+ *
83
+ * tinvar-spec.md, "Protocol": "in a `<script type=\"application/json\">`
84
+ * element with every `/` escaped as `\/`" -- so `</script>` cannot appear in
85
+ * it whatever the URL holds. `<` and the line separators are escaped as for
86
+ * payload rows. A data block is never executed, so it takes no nonce.
87
+ *
88
+ * Only the known fields are copied: the frame comes from PHP, but what
89
+ * reaches the page is decided here, where the markup is written. The
90
+ * history key (ADR 0019) is written only for a page that has one.
91
+ *
92
+ * @param {{ url: unknown, version: unknown, routeToken?: unknown, view?: unknown, historyKey?: unknown, route?: unknown, hosts?: unknown }} page
93
+ */
94
+ export function pageMetadataTag(page) {
95
+ if (typeof page?.url !== 'string' || typeof page.version !== 'string') {
96
+ throw new TypeError('Page metadata needs a string url and version.');
97
+ }
98
+ const routeToken = typeof page.routeToken === 'string' ? page.routeToken : null;
99
+ const view = typeof page.view === 'string' ? page.view : null;
100
+ const historyKey = typeof page.historyKey === 'string' ? { historyKey: page.historyKey } : {};
101
+ // What the page's reports are filed under (ADR 0129): absent, not null,
102
+ // where PHP gave none.
103
+ const route = typeof page.route === 'string' ? { route: page.route } : {};
104
+ // What route() writes absolute URLs with where PHP routes by host (ADR
105
+ // 0149): only its four fields, each of the type it is.
106
+ const hosts = hostsOf(page.hosts);
107
+
108
+ const json = JSON.stringify({
109
+ url: page.url,
110
+ version: page.version,
111
+ routeToken,
112
+ view,
113
+ ...historyKey,
114
+ ...route,
115
+ ...(hosts === null ? {} : { hosts }),
116
+ })
117
+ .split('/')
118
+ .join('\\/')
119
+ .split('<')
120
+ .join('\\u003c')
121
+ .split(LINE_SEPARATOR)
122
+ .join('\\u2028')
123
+ .split(PARAGRAPH_SEPARATOR)
124
+ .join('\\u2029');
125
+
126
+ return `<script type="application/json" id="${PAGE_METADATA_ID}">${json}</script>`;
127
+ }
128
+
129
+ const DOCTYPE = encoder.encode('<!doctype html>');
130
+
131
+ /**
132
+ * Where the prelude goes in the first bytes of the document: after the
133
+ * doctype if they open with one, otherwise at the very start. -1 while
134
+ * they are a prefix of a doctype that has not finished arriving.
135
+ *
136
+ * React's chunk boundaries fall where its buffer fills, not between tags,
137
+ * so the doctype can arrive in pieces. Decided on the first piece alone,
138
+ * `<!DOCTY` was not a doctype, and the prelude went ahead of it -- which
139
+ * puts the browser in quirks mode.
140
+ *
141
+ * @param {Uint8Array} head
142
+ * @param {boolean} ended no more bytes are coming
143
+ */
144
+ function preludeOffset(head, ended) {
145
+ const compared = Math.min(head.length, DOCTYPE.length);
146
+ for (let i = 0; i < compared; i++) {
147
+ const byte = head[i] >= 0x41 && head[i] <= 0x5a ? head[i] + 0x20 : head[i];
148
+ if (byte !== DOCTYPE[i]) return 0;
149
+ }
150
+ if (head.length >= DOCTYPE.length) return DOCTYPE.length;
151
+ return ended ? 0 : -1;
152
+ }
153
+
154
+ /**
155
+ * The first bytes of the document with the prelude at its very start --
156
+ * after the doctype, if there is one, and before everything else. Nothing
157
+ * the app renders can come before it.
158
+ *
159
+ * The prelude claims the payload globals and carries the page metadata.
160
+ * Both have to come first, for the same reason: page content can name
161
+ * things. An element with `id="__rsc"` -- a heading anchored by a user's
162
+ * slug, raw user HTML -- is reachable as `self.__rsc` until a script
163
+ * assigns the property, so the first row's push threw on it and every row
164
+ * was lost; two such elements made it a collection whose hrefs were read as
165
+ * rows. An assignment shadows the named element. And the router reads the
166
+ * FIRST metadata block, which a planted one could otherwise precede.
167
+ *
168
+ * @param {Uint8Array} head
169
+ * @param {number} offset from preludeOffset()
170
+ * @param {Uint8Array} prelude
171
+ */
172
+ function withPrelude(head, offset, prelude) {
173
+ const out = new Uint8Array(head.length + prelude.length);
174
+ out.set(head.subarray(0, offset), 0);
175
+ out.set(prelude, offset);
176
+ out.set(head.subarray(offset), offset + prelude.length);
177
+ return out;
178
+ }
179
+
180
+ /** @param {Uint8Array} a @param {Uint8Array} b */
181
+ function concat(a, b) {
182
+ if (a.length === 0) return b;
183
+ const out = new Uint8Array(a.length + b.length);
184
+ out.set(a, 0);
185
+ out.set(b, a.length);
186
+ return out;
187
+ }
188
+
189
+ // Fatal, so bytes that are not UTF-8 are found rather than replaced; and a
190
+ // byte order mark is payload like any other byte, not something to strip.
191
+ const strictUtf8 = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true });
192
+
193
+ /**
194
+ * How many bytes at the end of `bytes` begin a UTF-8 sequence that has not
195
+ * finished arriving: 0 to 3.
196
+ *
197
+ * @param {Uint8Array} bytes
198
+ */
199
+ function unfinishedSequence(bytes) {
200
+ let lead = bytes.length - 1;
201
+ while (lead >= 0 && bytes.length - lead <= 3 && (bytes[lead] & 0xc0) === 0x80) lead--;
202
+ if (lead < 0) return 0;
203
+
204
+ const byte = bytes[lead];
205
+ const length = (byte & 0xe0) === 0xc0 ? 2 : (byte & 0xf0) === 0xe0 ? 3 : (byte & 0xf8) === 0xf0 ? 4 : 1;
206
+ const present = bytes.length - lead;
207
+ return present < length ? present : 0;
208
+ }
209
+
210
+ /**
211
+ * The payload as the arguments of `self.__rsc.push(...)`.
212
+ *
213
+ * Flight is bytes, not text: a Uint8Array or ArrayBuffer prop goes out as
214
+ * its raw bytes. Decoded as UTF-8 for the document and encoded again in the
215
+ * browser, every byte that was not valid UTF-8 came back as U+FFFD and the
216
+ * prop was corrupted. So a read that is valid UTF-8 is written as a string,
217
+ * as every row always was, and one that is not as {"$b64": "..."} -- the
218
+ * marker the json-lines codec uses for binary -- which the browser entry
219
+ * decodes with inlinePayloadStream() from @tinvar/react/client.
220
+ *
221
+ * A character split between two reads is held for the next one, so text
222
+ * that merely arrived in pieces is not mistaken for binary.
223
+ */
224
+ class PayloadRows {
225
+ #held = new Uint8Array(0);
226
+
227
+ /**
228
+ * @param {Uint8Array} bytes
229
+ * @returns {string[]}
230
+ */
231
+ push(bytes) {
232
+ const all = concat(this.#held, bytes);
233
+ const complete = all.length - unfinishedSequence(all);
234
+
235
+ let text;
236
+ try {
237
+ text = strictUtf8.decode(all.subarray(0, complete));
238
+ } catch {
239
+ this.#held = new Uint8Array(0);
240
+ return [binaryRow(all)];
241
+ }
242
+
243
+ this.#held = all.slice(complete);
244
+ return text === '' ? [] : [scriptSafe(text)];
245
+ }
246
+
247
+ /** @returns {string[]} what is still held, which can only be binary now */
248
+ end() {
249
+ const held = this.#held;
250
+ this.#held = new Uint8Array(0);
251
+ return held.length === 0 ? [] : [binaryRow(held)];
252
+ }
253
+ }
254
+
255
+ /** @param {Uint8Array} bytes */
256
+ function binaryRow(bytes) {
257
+ // Base64 is letters, digits, +, / and =: nothing that can close a script.
258
+ return `{"$b64":"${Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength).toString('base64')}"}`;
259
+ }
260
+
261
+ /**
262
+ * A CSP nonce as PHP mints one: base64, or its URL-safe alphabet. The
263
+ * renderer writes it into an attribute itself, so anything else -- a quote,
264
+ * a space, an angle bracket -- is refused rather than escaped: a nonce that
265
+ * is not one is a frame that is not PHP's, and the render fails closed.
266
+ */
267
+ const NONCE = /^[A-Za-z0-9+/=_-]+$/;
268
+
269
+ /** @param {unknown} nonce */
270
+ function assertNonce(nonce) {
271
+ if (nonce === undefined || nonce === null || nonce === '') return;
272
+ if (typeof nonce !== 'string' || !NONCE.test(nonce)) {
273
+ throw new TypeError('The CSP nonce is not base64: refusing to write it into the document.');
274
+ }
275
+ }
276
+
277
+ function scriptTag(body, nonce) {
278
+ const attribute = nonce ? ` nonce="${nonce}"` : '';
279
+ return `<script${attribute}>${body}</script>`;
280
+ }
281
+
282
+ /**
283
+ * @param {ReadableStream<Uint8Array>} htmlStream
284
+ * @param {ReadableStream<Uint8Array>} rscStream
285
+ * @param {string} [nonce] the per-request CSP nonce PHP minted
286
+ * @param {{ url: string, version: string, routeToken?: string | null, view?: string | null, historyKey?: string | null, route?: string | null, hosts?: unknown }} [page]
287
+ * the initial page metadata, written first in the document
288
+ * @returns {ReadableStream<Uint8Array>}
289
+ */
290
+ export function injectRscPayload(htmlStream, rscStream, nonce, page) {
291
+ assertNonce(nonce);
292
+ // Built before anything streams, so a malformed frame fails the render
293
+ // rather than truncating a document already half sent.
294
+ const metadata = page === undefined || page === null ? '' : pageMetadataTag(page);
295
+ const prelude = encoder.encode(metadata + scriptTag('self.__rsc=[];self.__rsc_done=false', nonce));
296
+
297
+ const next = merge(htmlStream, rscStream, prelude, nonce);
298
+
299
+ // Pulled, not pumped: each piece is made when the reader asks for one,
300
+ // and highWaterMark 0 means nothing is made ahead of the asking. Drained
301
+ // eagerly from start(), both sources were read as fast as they produced,
302
+ // and a renderer whose PHP side was busy with a slow browser held the
303
+ // whole page in memory.
304
+ return new ReadableStream(
305
+ {
306
+ async pull(controller) {
307
+ const bytes = await next();
308
+ if (bytes === null) controller.close();
309
+ else controller.enqueue(bytes);
310
+ },
311
+ cancel(reason) {
312
+ return next.cancel(reason);
313
+ },
314
+ },
315
+ { highWaterMark: 0 },
316
+ );
317
+ }
318
+
319
+ /**
320
+ * The merged document, a piece at a time: `next()` resolves with the next
321
+ * bytes to write, or null once the document is complete.
322
+ *
323
+ * HTML passes through as it arrives. Payload rows are queued and written
324
+ * only between two of React's flushes. React writes a flush synchronously,
325
+ * in views of a fixed size, so one flush arrives as several chunks whose
326
+ * boundaries fall wherever a view filled -- inside a tag, inside an
327
+ * attribute. A row written as soon as it was ready landed between two of
328
+ * them, in the middle of the markup.
329
+ *
330
+ * Between flushes is when a read of the HTML has stayed unanswered for a
331
+ * whole macrotask: a flush is written in one go, so if any of it were
332
+ * waiting the read would have been answered within the task that asked.
333
+ * Only an outstanding read counts. A chunk read and not yet written says
334
+ * nothing about the rest of its flush, which may still be in the stream.
335
+ *
336
+ * The rows of one payload read are decoded and queued together, and the
337
+ * payload is read again only once they have been written, so rows wait for
338
+ * a gap without piling up behind one.
339
+ *
340
+ * @param {ReadableStream<Uint8Array>} htmlStream
341
+ * @param {ReadableStream<Uint8Array>} rscStream
342
+ * @param {Uint8Array} prelude
343
+ * @param {string | undefined} nonce
344
+ * @returns {(() => Promise<Uint8Array | null>) & { cancel: (reason: unknown) => Promise<void> }}
345
+ */
346
+ function merge(htmlStream, rscStream, prelude, nonce) {
347
+ const html = htmlStream.getReader();
348
+ const rsc = rscStream.getReader();
349
+ const rows = new PayloadRows();
350
+
351
+ /** The document's first bytes, held until it is known whether they open with a doctype. */
352
+ let head = new Uint8Array(0);
353
+ /** The prelude is written, so the document has started and rows may follow. */
354
+ let started = false;
355
+ let htmlDone = false;
356
+ let rscDone = false;
357
+ /** An HTML chunk read and not yet written. */
358
+ let htmlChunk = null;
359
+ /** The HTML read still waiting for an answer, if there is one. */
360
+ let htmlRead = null;
361
+ /** htmlRead has stayed unanswered across a macrotask: React is between flushes. */
362
+ let betweenFlushes = false;
363
+ let rscRead = null;
364
+ /** Payload scripts waiting for a gap between flushes. */
365
+ const queued = [];
366
+ let failure = null;
367
+ let wake = null;
368
+
369
+ const changed = () => {
370
+ const resume = wake;
371
+ wake = null;
372
+ resume?.();
373
+ };
374
+
375
+ const fail = (error) => {
376
+ failure ??= error;
377
+ changed();
378
+ };
379
+
380
+ const queueRows = (pushed) => {
381
+ for (const row of pushed) queued.push(encoder.encode(scriptTag(`self.__rsc.push(${row})`, nonce)));
382
+ };
383
+
384
+ const readHtml = () => {
385
+ const read = html.read().then(({ done, value }) => {
386
+ htmlRead = null;
387
+ betweenFlushes = false;
388
+ if (done) htmlDone = true;
389
+ // An empty chunk is not the start of the document. Counting it as
390
+ // one let the metadata and payload rows out ahead of a doctype that
391
+ // arrived in the next chunk.
392
+ else if (value?.length) htmlChunk = value;
393
+ changed();
394
+ }, fail);
395
+ htmlRead = read;
396
+ betweenFlushes = false;
397
+ setImmediate(() => {
398
+ if (htmlRead !== read) return;
399
+ betweenFlushes = true;
400
+ changed();
401
+ });
402
+ };
403
+
404
+ const readRsc = () => {
405
+ rscRead = rsc.read().then(({ done, value }) => {
406
+ rscRead = null;
407
+ if (done) {
408
+ queueRows(rows.end());
409
+ queued.push(encoder.encode(scriptTag('self.__rsc_done=true', nonce)));
410
+ rscDone = true;
411
+ } else {
412
+ queueRows(rows.push(value));
413
+ }
414
+ changed();
415
+ }, fail);
416
+ };
417
+
418
+ const next = async () => {
419
+ for (;;) {
420
+ if (failure) {
421
+ await next.cancel(failure);
422
+ throw failure;
423
+ }
424
+
425
+ if (htmlChunk) {
426
+ const chunk = htmlChunk;
427
+ htmlChunk = null;
428
+ if (started) return chunk;
429
+
430
+ head = concat(head, chunk);
431
+ const offset = preludeOffset(head, false);
432
+ if (offset >= 0) {
433
+ started = true;
434
+ return withPrelude(head, offset, prelude);
435
+ }
436
+ continue;
437
+ }
438
+
439
+ // An HTML stream that ended without a byte never wrote the prelude;
440
+ // the rows depend on it.
441
+ if (htmlDone && !started) {
442
+ started = true;
443
+ return withPrelude(head, preludeOffset(head, true), prelude);
444
+ }
445
+
446
+ // The HTML is rendered FROM the payload, so rows are usually ready
447
+ // first -- and a script written before the doctype puts the browser
448
+ // in quirks mode. Rows wait for the document to start, and then for
449
+ // a gap between flushes.
450
+ if (started && queued.length > 0 && (htmlDone || betweenFlushes)) {
451
+ return concatAll(queued.splice(0));
452
+ }
453
+
454
+ if (htmlDone && rscDone && queued.length === 0) return null;
455
+
456
+ if (!htmlDone && !htmlRead) readHtml();
457
+ if (!rscDone && !rscRead && queued.length === 0) readRsc();
458
+ await new Promise((resolve) => (wake = resolve));
459
+ }
460
+ };
461
+
462
+ next.cancel = async (reason) => {
463
+ await Promise.allSettled([html.cancel(reason), rsc.cancel(reason)]);
464
+ };
465
+
466
+ return next;
467
+ }
468
+
469
+ /** @param {Uint8Array[]} parts */
470
+ function concatAll(parts) {
471
+ if (parts.length === 1) return parts[0];
472
+ const out = new Uint8Array(parts.reduce((total, part) => total + part.length, 0));
473
+ let at = 0;
474
+ for (const part of parts) {
475
+ out.set(part, at);
476
+ at += part.length;
477
+ }
478
+ return out;
479
+ }
package/src/log.js ADDED
@@ -0,0 +1,131 @@
1
+ // The renderer's log lines about a render: one JSON object a line on
2
+ // standard error, in the shape PHP's Logger writes (ADR 0041), carrying the
3
+ // request id PHP put in the render frame.
4
+ //
5
+ // {"time":"…","level":"error","message":"ssr error","source":"renderer",
6
+ // "request_id":"4bf92f3577b34da6a3ce929d0e0e4736","error":{…}}
7
+ //
8
+ // So the spec's "shared request ID in both logs" holds: one query for an id
9
+ // finds what PHP and the renderer each wrote about that request. The id is
10
+ // the only thing about telemetry the renderer is given -- no exporter, no
11
+ // credentials, nothing to send anywhere; it writes to its own standard
12
+ // error, which its parent owns.
13
+ //
14
+ // A failure's message and stack can quote what the render was handed --
15
+ // loader data, a customer's address -- so both pass through the same
16
+ // redaction PHP's log applies before they are written.
17
+
18
+ /** The one shape a request id takes; anything else in the frame is not written. */
19
+ const REQUEST_ID = /^[0-9a-f]{32}$/;
20
+
21
+ /** Longest string kept, as PHP's Redactor keeps them. */
22
+ const MAX_STRING = 4096;
23
+
24
+ /** Mirror of Tinvar\Observability\Redactor::VALUES, in its order. */
25
+ const VALUES = [
26
+ [/\b([a-z][a-z0-9+.-]*:\/\/[^\s/:]*):\S*@/gi, '$1:[redacted]@'],
27
+ [/\b(Bearer)\s+[A-Za-z0-9._~+/=-]{8,}/gi, '$1 [redacted]'],
28
+ // The scheme in any case; the credentials by a digit, + / =, or a capital
29
+ // after the first letter -- spelled out, since /i would make [A-Z] any letter.
30
+ [
31
+ /\b([Bb][Aa][Ss][Ii][Cc])\s+(?=[A-Za-z0-9+/]*[0-9+/=]|[A-Za-z0-9+/]+[A-Z])[A-Za-z0-9+/]{8,}={0,2}/g,
32
+ '$1 [redacted]',
33
+ ],
34
+ [/\beyJ[A-Za-z0-9_-]*\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]*/g, '[jwt]'],
35
+ [/\bv1\.[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{16,}/g, '[token]'],
36
+ [
37
+ /([?&;][a-z0-9_-]*(?:token|secret|passw|signature|sig|key|code|session|auth)[a-z0-9_-]*=)[^&\s#"']*/gi,
38
+ '$1[redacted]',
39
+ ],
40
+ [/[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)*\.[A-Za-z]{2,}/g, '[email]'],
41
+ [
42
+ /(?<![A-Za-z0-9_-])(?=[A-Za-z0-9_-]*[0-9])(?=[A-Za-z0-9_-]*[A-Za-z])[A-Za-z0-9_-]{40,}(?![A-Za-z0-9_-])/g,
43
+ '[redacted]',
44
+ ],
45
+ ];
46
+
47
+ /** The frame's request id, when it is one; undefined otherwise. */
48
+ export function requestIdOf(frame) {
49
+ const id = frame?.rid;
50
+ return typeof id === 'string' && REQUEST_ID.test(id) ? id : undefined;
51
+ }
52
+
53
+ /** `text` with every recognisable secret and address cut out, and cut to MAX_STRING. */
54
+ export function redact(text) {
55
+ let value = String(text);
56
+ const length = value.length;
57
+ // Cut, then back to the last whitespace or delimiter: a secret the cut
58
+ // splits is too short for its pattern, and replacements earlier in the
59
+ // window could otherwise pull its fragment inside the final cut.
60
+ if (value.length > MAX_STRING + 512)
61
+ value = value.slice(0, MAX_STRING + 512).replace(/[^\s"'<>()[\]{},;]*$/, '');
62
+ for (const [pattern, replacement] of VALUES) value = value.replace(pattern, replacement);
63
+ // Marked whenever it was longer than what is kept, even where redaction
64
+ // left it short enough: a cut line must not read as whole.
65
+ return Math.max(length, value.length) > MAX_STRING ? `${value.slice(0, MAX_STRING)}...` : value;
66
+ }
67
+
68
+ /**
69
+ * One line about the renderer itself rather than a render: its server's
70
+ * connections, the pool's supervisor (ADR 0112). The same JSON shape as a
71
+ * render's lines, with no request id -- there is no request -- and the pid,
72
+ * which tells a pool's renderers apart. The message is redacted as a
73
+ * render's is, since an error's text can quote what a frame carried; so is
74
+ * every string field.
75
+ *
76
+ * Info goes to standard output and anything worse to standard error, as
77
+ * the plain lines these replace did.
78
+ *
79
+ * @param {'debug' | 'info' | 'notice' | 'warning' | 'error'} level
80
+ * @param {string} message
81
+ * @param {Record<string, unknown>} fields
82
+ * @param {unknown} [error] what was thrown, described as a render's failure is
83
+ */
84
+ export function logLine(level, message, fields = {}, error = undefined) {
85
+ const line = {
86
+ time: new Date().toISOString(),
87
+ level,
88
+ message: redact(message),
89
+ source: 'renderer',
90
+ pid: process.pid,
91
+ ...Object.fromEntries(
92
+ Object.entries(fields)
93
+ .filter(([, value]) => value !== undefined)
94
+ .map(([key, value]) => [key, typeof value === 'string' ? redact(value) : value]),
95
+ ),
96
+ ...(error === undefined ? {} : { error: describe(error) }),
97
+ };
98
+ const text = JSON.stringify(line);
99
+ if (level === 'debug' || level === 'info') console.log(text);
100
+ else console.error(text);
101
+ }
102
+
103
+ /**
104
+ * One error line about a render.
105
+ *
106
+ * @param {string} message what happened, in the renderer's own words
107
+ * @param {unknown} error what was thrown
108
+ * @param {Record<string, unknown>} fields the request id, and anything else to file it under
109
+ */
110
+ export function logError(message, error, fields = {}) {
111
+ const line = {
112
+ time: new Date().toISOString(),
113
+ level: 'error',
114
+ message,
115
+ source: 'renderer',
116
+ ...Object.fromEntries(Object.entries(fields).filter(([, value]) => value !== undefined)),
117
+ error: describe(error),
118
+ };
119
+ console.error(JSON.stringify(line));
120
+ }
121
+
122
+ function describe(error) {
123
+ if (error === null || typeof error !== 'object') return { message: redact(String(error)) };
124
+
125
+ return {
126
+ class: typeof error.name === 'string' ? error.name : 'Error',
127
+ message: redact(error.message ?? ''),
128
+ ...(typeof error.code === 'string' ? { code: error.code } : {}),
129
+ ...(typeof error.stack === 'string' ? { stack: redact(error.stack) } : {}),
130
+ };
131
+ }