@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,580 @@
1
+ // One render: the exchange that happens over a single connection.
2
+ //
3
+ // PHP sends `render`; the renderer streams `chunk` frames back and sends
4
+ // `query` frames when a server component needs data, which PHP answers with
5
+ // `result` on the same socket. Frames are one-way by type: the renderer never
6
+ // sends anything PHP would persist.
7
+
8
+ import { AsyncLocalStorage } from 'node:async_hooks';
9
+ import { encodeFrame, msgpackCodec } from './codec.js';
10
+ import { FRAME } from './protocol.js';
11
+ import { MintClient } from './mint-client.js';
12
+ import { QueryClient } from './query-client.js';
13
+ import { injectRscPayload } from './html-stream.js';
14
+ import { describe } from './classify.js';
15
+ import { withHead } from './head.js';
16
+ import { logError, requestIdOf } from './log.js';
17
+
18
+ /** How much of an email's HTML goes in one chunk frame. */
19
+ const MAIL_CHUNK_BYTES = 64 * 1024;
20
+
21
+ /**
22
+ * The phases a render times, by the names PHP takes (Frame::PHASES): when
23
+ * the Flight payload had all been written, when the HTML shell was ready,
24
+ * the server-side render of the HTML after it, and an email's render. Each
25
+ * is milliseconds from the moment this session was handed its frame, on
26
+ * this process's monotonic clock. PHP draws them as child spans of the
27
+ * render's (ADR 0128): the renderer reports timings and nothing else of
28
+ * telemetry, and holds no exporter, credential or secret to send them
29
+ * anywhere itself.
30
+ */
31
+ export const PHASES = Object.freeze(['flight', 'shell', 'ssr', 'mail']);
32
+
33
+ export class RenderSession {
34
+ #write;
35
+ #drained;
36
+ #app;
37
+ #codec;
38
+ #maxFrameBytes;
39
+ #client = null;
40
+ #mints = null;
41
+ #finished = false;
42
+
43
+ /**
44
+ * Fired when no one will read the render any more: PHP closed the socket
45
+ * (abort()). Handed to each of the app's entries, which pass it to
46
+ * React's renderToReadableStream, so React stops rendering -- and asking
47
+ * for data -- instead of finishing a page for a reader that is gone.
48
+ */
49
+ #controller = new AbortController();
50
+
51
+ /** The reader the pump holds, while it holds one: abort() cancels it. */
52
+ #reader = null;
53
+
54
+ /**
55
+ * What this render's components threw, by the digest each was reported
56
+ * under. A server component's error reaches the HTML render a second
57
+ * time, rebuilt from its error row with the digest on it, and is the
58
+ * same failure.
59
+ */
60
+ #reported = new Map();
61
+
62
+ /** When this session was handed its frame, on performance.now()'s clock. */
63
+ #began = 0;
64
+
65
+ /** The phases timed so far, by name: [start, end] in ms since #began. */
66
+ #phases = new Map();
67
+
68
+ /**
69
+ * Reports an HTML render holds while its shell is pending: a failure
70
+ * that fails the shell is the render's error frame, and is said once.
71
+ * Null for any other render, and once the shell is out.
72
+ */
73
+ #held = null;
74
+
75
+ /**
76
+ * @param {(bytes: Uint8Array) => boolean | void} write returns false, as
77
+ * a Node stream's write() does, when the destination is full
78
+ * @param {{ renderHtml: Function, renderRsc: Function, renderFragment?: Function }} app
79
+ * each entry is handed, last, the AbortSignal that fires when the
80
+ * render is abandoned -- renderRsc(frame, client, mints, onError,
81
+ * signal), renderHtml(frame, client, onError, mints, signal),
82
+ * renderFragment(frame, client, onError, signal) -- for React's
83
+ * renderToReadableStream `signal` option
84
+ * @param {{codec?: object, maxFrameBytes?: number, drained?: () => Promise<void>}} [options]
85
+ * `drained` settles once a full destination has room again
86
+ */
87
+ constructor(write, app, options = {}) {
88
+ this.#write = write;
89
+ this.#drained = options.drained ?? (() => Promise.resolve());
90
+ this.#app = app;
91
+ this.#codec = options.codec ?? msgpackCodec;
92
+ this.#maxFrameBytes = options.maxFrameBytes;
93
+ }
94
+
95
+ get client() {
96
+ return this.#client;
97
+ }
98
+
99
+ get mints() {
100
+ return this.#mints;
101
+ }
102
+
103
+ /**
104
+ * Abandons the render: PHP has gone, so nothing it writes is read. The
105
+ * app's stream is cancelled and its signal fired, the clients fail what
106
+ * they wait on, and the render settles without a done or error frame --
107
+ * a closed connection is not a failed render, and is not logged as one.
108
+ *
109
+ * @param {unknown} [reason]
110
+ */
111
+ abort(reason = 'socket_closed') {
112
+ if (this.#controller.signal.aborted) return;
113
+ this.#controller.abort(reason);
114
+ this.#reader?.cancel(reason).catch(() => {});
115
+ this.#client?.close(reason);
116
+ this.#mints?.close(reason);
117
+ }
118
+
119
+ /** @param {Record<string, any>} frame */
120
+ async render(frame) {
121
+ this.#began = performance.now();
122
+
123
+ // What route() writes URLs across hosts with, where PHP routes by host
124
+ // (ADR 0149): this render's, the host it draws among them, read by
125
+ // route() from the store it runs in -- one renderer draws several hosts'
126
+ // pages at once, and each render sees its own.
127
+ // Outside the store -- work a stream's reader drives later, in its own
128
+ // context -- route() falls back to what every render shares: the
129
+ // scheme, the port and the primary host's domain, with no host drawn,
130
+ // so every link is absolute -- never a path on the wrong host -- and one
131
+ // to any other host is refused. Never a render's own domains: the last
132
+ // one written would be another page's, an admin host's domain handed to
133
+ // a storefront page drawn beside it.
134
+ if (frame.hosts && typeof frame.hosts === 'object') {
135
+ globalThis.__TINVAR_HOSTS__ = sharedHosts(frame.hosts);
136
+ return hostsStore().run(frame.hosts, () => this.#render(frame));
137
+ }
138
+ return this.#render(frame);
139
+ }
140
+
141
+ /** @param {Record<string, any>} frame */
142
+ async #render(frame) {
143
+ const isFragment = frame.t === FRAME.FRAGMENT;
144
+
145
+ // A fragment is always payload-only. There is no shell to send: React
146
+ // mounts the result into a boundary that is already on the page, so
147
+ // emitting HTML here would be a second document inside the first.
148
+ const payloadOnly = isFragment || frame.rsc === true;
149
+
150
+ // Both clients are built here and BOTH have to reach the app. The mint
151
+ // client was built, exposed through a getter, closed in the finally --
152
+ // and never passed, so getRenderContext().mints was always undefined and
153
+ // every <Deferred> threw. One missing argument made the whole exchange,
154
+ // its ADR, its frame types and its caps dead in the composed system.
155
+ this.#client = new QueryClient((calls) => {
156
+ this.#send({ t: FRAME.QUERY, q: calls });
157
+ });
158
+
159
+ // Fragment tokens are signed by PHP, which holds the key; the renderer
160
+ // only knows which boundaries exist.
161
+ this.#mints = new MintClient((requests) => {
162
+ this.#send({ t: FRAME.MINT, m: requests });
163
+ });
164
+
165
+ // Until its shell is out, an HTML render's reports wait: see #held.
166
+ this.#held = isFragment || payloadOnly ? null : [];
167
+
168
+ try {
169
+ // Awaited, not just called: an RSC entry has to await a fragment token
170
+ // before it can return its stream, which is precisely what a page with
171
+ // a <Deferred> boundary does. Without this it returned a promise and
172
+ // the pump asked it for a reader.
173
+ const signal = this.#controller.signal;
174
+ const stream = isFragment
175
+ ? await this.#fragmentStream(frame)
176
+ : payloadOnly
177
+ ? await this.#app.renderRsc(
178
+ frame,
179
+ this.#client,
180
+ this.#mints,
181
+ this.#reporter(frame, 'render error'),
182
+ signal,
183
+ )
184
+ : await this.#htmlStream(frame);
185
+
186
+ // Abandoned while the app was still answering: its stream is for no
187
+ // one, and is stopped before it renders any further.
188
+ if (signal.aborted) {
189
+ await cancelQuietly(stream, signal.reason);
190
+ return;
191
+ }
192
+
193
+ // The shell has rendered: what failed so far failed inside a Suspense
194
+ // boundary, and the document goes on without it.
195
+ this.#release();
196
+ await this.#pump(stream);
197
+ if (this.#controller.signal.aborted) return;
198
+ // A payload's stream is the Flight payload itself; a document's is
199
+ // the HTML, which went on from its shell to here.
200
+ if (payloadOnly) this.#phase('flight', 0);
201
+ else this.#phase('ssr', this.#phases.get('shell')?.[1] ?? 0);
202
+ this.#done();
203
+ } catch (error) {
204
+ // What an abandoned render throws -- React's abort, a query the
205
+ // closed socket failed -- is for no one, and no failure of its own.
206
+ if (this.#controller.signal.aborted) return;
207
+ // Stacks, source locations and hints go to logs, never to the browser.
208
+ // PHP is handed them with the classified code, and decides: the dev
209
+ // overlay shows them, and anywhere else they are dropped there.
210
+ //
211
+ // A server component's error that failed the shell arrives here
212
+ // rebuilt from its error row -- in a production build with React's
213
+ // stand-in message -- so it is described from the error the component
214
+ // threw, under the digest it was reported with, and that report is
215
+ // not sent as well: one failure, one line in PHP's log.
216
+ const digest = this.#knownDigest(error);
217
+ const thrown = digest === undefined ? error : this.#reported.get(digest);
218
+ this.#release(digest);
219
+ const { code, ...cause } = describe(thrown);
220
+ logError('render failed', thrown, { request_id: requestIdOf(frame), code });
221
+ this.#send({
222
+ t: FRAME.ERROR,
223
+ code,
224
+ digest: digest ?? digestOf(thrown),
225
+ message: String(thrown?.message ?? thrown),
226
+ ...cause,
227
+ });
228
+ } finally {
229
+ this.#finished = true;
230
+ this.#client.close();
231
+ this.#mints.close();
232
+ }
233
+ }
234
+
235
+ /**
236
+ * One deferred subtree, rendered on its own.
237
+ *
238
+ * The token arrives ALREADY VERIFIED: PHP checked its signature, session,
239
+ * build and expiry, and ran the originating route's #[Authorize] chain
240
+ * before opening this channel. The renderer never verifies anything -- it
241
+ * holds no key, and the trust boundary puts it on the "runs third-party npm
242
+ * code" side.
243
+ *
244
+ * Queries inside the subtree go through the same client as a page render,
245
+ * so they batch into one frame and resolve with Level 2 lazy loading, as
246
+ * the spec requires.
247
+ */
248
+ #fragmentStream(frame) {
249
+ if (typeof this.#app.renderFragment !== 'function') {
250
+ throw new Error('This app has no renderFragment entry; it cannot serve deferred components.');
251
+ }
252
+
253
+ // No mint client: a deferred boundary does not nest. A fragment render
254
+ // that could mint would let one boundary spawn others without the page
255
+ // ever having declared them.
256
+ return this.#app.renderFragment(
257
+ frame,
258
+ this.#client,
259
+ this.#reporter(frame, 'fragment error'),
260
+ this.#controller.signal,
261
+ );
262
+ }
263
+
264
+ /**
265
+ * The onError a render hands React: for a fragment's
266
+ * renderToReadableStream, and for a page's -- the RSC serialization and,
267
+ * for a document, the HTML render beside it.
268
+ *
269
+ * A component that throws inside a Suspense boundary is an error row in
270
+ * the payload, not a failed render: React carries on, and the browser
271
+ * shows the boundary's error state -- a fragment's `rescue` slot
272
+ * (tinvar-spec.md, "Rescue and reloading"). Left to React's default, the
273
+ * failure went to this process's console and nowhere else, and PHP
274
+ * answered 200 with nothing logged. So each is reported over the socket,
275
+ * classified as an error frame is, and the digest returned is the one
276
+ * React writes on the row, so the browser's reference and PHP's log line
277
+ * are the same string. A failure met twice -- a server component's error
278
+ * that the HTML render reads back off its row -- is reported once.
279
+ *
280
+ * @param {Record<string, any>} frame
281
+ * @param {string} what how the renderer's own log names it
282
+ * @returns {(error: unknown) => string}
283
+ */
284
+ #reporter(frame, what) {
285
+ return (error) => {
286
+ const known = this.#knownDigest(error);
287
+ if (known !== undefined) return known;
288
+
289
+ const digest = reportedDigest(error);
290
+ this.#reported.set(digest, error);
291
+ const { code, ...cause } = describe(error);
292
+ logError(what, error, { request_id: requestIdOf(frame), code, digest });
293
+ const report = { t: FRAME.REPORT, code, digest, message: String(error?.message ?? error), ...cause };
294
+ if (this.#held !== null) this.#held.push(report);
295
+ else this.#send(report);
296
+ return digest;
297
+ };
298
+ }
299
+
300
+ /**
301
+ * The digest `error` was already reported under: the error itself, or
302
+ * one React rebuilt from its row, which carries the digest.
303
+ *
304
+ * @returns {string | undefined}
305
+ */
306
+ #knownDigest(error) {
307
+ for (const [digest, reported] of this.#reported) {
308
+ if (reported === error) return digest;
309
+ }
310
+ const carried = error?.digest;
311
+ return typeof carried === 'string' && this.#reported.has(carried) ? carried : undefined;
312
+ }
313
+
314
+ /**
315
+ * Sends the reports held while the shell was pending, but the one the
316
+ * render's error frame is about to say.
317
+ *
318
+ * @param {string} [failed] the digest of the failure that failed the shell
319
+ */
320
+ #release(failed) {
321
+ const held = this.#held ?? [];
322
+ this.#held = null;
323
+ for (const report of held) {
324
+ if (report.digest !== failed) this.#send(report);
325
+ }
326
+ }
327
+
328
+ async #htmlStream(frame) {
329
+ // Reported as each happens. Collected and read once, before the
330
+ // document streamed, they missed every error from a Suspense boundary
331
+ // that failed after the shell -- the usual kind. The app hands this to
332
+ // both of the page's renders, so its server components and its client
333
+ // components rendered here are reported alike.
334
+ const onError = this.#reporter(frame, 'render error');
335
+ const rendered = await this.#app.renderHtml(
336
+ frame,
337
+ this.#client,
338
+ onError,
339
+ this.#mints,
340
+ this.#controller.signal,
341
+ );
342
+ // React resolves the HTML render once its shell is ready.
343
+ this.#phase('shell', 0);
344
+ let htmlStream = rendered?.htmlStream;
345
+ let rscStream = rendered?.rscStream;
346
+
347
+ try {
348
+ rscStream = this.#timed(rscStream, 'flight');
349
+
350
+ // One title and one meta tag of each key in the head, PHP's fallbacks
351
+ // among them until a component's replace them. What the head preloads
352
+ // goes to PHP as a `meta` frame -- the page's client references, for its
353
+ // Link header -- before the first byte of the document does.
354
+ htmlStream = withHead(htmlStream, frame.head, ({ preload }) => {
355
+ if (preload.length > 0) this.#send({ t: FRAME.META, preload });
356
+ });
357
+ return injectRscPayload(htmlStream, rscStream, frame.nonce, frame.page);
358
+ } catch (error) {
359
+ // Both renders are running by now, and neither has a reader: left
360
+ // alone, they went on to the end of the page for no one.
361
+ await Promise.all([cancelQuietly(htmlStream, error), cancelQuietly(rscStream, error)]);
362
+ throw error;
363
+ }
364
+ }
365
+
366
+ async #pump(stream) {
367
+ const reader = stream.getReader();
368
+ this.#reader = reader;
369
+ let finished = false;
370
+ try {
371
+ for (;;) {
372
+ const { done, value } = await reader.read();
373
+ if (done) {
374
+ finished = true;
375
+ return;
376
+ }
377
+ if (this.#controller.signal.aborted) return;
378
+ if (!value?.length) continue;
379
+
380
+ // PHP reads the socket between writes to the browser, so a slow
381
+ // browser fills it. Written regardless, the app's whole output
382
+ // collected in the socket's buffer; the next chunk is read once
383
+ // there is room for it.
384
+ if (this.#send({ t: FRAME.CHUNK, b: value }) === false) await this.#drained();
385
+ }
386
+ } finally {
387
+ this.#reader = null;
388
+ // Left any other way -- abandoned, a chunk that could not be sent, a
389
+ // stream that failed -- the app's render is told to stop: a reader
390
+ // merely dropped leaves React rendering the rest of the page.
391
+ if (!finished) {
392
+ const reason = this.#controller.signal.aborted ? this.#controller.signal.reason : 'render_stopped';
393
+ await reader.cancel(reason).catch(() => {});
394
+ }
395
+ }
396
+ }
397
+
398
+ /**
399
+ * One email: the app's renderMail() draws the template from its props,
400
+ * and the HTML goes back as chunks and the plain-text part -- every link
401
+ * spelled out, which PHP checks -- in a meta frame.
402
+ *
403
+ * No query client and no mint client. A template is handed everything it
404
+ * shows, and an email has no boundary to defer: a component that awaited
405
+ * a query here would be one PHP never authorised for this render.
406
+ *
407
+ * @param {Record<string, any>} frame
408
+ */
409
+ async mail(frame) {
410
+ this.#began = performance.now();
411
+ try {
412
+ if (typeof this.#app.renderMail !== 'function') {
413
+ throw new Error('This app has no renderMail entry; it cannot render email.');
414
+ }
415
+ const mail = await this.#app.renderMail(frame);
416
+ if (typeof mail?.html !== 'string' || typeof mail?.text !== 'string') {
417
+ throw new TypeError('renderMail must resolve to { html, text }, both strings.');
418
+ }
419
+ this.#phase('mail', 0);
420
+
421
+ const bytes = new TextEncoder().encode(mail.html);
422
+ for (let at = 0; at < bytes.length; at += MAIL_CHUNK_BYTES) {
423
+ if (this.#send({ t: FRAME.CHUNK, b: bytes.subarray(at, at + MAIL_CHUNK_BYTES) }) === false) {
424
+ await this.#drained();
425
+ }
426
+ }
427
+ this.#send({ t: FRAME.META, mail: { text: mail.text } });
428
+ this.#done();
429
+ } catch (error) {
430
+ logError('mail render failed', error, { request_id: requestIdOf(frame) });
431
+ this.#send({
432
+ t: FRAME.ERROR,
433
+ code: 'render_failed',
434
+ digest: digestOf(error),
435
+ message: String(error?.message ?? error),
436
+ });
437
+ } finally {
438
+ this.#finished = true;
439
+ }
440
+ }
441
+
442
+ /**
443
+ * Times a phase as ending now, from `start` -- milliseconds since the
444
+ * frame arrived -- and answers when it ended. The first time alone: a
445
+ * phase is timed once a render.
446
+ *
447
+ * @param {string} name one of PHASES
448
+ * @param {number} start
449
+ * @returns {number}
450
+ */
451
+ #phase(name, start) {
452
+ const end = performance.now() - this.#began;
453
+ if (!this.#phases.has(name)) this.#phases.set(name, [start, end]);
454
+ return end;
455
+ }
456
+
457
+ /**
458
+ * `stream` as it was, with `name` timed from the frame's arrival to the
459
+ * moment it ended. Read through as injectRscPayload reads its sources --
460
+ * pulled, nothing read ahead -- so what was read when is unchanged. Not
461
+ * timed where it is cancelled or fails: that render ends in an error
462
+ * frame, which carries no phases.
463
+ *
464
+ * @param {ReadableStream<Uint8Array>} stream
465
+ * @param {string} name
466
+ */
467
+ #timed(stream, name) {
468
+ if (!(stream instanceof ReadableStream)) return stream;
469
+ const reader = stream.getReader();
470
+ return new ReadableStream(
471
+ {
472
+ pull: async (controller) => {
473
+ const { done, value } = await reader.read();
474
+ if (!done) {
475
+ controller.enqueue(value);
476
+ return;
477
+ }
478
+ this.#phase(name, 0);
479
+ controller.close();
480
+ },
481
+ cancel: (reason) => reader.cancel(reason),
482
+ },
483
+ { highWaterMark: 0 },
484
+ );
485
+ }
486
+
487
+ /** The done frame, with the phases timed: start and end in ms, to the microsecond. */
488
+ #done() {
489
+ const phases = [...this.#phases].map(([n, [s, e]]) => ({ n, s: round(s), e: round(e) }));
490
+ this.#send(phases.length === 0 ? { t: FRAME.DONE } : { t: FRAME.DONE, ph: phases });
491
+ }
492
+
493
+ /** @param {Array<{id: number}>} results */
494
+ settle(results) {
495
+ this.#client?.settle(results);
496
+ }
497
+
498
+ /** @param {Array<{id: number, token: string | null}>} tokens */
499
+ settleMints(tokens) {
500
+ this.#mints?.settle(tokens);
501
+ }
502
+
503
+ /** @returns {boolean | void} false when the destination is full */
504
+ #send(frame) {
505
+ if (this.#finished && frame.t !== FRAME.DONE && frame.t !== FRAME.ERROR) return;
506
+ return this.#write(encodeFrame(frame, this.#maxFrameBytes, this.#codec));
507
+ }
508
+ }
509
+
510
+ /**
511
+ * Cancels `stream` where it is one that can be: a render's output nothing
512
+ * will read. What cancelling throws -- a stream already locked or errored --
513
+ * changes nothing about why it was cancelled.
514
+ *
515
+ * @param {unknown} stream
516
+ * @param {unknown} reason
517
+ */
518
+ async function cancelQuietly(stream, reason) {
519
+ if (!(stream instanceof ReadableStream) || stream.locked) return;
520
+ await stream.cancel(reason).catch(() => {});
521
+ }
522
+
523
+ /**
524
+ * The digest a failure is reported under. A failed query already has one --
525
+ * PHP logged the exception under it when it sent the error record -- and
526
+ * keeping it means the reference a visitor quotes finds that line, which
527
+ * names the cause, rather than one that says a query failed.
528
+ */
529
+ function reportedDigest(error) {
530
+ const digest = error?.name === 'QueryFailed' ? error.digest : undefined;
531
+ return typeof digest === 'string' && /^[A-Za-z0-9_-]{1,64}$/.test(digest) ? digest : digestOf(error);
532
+ }
533
+
534
+ function round(ms) {
535
+ return Math.round(Math.max(ms, 0) * 1000) / 1000;
536
+ }
537
+
538
+ function digestOf(error) {
539
+ const text = String(error?.stack ?? error?.message ?? error);
540
+ let hash = 0x811c9dc5;
541
+ for (let i = 0; i < text.length; i++) {
542
+ hash ^= text.charCodeAt(i);
543
+ hash = Math.imul(hash, 0x01000193) >>> 0;
544
+ }
545
+ return hash.toString(16).padStart(8, '0');
546
+ }
547
+
548
+ /**
549
+ * What every render's hosts have in common: the scheme, the port, the
550
+ * primary host's pattern and its configured domain alone -- the one every
551
+ * page is told, whichever host it is on.
552
+ *
553
+ * @param {Record<string, any>} hosts
554
+ */
555
+ function sharedHosts(hosts) {
556
+ const primary = typeof hosts.primary === 'string' ? hosts.primary : '';
557
+ const last = primary.split('.').pop() ?? '';
558
+ const name = last.startsWith('{') && last.endsWith('}') ? last.slice(1, -1) : null;
559
+ const roots = hosts.roots && typeof hosts.roots === 'object' ? hosts.roots : {};
560
+ return {
561
+ scheme: hosts.scheme,
562
+ port: hosts.port,
563
+ primary,
564
+ roots: name !== null && typeof roots[name] === 'string' ? { [name]: roots[name] } : {},
565
+ };
566
+ }
567
+
568
+ /**
569
+ * The store each render's hosts are read from (route(), ADR 0149): one per
570
+ * process, under the global route() reads it by, so the app's server and
571
+ * client component bundles -- each with its own copy of route() -- see the
572
+ * same one.
573
+ *
574
+ * @returns {AsyncLocalStorage<unknown>}
575
+ */
576
+ function hostsStore() {
577
+ const g = /** @type {Record<string, any>} */ (globalThis);
578
+ g.__TINVAR_HOSTS_STORE__ ??= new AsyncLocalStorage();
579
+ return g.__TINVAR_HOSTS_STORE__;
580
+ }