@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
package/src/workbench.js
ADDED
|
@@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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
|
+
}
|