@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,754 @@
1
+ // The workbench and the playground: an app's pages and components rendered
2
+ // in isolation, from props an app writes or a developer gives, in dev and
3
+ // in dev alone (ADR 0089).
4
+ //
5
+ // A Vite plugin, as the email preview is (ADR 0086): its pages are served by
6
+ // the dev server `tinvar serve --dev` starts, behind PHP's server under
7
+ // /_vite/, and by nothing else. It has no part in a build (`apply: 'serve'`),
8
+ // and the module it reads samples from is imported by no entry, so no build
9
+ // contains either.
10
+ //
11
+ // /_vite/__workbench each page and its samples
12
+ // /_vite/__workbench/<page> one page: a sample, its props to edit, and the page drawn
13
+ // /_vite/__playground each component and its samples
14
+ // /_vite/__playground/<component> the same, for one component
15
+ //
16
+ // What renders is the app's own code in the dev server's rsc and ssr
17
+ // environments -- the process that renders every page in dev, which holds no
18
+ // secret -- and nothing else: no PHP, no database, no session. A query a
19
+ // server component calls is answered from the sample's `queries`, and one
20
+ // the sample does not answer fails; a <Deferred> boundary is given a token
21
+ // nothing will take. The page drawn runs no script: what is shown is the
22
+ // HTML, under a policy of its own, in a sandboxed frame. Where the app gives
23
+ // a client module, it may be drawn with its client script instead (ADR
24
+ // 0132): hydrated in a frame sandboxed to an origin of its own -- an opaque
25
+ // one, which holds no cookie and no storage and reaches no page of the
26
+ // app's -- under a policy that connects nowhere and posts no form.
27
+ //
28
+ // Where it refuses:
29
+ // - anywhere that says production, loudly, as the dev server does;
30
+ // - and it is not mounted -- with a line saying why -- unless TINVAR_ENV
31
+ // says local, development or testing, or under a server profile other
32
+ // than test (production, staging);
33
+ // - a request not from this machine, naming this machine, is answered as a
34
+ // path the dev server has nothing at: DNS rebinding points a name of a
35
+ // page's own at 127.0.0.1, and cannot change the Host its requests name;
36
+ // - props are given by a POST from the workbench's own page alone (its
37
+ // Origin), JSON, and at most 64 KiB.
38
+
39
+ import { randomBytes } from 'node:crypto';
40
+ import { PRODUCTION_MARKERS } from './dev-guard.js';
41
+
42
+ /** The TINVAR_ENV values that say this is not production; nothing else does. Mirrors Tinvar\Support\BuildProfile. */
43
+ export const NON_PRODUCTION = ['local', 'development', 'testing'];
44
+
45
+ /** Hosts a request may name: this machine's, by address, `localhost` or a name under it. */
46
+ const LOOPBACK_HOST =
47
+ /^(?:localhost|(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+localhost|127(?:\.\d{1,3}){3}|\[::1\])(?::[1-9]\d{0,4})?$/i;
48
+
49
+ /** Addresses a loopback peer connects from. */
50
+ const LOOPBACK_PEER = /^(?:127(?:\.\d{1,3}){3}|::1|::ffff:127(?:\.\d{1,3}){3})$/i;
51
+
52
+ /** A page's, a component's or a sample's name. */
53
+ const NAME = /^[A-Za-z0-9][A-Za-z0-9_.\-]{0,63}$/;
54
+
55
+ /** The most a POST of props may be. */
56
+ export const MAX_PROPS_BYTES = 64 * 1024;
57
+
58
+ /** The most of a render's HTML that is read. */
59
+ const MAX_HTML_BYTES = 8 * 1024 * 1024;
60
+
61
+ /** And of its RSC payload, kept for a frame that runs the app's client script. */
62
+ const MAX_PAYLOAD_BYTES = 8 * 1024 * 1024;
63
+
64
+ /** The id of the data block a scripted frame's payload is in, which the app's client module reads. */
65
+ export const PAYLOAD_ID = 'tinvar-workbench-payload';
66
+
67
+ export class WorkbenchRefused extends Error {}
68
+
69
+ /**
70
+ * Whether the workbench may be mounted here: throws where anything says
71
+ * production, and answers why not where nothing says this is a developer's
72
+ * machine.
73
+ *
74
+ * @param {Record<string, string | undefined>} env
75
+ * @returns {string | null} why it is not mounted, or null when it is
76
+ */
77
+ export function workbenchRefusal(env = process.env) {
78
+ for (const name of PRODUCTION_MARKERS) {
79
+ if (
80
+ String(env[name] ?? '')
81
+ .trim()
82
+ .toLowerCase() === 'production'
83
+ ) {
84
+ throw new WorkbenchRefused(
85
+ `The workbench is refused: ${name}=production. It renders the app's pages and components for whoever ` +
86
+ 'asks, so it runs under the dev server alone.',
87
+ );
88
+ }
89
+ }
90
+ const environment = String(env.TINVAR_ENV ?? '')
91
+ .trim()
92
+ .toLowerCase();
93
+ if (!NON_PRODUCTION.includes(environment)) {
94
+ return 'nothing says this is not production: set TINVAR_ENV=local (or development, or testing)';
95
+ }
96
+ const profile = String(env.TINVAR_PROFILE ?? '')
97
+ .trim()
98
+ .toLowerCase();
99
+ if (profile !== '' && profile !== 'test') {
100
+ return `TINVAR_PROFILE=${profile}: only the test profile, and dev, carry the development tools`;
101
+ }
102
+
103
+ return null;
104
+ }
105
+
106
+ /**
107
+ * Whether a request is from this machine, naming this machine: its peer, the
108
+ * Host it names, and -- behind PHP's server, which proxies the dev server --
109
+ * every address and host the proxy says it forwarded for.
110
+ *
111
+ * @param {import('node:http').IncomingMessage} req
112
+ */
113
+ export function fromThisMachine(req) {
114
+ const host = req.headers.host ?? '';
115
+ const peer = req.socket?.remoteAddress ?? '';
116
+ if (!LOOPBACK_HOST.test(host) || !LOOPBACK_PEER.test(peer)) return false;
117
+
118
+ const forwardedFor = req.headers['x-forwarded-for'];
119
+ if (forwardedFor !== undefined) {
120
+ const addresses = String(forwardedFor)
121
+ .split(',')
122
+ .map((a) => a.trim());
123
+ if (!addresses.every((a) => LOOPBACK_PEER.test(a))) return false;
124
+ }
125
+ const forwardedHost = req.headers['x-forwarded-host'];
126
+ if (forwardedHost !== undefined && !LOOPBACK_HOST.test(String(forwardedHost))) return false;
127
+ // A header no proxy of ours adds, which says someone else's did.
128
+ if (req.headers.forwarded !== undefined || req.headers['x-real-ip'] !== undefined) return false;
129
+
130
+ return true;
131
+ }
132
+
133
+ /**
134
+ * Whether a request is a module, style or font that a scripted frame of the
135
+ * workbench asked for -- which it may read, and nothing else may.
136
+ *
137
+ * Such a frame is sandboxed to an opaque origin, so what it loads it asks
138
+ * for with `Origin: null`, and its loads are cross-origin: the dev server
139
+ * must allow `null` for them, or no module runs. But `null` is every
140
+ * sandboxed frame's origin, a page on any site included, and the dev
141
+ * server's modules are the app's source. So `null` is allowed only where
142
+ * the request also says it came from this dev server: a Referer of this
143
+ * origin, under the dev server's base -- the frame's own document, or a
144
+ * module it loaded. A page elsewhere cannot send this origin as its
145
+ * Referer (it can send none, which is refused). And only for a GET or HEAD
146
+ * from this machine, naming this machine, of something under the base that
147
+ * is not one of the dev tools' own pages (`__workbench`, `__mail`, ...).
148
+ *
149
+ * @param {import('node:http').IncomingMessage} req
150
+ * @param {string} base the dev server's base, `/_vite/`
151
+ */
152
+ export function forScriptedFrame(req, base) {
153
+ if (req.headers.origin !== 'null' || (req.method !== 'GET' && req.method !== 'HEAD')) return false;
154
+ if (!fromThisMachine(req)) return false;
155
+ const path = new URL(req.url ?? '/', 'http://workbench.invalid').pathname;
156
+ if (!path.startsWith(base) || path.startsWith(`${base}__`)) return false;
157
+ let referer;
158
+ try {
159
+ referer = new URL(String(req.headers.referer ?? ''));
160
+ } catch {
161
+ return false;
162
+ }
163
+ return referer.origin === `http://${req.headers.host}` && referer.pathname.startsWith(base);
164
+ }
165
+
166
+ /**
167
+ * The HTML of a frame that runs the app's client script: the render with
168
+ * its own scripts taken out, as forTheFrame() has it, then the payload in a
169
+ * data block, never run, and one module script under the frame's nonce. That
170
+ * script installs React Refresh's preamble where the dev server has it --
171
+ * the app's components, transformed for Fast Refresh, refuse to load
172
+ * without it -- keeps a link from taking the frame anywhere, and imports
173
+ * the app's client module, which reads the payload and hydrates.
174
+ *
175
+ * @param {string} html
176
+ * @param {string} css
177
+ * @param {unknown[]} rows the payload, as the rows a page's own inline scripts push
178
+ * @param {string} nonce
179
+ * @param {string} client the client module's URL
180
+ * @param {string | null} refresh React Refresh's runtime's URL, where the dev server has it
181
+ */
182
+ export function scriptedFrame(html, css, rows, nonce, client, refresh) {
183
+ const json = (value) => JSON.stringify(value).replace(/</g, '\\u003c');
184
+ const boot =
185
+ (refresh === null
186
+ ? ''
187
+ : `import RefreshRuntime from ${json(refresh)};RefreshRuntime.injectIntoGlobalHook(window);` +
188
+ 'window.$RefreshReg$=()=>{};window.$RefreshSig$=()=>(type)=>type;' +
189
+ 'window.__vite_plugin_react_preamble_installed__=true;') +
190
+ // A link would load the app's own page into the frame: it stays put.
191
+ "document.addEventListener('click',(e)=>{if(e.target instanceof Element&&e.target.closest('a[href]'))e.preventDefault()},true);" +
192
+ `await import(${json(client)});`;
193
+ const tail =
194
+ `<script type="application/json" id="${PAYLOAD_ID}">${json(rows)}</script>` +
195
+ `<script type="module" nonce="${escapeHtml(nonce)}">${boot}</script>`;
196
+ // A stylesheet asked for with CORS from an opaque origin carries no
197
+ // Referer in Chromium, so forScriptedFrame() could not tell it from a
198
+ // page elsewhere asking: it is asked for without, as a stylesheet may be,
199
+ // and the frame's policy holds it to the dev server.
200
+ const out = forTheFrame(html, css).replace(/<link\b[^>]*>/gi, (tag) =>
201
+ /\brel=["']?stylesheet\b/i.test(tag)
202
+ ? tag.replace(/\s+crossorigin(?:=(?:"[^"]*"|'[^']*'|[^\s>]+))?/gi, '')
203
+ : tag,
204
+ );
205
+ return /<\/body>/i.test(out)
206
+ ? out.replace(/<\/body>(?![\s\S]*<\/body>)/i, () => `${tail}</body>`)
207
+ : out + tail;
208
+ }
209
+
210
+ /**
211
+ * A render's RSC payload as the rows a page's inline scripts push: text
212
+ * where it is UTF-8, and a `{"$b64": ...}` row where it is not, as the
213
+ * renderer writes them and `inlinePayloadStream()` reads them.
214
+ *
215
+ * @param {Uint8Array} bytes
216
+ * @returns {unknown[]}
217
+ */
218
+ export function payloadRows(bytes) {
219
+ if (bytes.length === 0) return [];
220
+ try {
221
+ return [new TextDecoder('utf-8', { fatal: true }).decode(bytes)];
222
+ } catch {
223
+ return [{ $b64: Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength).toString('base64') }];
224
+ }
225
+ }
226
+
227
+ /** @param {string} value */
228
+ function escapeHtml(value) {
229
+ return String(value).replace(
230
+ /[&<>"']/g,
231
+ (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#x27;' })[c],
232
+ );
233
+ }
234
+
235
+ /** @param {unknown} value */
236
+ function isRecord(value) {
237
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
238
+ }
239
+
240
+ /**
241
+ * A query client that answers from `answers` alone: a value, or a function
242
+ * of the call's arguments. A call it has no answer for fails, naming it.
243
+ *
244
+ * @param {Record<string, unknown>} answers
245
+ */
246
+ export function sampleQueries(answers) {
247
+ return {
248
+ async call(name, args) {
249
+ if (!Object.hasOwn(answers, name)) {
250
+ throw new Error(
251
+ `The workbench has no answer for '${name}': give the sample one under queries['${name}'].`,
252
+ );
253
+ }
254
+ const answer = answers[name];
255
+ return typeof answer === 'function' ? answer(args ?? {}) : structuredClone(answer);
256
+ },
257
+ };
258
+ }
259
+
260
+ /** A mint client whose tokens nothing takes: a <Deferred> boundary draws its fallback. */
261
+ const MINTS = { mint: async () => 'workbench' };
262
+
263
+ /**
264
+ * Every byte of a stream, up to MAX_PAYLOAD_BYTES.
265
+ *
266
+ * @param {ReadableStream<Uint8Array>} stream
267
+ */
268
+ async function readBytes(stream) {
269
+ const reader = stream.getReader();
270
+ const chunks = [];
271
+ let bytes = 0;
272
+ for (;;) {
273
+ const { done, value } = await reader.read();
274
+ if (done) break;
275
+ bytes += value.byteLength;
276
+ if (bytes > MAX_PAYLOAD_BYTES) {
277
+ await reader.cancel();
278
+ throw new Error('The render wrote more than 8 MiB of payload.');
279
+ }
280
+ chunks.push(value);
281
+ }
282
+ return new Uint8Array(Buffer.concat(chunks));
283
+ }
284
+
285
+ /** @param {ReadableStream<Uint8Array>} stream */
286
+ async function readAll(stream) {
287
+ const reader = stream.getReader();
288
+ const decoder = new TextDecoder();
289
+ let html = '';
290
+ let bytes = 0;
291
+ for (;;) {
292
+ const { done, value } = await reader.read();
293
+ if (done) break;
294
+ bytes += value.byteLength;
295
+ if (bytes > MAX_HTML_BYTES) {
296
+ await reader.cancel();
297
+ throw new Error('The render wrote more than 8 MiB.');
298
+ }
299
+ html += decoder.decode(value, { stream: true });
300
+ }
301
+ return html + decoder.decode();
302
+ }
303
+
304
+ /**
305
+ * The render made for the frame it is drawn in, which runs no script: its
306
+ * scripts and module preloads taken out -- none would run, and the payload
307
+ * they carry is not what is shown -- and `css`, styles the app's tooling
308
+ * would have injected with a script, put in its head.
309
+ *
310
+ * @param {string} html
311
+ * @param {string} css
312
+ */
313
+ export function forTheFrame(html, css = '') {
314
+ let out = html
315
+ .replace(/<script\b[^>]*>[\s\S]*?<\/script\s*>/gi, '')
316
+ .replace(/<link\b[^>]*\brel=["']?modulepreload["']?[^>]*>/gi, '');
317
+ if (css !== '') {
318
+ // Inside a <style>, only its own end tag ends it.
319
+ const style = `<style>${css.replace(/<\/style/gi, '<\\/style')}</style>`;
320
+ out = /<\/head>/i.test(out) ? out.replace(/<\/head>/i, () => `${style}</head>`) : style + out;
321
+ }
322
+ return out;
323
+ }
324
+
325
+ /**
326
+ * Reads a POST's body, up to MAX_PROPS_BYTES, as the `props` field of a
327
+ * form, parsed as JSON.
328
+ *
329
+ * @param {import('node:http').IncomingMessage} req
330
+ */
331
+ async function postedProps(req) {
332
+ const type = String(req.headers['content-type'] ?? '')
333
+ .split(';')[0]
334
+ .trim()
335
+ .toLowerCase();
336
+ if (type !== 'application/x-www-form-urlencoded') {
337
+ throw new WorkbenchRefused('Props are posted from the workbench page, as a form.');
338
+ }
339
+ let size = 0;
340
+ const chunks = [];
341
+ for await (const chunk of req) {
342
+ size += chunk.length;
343
+ if (size > MAX_PROPS_BYTES) throw new WorkbenchRefused('The props are larger than 64 KiB.');
344
+ chunks.push(chunk);
345
+ }
346
+ const form = new URLSearchParams(Buffer.concat(chunks).toString('utf8'));
347
+ try {
348
+ return { props: JSON.parse(form.get('props') ?? ''), sample: form.get('sample') ?? '' };
349
+ } catch {
350
+ throw new WorkbenchRefused('The props are not JSON.');
351
+ }
352
+ }
353
+
354
+ /**
355
+ * The queries a render is answered from: the entry's, then its sample's,
356
+ * then any given with the props -- where a function in the sample, shown
357
+ * as '[function]' to edit, is kept as the function.
358
+ */
359
+ function answersFor(entry, sample, given) {
360
+ const own = (value) => (isRecord(value) ? value : {});
361
+ const edited = Object.fromEntries(
362
+ Object.entries(own(given?.queries)).filter(([, value]) => value !== FUNCTION_SHOWN),
363
+ );
364
+ return { ...own(entry?.queries), ...own(sample?.queries), ...edited };
365
+ }
366
+
367
+ /** How a function among a sample's props is shown in the editor. */
368
+ const FUNCTION_SHOWN = '[function]';
369
+
370
+ /**
371
+ * The plugin. `module` is the app's workbench module, loaded in the rsc
372
+ * environment, whose default export has its `pages` and `components` with
373
+ * their samples, `renderPage(frame, client, mints)` and
374
+ * `renderElement(element, frame, client, mints)` -- each answering an RSC
375
+ * stream. `ssr` is a module of the app's, loaded in the ssr environment,
376
+ * whose `prerenderHtml(rscStream)` answers the whole HTML, every Suspense
377
+ * boundary filled in, since the frame runs no script to fill one in later.
378
+ * Neither is imported by an entry, so no build has them.
379
+ *
380
+ * `css`, when given, answers styles the app's tooling injects with a script
381
+ * in dev -- StyleX's, say -- for the frame, which runs none.
382
+ *
383
+ * @param {{ module: string, ssr: string, environment?: string, ssrEnvironment?: string, workbench?: string, playground?: string, css?: () => string | Promise<string> }} options
384
+ * @returns {import('vite').Plugin}
385
+ */
386
+ export function workbench({
387
+ module,
388
+ ssr,
389
+ environment = 'rsc',
390
+ ssrEnvironment = 'ssr',
391
+ workbench: workbenchPath = '__workbench',
392
+ playground: playgroundPath = '__playground',
393
+ css,
394
+ client,
395
+ }) {
396
+ return {
397
+ name: 'tinvar:workbench',
398
+ apply: 'serve',
399
+ configureServer(server) {
400
+ const refusal = workbenchRefusal();
401
+ if (refusal !== null) {
402
+ server.config.logger.info(`[tinvar:workbench] not mounted: ${refusal}.`);
403
+ return;
404
+ }
405
+
406
+ const base = server.config.base.replace(/\/?$/, '/');
407
+ const kinds = {
408
+ [`${base}${workbenchPath}`]: 'pages',
409
+ [`${base}${playgroundPath}`]: 'components',
410
+ };
411
+
412
+ // A scripted frame's modules, styles and fonts, and nothing else of
413
+ // the dev server's, may be read from its opaque origin.
414
+ if (client !== undefined) {
415
+ server.middlewares.use((req, res, next) => {
416
+ if (forScriptedFrame(req, base)) {
417
+ res.setHeader('access-control-allow-origin', 'null');
418
+ res.setHeader('vary', 'Origin, Referer');
419
+ }
420
+ next();
421
+ });
422
+ }
423
+
424
+ server.middlewares.use(async (req, res, next) => {
425
+ const url = new URL(req.url ?? '/', 'http://workbench.invalid');
426
+ const prefix = Object.keys(kinds).find((p) => url.pathname === p || url.pathname.startsWith(`${p}/`));
427
+ if (prefix === undefined) return next();
428
+ const kind = kinds[prefix];
429
+
430
+ const send = (status, type, body, policy, referrer = 'no-referrer') => {
431
+ res.statusCode = status;
432
+ res.setHeader('content-type', `${type}; charset=utf-8`);
433
+ res.setHeader('cache-control', 'no-store');
434
+ res.setHeader('x-content-type-options', 'nosniff');
435
+ res.setHeader('referrer-policy', referrer);
436
+ res.setHeader('cross-origin-resource-policy', 'same-origin');
437
+ res.setHeader('content-security-policy', policy);
438
+ res.end(body);
439
+ };
440
+ const plain = (status, text) => send(status, 'text/plain', text, "default-src 'none'");
441
+
442
+ // This machine, asked by this machine.
443
+ if (!fromThisMachine(req)) return plain(404, 'Not found');
444
+
445
+ const host = String(req.headers.host);
446
+ const origin = `http://${host}`;
447
+ const rest = url.pathname.slice(prefix.length).replace(/^\//, '');
448
+ const [name, action, extra] = rest === '' ? [] : rest.split('/');
449
+ if (extra !== undefined || (action !== undefined && action !== 'render')) {
450
+ return plain(404, 'Not found');
451
+ }
452
+
453
+ // With the app's client script, where the app gives a client module.
454
+ const scripted = url.searchParams.get('script') === '1';
455
+ if (scripted && client === undefined) {
456
+ return plain(404, 'This app gives the workbench no client module to run: workbench({ client }).');
457
+ }
458
+
459
+ const posted = req.method === 'POST';
460
+ if (!posted && req.method !== 'GET' && req.method !== 'HEAD') {
461
+ res.setHeader('allow', 'GET, HEAD, POST');
462
+ return plain(405, 'Method not allowed');
463
+ }
464
+ // Given props come from the workbench's own page, and from no other:
465
+ // a form on any site can post to this machine's address.
466
+ if (posted) {
467
+ const site = req.headers['sec-fetch-site'];
468
+ if (
469
+ action !== 'render' ||
470
+ req.headers.origin !== origin ||
471
+ (site !== undefined && site !== 'same-origin')
472
+ ) {
473
+ return plain(403, 'Props are posted from the workbench page alone.');
474
+ }
475
+ }
476
+
477
+ const page =
478
+ "default-src 'none'; style-src 'unsafe-inline'; frame-src 'self'; form-action 'self'; " +
479
+ "frame-ancestors 'none'; base-uri 'none'";
480
+
481
+ try {
482
+ const app = (await load(server, environment, module)).default;
483
+ if (
484
+ !isRecord(app) ||
485
+ typeof app.renderPage !== 'function' ||
486
+ typeof app.renderElement !== 'function'
487
+ ) {
488
+ throw new Error(
489
+ `${module} must export a default workbench: its pages, components, renderPage and renderElement.`,
490
+ );
491
+ }
492
+ const entries = isRecord(app[kind]) ? app[kind] : {};
493
+ const names = Object.keys(entries)
494
+ .filter((n) => NAME.test(n))
495
+ .sort();
496
+
497
+ if (name === undefined) {
498
+ return send(200, 'text/html', index(kind, prefix, entries, names), page);
499
+ }
500
+ if (!NAME.test(name) || !names.includes(name) || !Object.hasOwn(entries, name)) {
501
+ return plain(404, `No such ${kind === 'pages' ? 'page' : 'component'}.`);
502
+ }
503
+ const entry = entries[name];
504
+ const samples = isRecord(entry?.samples) ? entry.samples : {};
505
+ const sampleNames = Object.keys(samples).filter((n) => NAME.test(n));
506
+ const asked = url.searchParams.get('sample') ?? sampleNames[0] ?? '';
507
+ if (!posted && (!NAME.test(asked) || !Object.hasOwn(samples, asked))) {
508
+ return plain(404, 'No such sample.');
509
+ }
510
+
511
+ if (action === undefined) {
512
+ return send(
513
+ 200,
514
+ 'text/html',
515
+ detail(kind, prefix, name, sampleNames, asked, samples[asked], scripted, client !== undefined),
516
+ page,
517
+ );
518
+ }
519
+
520
+ let given = samples[asked];
521
+ let sample = samples[asked];
522
+ if (posted) {
523
+ const form = await postedProps(req);
524
+ given = form.props;
525
+ sample =
526
+ NAME.test(form.sample) && Object.hasOwn(samples, form.sample)
527
+ ? samples[form.sample]
528
+ : undefined;
529
+ }
530
+ const prerenderer = await load(server, ssrEnvironment, ssr);
531
+ if (typeof prerenderer.prerenderHtml !== 'function') {
532
+ throw new Error(`${ssr} must export prerenderHtml(rscStream), answering the whole HTML.`);
533
+ }
534
+ const drawn = await render(
535
+ app,
536
+ prerenderer,
537
+ kind,
538
+ entry,
539
+ given,
540
+ answersFor(entry, sample, given),
541
+ scripted,
542
+ );
543
+ const styles = typeof css === 'function' ? String((await css()) ?? '') : '';
544
+ if (scripted) {
545
+ const nonce = randomBytes(16).toString('base64');
546
+ const html = scriptedFrame(
547
+ drawn.html,
548
+ styles,
549
+ drawn.rows ?? [],
550
+ nonce,
551
+ await clientUrl(server, client, base),
552
+ server.config.plugins.some((p) => p.name === 'vite:react-refresh')
553
+ ? `${base}@react-refresh`
554
+ : null,
555
+ );
556
+ // Its own origin, an opaque one: no cookie, no storage, no
557
+ // reach into this page or the app's. Script under the nonce and
558
+ // from the dev server alone, which answers its modules to that
559
+ // origin only for a Referer of its own (forScriptedFrame); eval
560
+ // for React's development build, as the app's dev pages allow.
561
+ // It connects nowhere -- no fetch, no socket, so no request of
562
+ // the app's, Vite's HMR included -- posts no form and frames
563
+ // nothing. Its Referer is sent, to this origin alone, since that
564
+ // is what lets its modules load.
565
+ return send(
566
+ 200,
567
+ 'text/html',
568
+ html,
569
+ `sandbox allow-scripts; default-src 'none'; script-src 'nonce-${nonce}' 'unsafe-eval' ${origin}${base}; ` +
570
+ `style-src ${origin} 'unsafe-inline'; img-src ${origin} data: blob:; font-src ${origin} data:; ` +
571
+ "connect-src 'none'; form-action 'none'; frame-src 'none'; worker-src 'none'; object-src 'none'; " +
572
+ "frame-ancestors 'self'; base-uri 'none'",
573
+ 'same-origin',
574
+ );
575
+ }
576
+ const html = forTheFrame(drawn.html, styles);
577
+ // Drawn in a sandbox of its own: no script and no form, by the
578
+ // sandbox and by the policy both, and nothing loaded but styles,
579
+ // fonts and images from here. It keeps this origin -- with no
580
+ // script to use it -- so the stylesheet's fonts, which are always
581
+ // fetched by CORS, load.
582
+ return send(
583
+ 200,
584
+ 'text/html',
585
+ html,
586
+ `sandbox allow-same-origin; default-src 'none'; script-src 'none'; style-src ${origin} 'unsafe-inline'; ` +
587
+ `img-src ${origin} data:; font-src ${origin} data:; frame-ancestors 'self'; base-uri 'none'; form-action 'none'`,
588
+ );
589
+ } catch (error) {
590
+ const message = error instanceof Error ? `${error.name}: ${error.message}` : String(error);
591
+ return plain(error instanceof WorkbenchRefused ? 400 : 500, message);
592
+ }
593
+ });
594
+ },
595
+ };
596
+ }
597
+
598
+ /**
599
+ * The URL the browser loads the app's client module by: under the dev
600
+ * server's base, by its path in the project, or through `@fs` for a file
601
+ * outside it.
602
+ */
603
+ async function clientUrl(server, module, base) {
604
+ const env = server.environments.client;
605
+ const resolved = env ? await env.pluginContainer.resolveId(module, undefined) : null;
606
+ if (!resolved) throw new Error(`${module} does not resolve in the 'client' environment.`);
607
+ const root = server.config.root.replace(/\/?$/, '/');
608
+ const id = resolved.id.split('?')[0];
609
+ return id.startsWith(root) ? `${base}${id.slice(root.length)}` : `${base}@fs${id}`;
610
+ }
611
+
612
+ /**
613
+ * One render: a page's frame, or a component's element, from its props --
614
+ * the app's RSC stream, then the whole HTML from it; and, for a frame that
615
+ * runs the app's client script, the payload too, as rows.
616
+ */
617
+ async function render(app, html, kind, entry, given, queries, keep = false) {
618
+ if (!isRecord(given)) throw new WorkbenchRefused('The props are a JSON object.');
619
+ const frame = {
620
+ rid: `workbench-${randomBytes(8).toString('hex')}`,
621
+ nonce: randomBytes(16).toString('base64'),
622
+ allow: Object.keys(queries).filter((q) => !q.startsWith('$')),
623
+ };
624
+ const client = sampleQueries(queries);
625
+
626
+ if (kind === 'pages') {
627
+ if (typeof entry.route !== 'string') throw new Error('A workbench page names its route.');
628
+ const pageFrame = {
629
+ ...frame,
630
+ route: entry.route,
631
+ params: isRecord(given.params) ? given.params : {},
632
+ data: { page: given.page ?? null, layouts: isRecord(given.layouts) ? given.layouts : {} },
633
+ };
634
+ return drawn(html, await app.renderPage(pageFrame, client, MINTS), keep);
635
+ }
636
+
637
+ if (typeof entry.render !== 'function') throw new Error('A playground component has a render(props).');
638
+ const props = isRecord(given.props) ? given.props : {};
639
+ return drawn(html, await app.renderElement(entry.render(props), frame, client, MINTS), keep);
640
+ }
641
+
642
+ /**
643
+ * The HTML of an RSC stream, and its payload where $keep: the stream read
644
+ * twice, the payload while the HTML is drawn from it.
645
+ *
646
+ * @param {{ prerenderHtml(stream: ReadableStream<Uint8Array>): Promise<ReadableStream<Uint8Array>> }} html
647
+ * @param {ReadableStream<Uint8Array>} stream
648
+ * @param {boolean} keep
649
+ */
650
+ async function drawn(html, stream, keep) {
651
+ if (!keep) return { html: await readAll(await html.prerenderHtml(stream)), rows: null };
652
+ const [forHtml, forPayload] = stream.tee();
653
+ const payload = readBytes(forPayload);
654
+ const out = await readAll(await html.prerenderHtml(forHtml));
655
+ return { html: out, rows: payloadRows(await payload) };
656
+ }
657
+
658
+ /**
659
+ * The default export of `module`, imported in `environment` of the dev
660
+ * server, where it renders: that environment's own module runner, never
661
+ * Node's, so it shares React and the RSC plugin's set-up with the app.
662
+ */
663
+ async function load(server, environment, module) {
664
+ const env = server.environments[environment];
665
+ if (!env?.runner) throw new Error(`The dev server has no runnable '${environment}' environment.`);
666
+ const resolved = await env.pluginContainer.resolveId(module, undefined);
667
+ if (!resolved) throw new Error(`${module} does not resolve in the '${environment}' environment.`);
668
+
669
+ return env.runner.import(resolved.id);
670
+ }
671
+
672
+ /** The list of pages or components, each with its samples. */
673
+ function index(kind, prefix, entries, names) {
674
+ const title = kind === 'pages' ? 'Workbench' : 'Playground';
675
+ const items = names
676
+ .map((n) => {
677
+ const samples = isRecord(entries[n]?.samples)
678
+ ? Object.keys(entries[n].samples).filter((s) => NAME.test(s))
679
+ : [];
680
+ const links = samples
681
+ .map(
682
+ (s) =>
683
+ `<a href="${escapeHtml(`${prefix}/${n}?sample=${encodeURIComponent(s)}`)}">${escapeHtml(s)}</a>`,
684
+ )
685
+ .join(' · ');
686
+ const route =
687
+ kind === 'pages' && typeof entries[n]?.route === 'string'
688
+ ? ` <code>${escapeHtml(entries[n].route)}</code>`
689
+ : '';
690
+ return `<li><strong>${escapeHtml(n)}</strong>${route} ${links}</li>`;
691
+ })
692
+ .join('');
693
+ const other = kind === 'pages' ? ['__playground', 'components'] : ['__workbench', 'pages'];
694
+ const otherHref = prefix.replace(/__[a-z]+$/, other[0]);
695
+ return shell(
696
+ title,
697
+ `<h1>${title}</h1><p>The app's ${kind}, each drawn from its samples, in isolation. ` +
698
+ `<a href="${escapeHtml(otherHref)}">The ${other[1]}</a>.</p><ul>${items || '<li>None yet.</li>'}</ul>`,
699
+ );
700
+ }
701
+
702
+ /** One page or component: its samples, the props to edit, and the render beside them. */
703
+ function detail(kind, prefix, name, sampleNames, asked, sample, scripted = false, canScript = false) {
704
+ const base = `${prefix}/${name}`;
705
+ const script = scripted ? '&script=1' : '';
706
+ const tabs = sampleNames
707
+ .map((s) =>
708
+ s === asked
709
+ ? `<strong>${escapeHtml(s)}</strong>`
710
+ : `<a href="${escapeHtml(`${base}?sample=${encodeURIComponent(s)}${script}`)}">${escapeHtml(s)}</a>`,
711
+ )
712
+ .join(' · ');
713
+ // Static, or with the app's client script in a frame of an origin of its own.
714
+ const modes = canScript
715
+ ? `<p>${
716
+ scripted
717
+ ? `<a href="${escapeHtml(`${base}?sample=${encodeURIComponent(asked)}`)}">Static</a> · <strong>With client script</strong>` +
718
+ ' — hydrated in a sandbox of its own origin: it holds no cookie, connects nowhere and posts no form, so a query or an action it makes fails.'
719
+ : `<strong>Static</strong> · <a href="${escapeHtml(`${base}?sample=${encodeURIComponent(asked)}&script=1`)}">With client script</a>`
720
+ }</p>`
721
+ : '';
722
+ const json = JSON.stringify(
723
+ sample ?? {},
724
+ (_key, value) => (typeof value === 'function' ? FUNCTION_SHOWN : value),
725
+ 2,
726
+ );
727
+ const render = `${base}/render?sample=${encodeURIComponent(asked)}${script}`;
728
+ return shell(
729
+ name,
730
+ `<p><a href="${escapeHtml(prefix)}">${kind === 'pages' ? 'Workbench' : 'Playground'}</a></p>` +
731
+ `<h1>${escapeHtml(name)}</h1><p>${tabs}</p>${modes}` +
732
+ `<form method="post" action="${escapeHtml(`${base}/render${scripted ? '?script=1' : ''}`)}" target="drawn">` +
733
+ `<input type="hidden" name="sample" value="${escapeHtml(asked)}">` +
734
+ `<textarea name="props" spellcheck="false">${escapeHtml(json)}</textarea>` +
735
+ '<p><button type="submit">Draw with these props</button> ' +
736
+ `A query the sample answers with a function shows as <code>${FUNCTION_SHOWN}</code> and is still answered by it; ` +
737
+ 'give a value under <code>queries</code> to change it.</p></form>' +
738
+ // Never both: a frame that runs script and keeps this origin could
739
+ // take its own sandbox off.
740
+ `<iframe name="drawn" src="${escapeHtml(render)}" sandbox="${scripted ? 'allow-scripts' : 'allow-same-origin'}" title="${escapeHtml(name)}"></iframe>`,
741
+ );
742
+ }
743
+
744
+ /** @param {string} title @param {string} body */
745
+ function shell(title, body) {
746
+ return (
747
+ `<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><title>${escapeHtml(title)} · workbench</title>` +
748
+ '<style>body{font:14px/1.5 system-ui,sans-serif;margin:24px;color:#111110;background:#F7F7F5}' +
749
+ 'iframe{width:100%;height:720px;border:1px solid #E6E5E1;background:#fff}' +
750
+ 'textarea{width:100%;height:220px;font:12px/1.4 ui-monospace,monospace;border:1px solid #E6E5E1;padding:8px}' +
751
+ 'li{margin:4px 0}</style>' +
752
+ `</head><body>${body}</body></html>`
753
+ );
754
+ }