@tinvar/renderer 0.0.0-stage → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/bin/dev-renderer.js +117 -0
- package/bin/renderer-probe.js +52 -0
- package/bin/renderer.js +327 -0
- package/package.json +44 -4
- package/src/asset-origin.js +306 -0
- package/src/build-profile.js +108 -0
- package/src/can-branch.js +1666 -0
- package/src/classify.js +157 -0
- package/src/client-chunks.js +54 -0
- package/src/client-props.js +540 -0
- package/src/codec.js +297 -0
- package/src/dev-app.js +45 -0
- package/src/dev-guard.js +33 -0
- package/src/dev-server.js +460 -0
- package/src/eslint.js +469 -0
- package/src/head.js +524 -0
- package/src/html-stream.js +479 -0
- package/src/log.js +131 -0
- package/src/mint-client.js +142 -0
- package/src/panel-styles.js +254 -0
- package/src/pool.js +664 -0
- package/src/protocol.js +71 -0
- package/src/query-client.js +198 -0
- package/src/query-map.js +116 -0
- package/src/render-session.js +580 -0
- package/src/sandbox.js +710 -0
- package/src/server.js +466 -0
- package/src/socket-group.js +72 -0
- package/src/vite.js +2416 -0
- package/src/vitest.d.ts +20 -0
- package/src/vitest.js +179 -0
- package/src/warm.d.ts +9 -0
- package/src/warm.js +60 -0
- package/src/workbench.d.ts +146 -0
- package/src/workbench.js +754 -0
- package/README.md +0 -3
|
@@ -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
|
+
}
|