@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.
@@ -0,0 +1,460 @@
1
+ // Dev mode: Vite's dev server is the renderer.
2
+ //
3
+ // tinvar-spec.md, "Operations and tooling": "Dev mode: Vite's dev server is
4
+ // the renderer, with HMR and no separate build." The rsc and ssr
5
+ // environments render from source through Vite's module runner, and the
6
+ // browser loads the client environment from the same dev server -- under
7
+ // DEV_BASE, which PHP's server proxies to it, HMR socket included, so the
8
+ // app runs on one origin ("Dev experience": "HMR passed through PHP").
9
+ //
10
+ // What production's renderer has and this does not: the permission-model
11
+ // sandbox and the in-process network lock. Vite watches the whole project,
12
+ // spawns its bundlers' native code and serves HTTP, none of which the
13
+ // sandbox would let it do. In their place: it refuses to start anywhere
14
+ // that says production, binds loopback only, keeps the dev server's file
15
+ // serving away from secrets, and still refuses an environment holding
16
+ // credentials. That is a dev tool's footing, not production's, which is why
17
+ // production can never reach it.
18
+
19
+ import { createRequire } from 'node:module';
20
+ import { existsSync, mkdirSync, renameSync, statSync, writeFileSync } from 'node:fs';
21
+ import path from 'node:path';
22
+ import { useDevEntry } from './dev-app.js';
23
+ import { assertDevelopment, DevServerRefused } from './dev-guard.js';
24
+ import {
25
+ actionsOf,
26
+ boundaryUsers,
27
+ QUERY_MAP,
28
+ queriesOfComponents,
29
+ queriesOfViews,
30
+ queryUsers,
31
+ readQueryMap,
32
+ viewsOf,
33
+ } from './vite.js';
34
+
35
+ export { assertDevelopment, DevServerRefused, PRODUCTION_MARKERS } from './dev-guard.js';
36
+
37
+ /** Where the dev server's modules are served from, behind PHP's server. */
38
+ export const DEV_BASE = '/_vite/';
39
+
40
+ /** Addresses the dev server may listen on: this machine's, and nothing else. */
41
+ const LOOPBACK = new Set(['127.0.0.1', '::1']);
42
+
43
+ /** The app's Vite config file. */
44
+ function configFileOf(appRoot) {
45
+ for (const name of ['vite.config.ts', 'vite.config.mts', 'vite.config.js', 'vite.config.mjs']) {
46
+ const file = path.join(appRoot, name);
47
+ if (existsSync(file)) return file;
48
+ }
49
+ throw new Error(`No Vite config in ${appRoot}; dev mode renders through the app's own Vite environments.`);
50
+ }
51
+
52
+ /**
53
+ * Starts Vite's dev server on loopback for the app at `appRoot`, and points
54
+ * the renderer's dev entry at its rsc environment.
55
+ *
56
+ * @param {{ appRoot: string, port: number, host?: string, deferredManifest?: string | null, logLevel?: string }} options
57
+ * `deferredManifest` is where to keep the views' <Deferred> components
58
+ * up to date, as the build's deferredBoundaries() writes them
59
+ * @returns {Promise<{ vite: any, url: string, close: () => Promise<void> }>}
60
+ */
61
+ export async function createDevServer({
62
+ appRoot,
63
+ port,
64
+ host = '127.0.0.1',
65
+ deferredManifest = null,
66
+ logLevel,
67
+ }) {
68
+ assertDevelopment();
69
+ if (!LOOPBACK.has(host)) {
70
+ throw new DevServerRefused(`The dev server listens on loopback only; refusing ${host}.`);
71
+ }
72
+
73
+ // The app's own Vite, the one its plugins were written against.
74
+ const require = createRequire(path.join(appRoot, 'package.json'));
75
+ const { createServer } = await import(require.resolve('vite'));
76
+
77
+ // Before the watcher exists: what was written before this moment is no
78
+ // change it should act on. See ignoreReplayedChanges().
79
+ const watchedFrom = Date.now();
80
+
81
+ // Told when Vite makes its server again -- an edit to vite.config.ts
82
+ // restarts it -- once the new one is listening (see followRestarts()).
83
+ let restarted = () => {};
84
+ // Settled once a restarted server listens: what asks for the environment
85
+ // in the middle of a restart -- the new environments made, the new server
86
+ // not yet listening -- waits for that rather than reading no address as
87
+ // one off loopback.
88
+ let listened = null;
89
+ const restartListening = () => (listened ??= Promise.withResolvers()).promise;
90
+
91
+ const vite = await createServer({
92
+ root: appRoot,
93
+ configFile: configFileOf(appRoot),
94
+ base: DEV_BASE,
95
+ // No index.html and no SPA fallback: PHP serves every document.
96
+ appType: 'custom',
97
+ clearScreen: false,
98
+ ...(logLevel ? { logLevel } : {}),
99
+ plugins: [
100
+ ignoreReplayedChanges(watchedFrom),
101
+ followRestarts(() => {
102
+ const waiting = listened;
103
+ listened = null;
104
+ waiting?.resolve();
105
+ restarted();
106
+ }),
107
+ ],
108
+ // The plugin's own request handler renders through the rsc entry's
109
+ // default export, over HTTP. Here documents come from PHP, over the
110
+ // socket, so the dev server serves modules and nothing else.
111
+ rsc: { serverHandler: false },
112
+ server: {
113
+ host,
114
+ port,
115
+ strictPort: true,
116
+ fs: {
117
+ // Vite's own refusals, and the secrets an app keeps beside its code:
118
+ // a key file, a database, the PHP dependencies.
119
+ deny: ['.env', '.env.*', '*.{crt,pem}', '**/.git/**', '**/*.key', '**/*.sqlite*', '**/vendor/**'],
120
+ },
121
+ },
122
+ });
123
+
124
+ await vite.listen();
125
+
126
+ const address = loopbackAddress(vite);
127
+ if (address === null) {
128
+ const bound = vite.httpServer?.address();
129
+ await vite.close();
130
+ throw new DevServerRefused(`The dev server bound ${JSON.stringify(bound)}, which is not loopback.`);
131
+ }
132
+
133
+ // The rsc environment renders go through, and its entry's id. Vite makes
134
+ // its environments again when it restarts, closing the old ones' module
135
+ // runners: a render through the one the server started with failed from
136
+ // then on. So each render asks for the environment Vite has now, and one
137
+ // that has changed is taken up -- its entry resolved, the ssr environment
138
+ // warmed, the watcher followed -- before anything renders through it.
139
+ let current = null;
140
+ let refused = null;
141
+ let taking = null;
142
+ const rscEntry = async (rsc) => {
143
+ const input = rsc.config.build.rollupOptions.input;
144
+ const source = typeof input === 'string' ? input : input?.index;
145
+ if (typeof source !== 'string') {
146
+ throw new Error("The rsc environment names no 'index' entry in build.rollupOptions.input.");
147
+ }
148
+ const resolved = await rsc.pluginContainer.resolveId(source, undefined);
149
+ if (!resolved) throw new Error(`The rsc entry ${source} does not resolve.`);
150
+ return resolved.id;
151
+ };
152
+ const environment = async () => {
153
+ if (refused !== null) throw refused;
154
+ const rsc = vite.environments.rsc;
155
+ if (current?.rsc === rsc) return current;
156
+ taking ??= (async () => {
157
+ try {
158
+ // Restarted on another address -- a config that moved the host --
159
+ // is closed rather than served: dev stays on loopback, whatever
160
+ // the config now says. A server not listening yet is a restart
161
+ // still under way, which says nothing of its address until it is.
162
+ if (current !== null && vite.httpServer && !vite.httpServer.listening) {
163
+ await restartListening();
164
+ }
165
+ if (current !== null && loopbackAddress(vite) === null) {
166
+ refused = new DevServerRefused(
167
+ `The dev server restarted on ${JSON.stringify(vite.httpServer?.address())}, which is not loopback; it is closed. Start dev again.`,
168
+ );
169
+ await vite.close();
170
+ throw refused;
171
+ }
172
+ const id = await rscEntry(rsc);
173
+ const restart = current !== null;
174
+ current = { rsc, id };
175
+ if (restart) {
176
+ await rsc.runner.import(id);
177
+ await warmSsr(vite, require);
178
+ watch();
179
+ }
180
+ return current;
181
+ } finally {
182
+ taking = null;
183
+ }
184
+ })();
185
+ return taking;
186
+ };
187
+ const load = async () => {
188
+ const { rsc, id } = await environment();
189
+ return rsc.runner.import(id);
190
+ };
191
+
192
+ try {
193
+ await environment();
194
+ } catch (error) {
195
+ await vite.close();
196
+ throw error;
197
+ }
198
+ useDevEntry(load);
199
+
200
+ // Loaded once now, so a broken entry fails the start rather than the
201
+ // first page, and the module graph the deferred manifest is read from
202
+ // holds every view the entry imports.
203
+ await load();
204
+
205
+ // And what a document render asks of the ssr environment, before the
206
+ // socket that says this renderer is ready exists.
207
+ await warmSsr(vite, require);
208
+
209
+ // Taken up as soon as a restarted server listens, not at the next render:
210
+ // the first page after a config edit renders in PHP's time, as the first
211
+ // page after a start does.
212
+ restarted = () => {
213
+ environment().catch((error) => {
214
+ console.error('[tinvar:renderer] could not take up the restarted dev server:', error?.message ?? error);
215
+ });
216
+ };
217
+
218
+ // The watcher is Vite's server's, and a restart makes a new one: what
219
+ // listens on it is put on the one in use, and taken off the one before.
220
+ let watching = null;
221
+ let stopWatching = () => {};
222
+ const watch = () => {
223
+ if (watching === null || watching.watcher === vite.watcher) return;
224
+ watching.off();
225
+ watching.on();
226
+ };
227
+ if (deferredManifest) {
228
+ const write = async () => {
229
+ try {
230
+ await load();
231
+ writeAtomically(deferredManifest, await deferredManifestOf(vite, appRoot));
232
+ } catch (error) {
233
+ // An edit that does not compile is Vite's to report, in the browser
234
+ // overlay; the last manifest that did stays.
235
+ console.error(
236
+ "[tinvar:renderer] could not read the views' deferred boundaries:",
237
+ error?.message ?? error,
238
+ );
239
+ }
240
+ };
241
+ await write();
242
+ let timer = null;
243
+ const changed = () => {
244
+ clearTimeout(timer);
245
+ timer = setTimeout(write, 150);
246
+ };
247
+ watching = {
248
+ watcher: null,
249
+ on() {
250
+ this.watcher = vite.watcher;
251
+ for (const event of ['change', 'add', 'unlink']) this.watcher.on(event, changed);
252
+ },
253
+ off() {
254
+ for (const event of ['change', 'add', 'unlink']) this.watcher?.off(event, changed);
255
+ this.watcher = null;
256
+ },
257
+ };
258
+ watching.on();
259
+ stopWatching = () => {
260
+ clearTimeout(timer);
261
+ watching?.off();
262
+ };
263
+ }
264
+
265
+ const shown = address.family === 'IPv6' ? `[${address.address}]` : address.address;
266
+
267
+ return {
268
+ vite,
269
+ url: `http://${shown}:${address.port}${DEV_BASE}`,
270
+ async close() {
271
+ stopWatching();
272
+ await vite.close();
273
+ },
274
+ };
275
+ }
276
+
277
+ /** The dev server's address when it is a loopback one; null for any other, or none. */
278
+ function loopbackAddress(vite) {
279
+ const address = vite.httpServer?.address();
280
+ return address && typeof address === 'object' && LOOPBACK.has(address.address) ? address : null;
281
+ }
282
+
283
+ /**
284
+ * Calls `restarted` each time Vite makes its server again after the first,
285
+ * once the new server is listening: a restart, for an edit to the config
286
+ * file or a plugin's asking. configureServer is called for every server
287
+ * Vite makes, this plugin being in the inline config every one is made
288
+ * from; the first is the one createDevServer() waits for itself.
289
+ *
290
+ * @param {() => void} restarted
291
+ */
292
+ function followRestarts(restarted) {
293
+ let made = 0;
294
+ return {
295
+ name: 'tinvar:follow-restarts',
296
+ apply: 'serve',
297
+ configureServer(server) {
298
+ if (made++ === 0) return;
299
+ server.httpServer?.once('listening', () => restarted());
300
+ },
301
+ };
302
+ }
303
+
304
+ /**
305
+ * Drops the watcher's change events for files that have not changed since
306
+ * `watchedFrom`.
307
+ *
308
+ * macOS's file events can report writes made before the watch began: files
309
+ * written a few seconds before -- an app copied, a branch checked out, the
310
+ * dev server started straight after -- came back as changes the next time
311
+ * anything in the tree was written, seconds later or many, and all at
312
+ * once. Vite acts on each: vite.config.ts "changed", so it restarted the
313
+ * server under the renderer, whose renders then failed; tsconfig.json
314
+ * "changed", so every page was reloaded; and the edit that set it off was
315
+ * lost among them. A change the watcher reports for a file whose inode was
316
+ * last changed before the watch began is that history, and goes no further.
317
+ * The inode's change time, not the modification time a copy or an archive
318
+ * may carry over: only the kernel sets it, to the moment of the write.
319
+ *
320
+ * Changes only. An added file was not there when the watch began, whatever
321
+ * its times say -- a directory moved in keeps its files' -- and goes on.
322
+ *
323
+ * @param {number} watchedFrom milliseconds since the epoch
324
+ */
325
+ function ignoreReplayedChanges(watchedFrom) {
326
+ const before = (file) => {
327
+ try {
328
+ return statSync(file).ctimeMs < watchedFrom;
329
+ } catch {
330
+ return false;
331
+ }
332
+ };
333
+
334
+ return {
335
+ name: 'tinvar:ignore-replayed-changes',
336
+ apply: 'serve',
337
+ // The first hook that has the watcher. Nothing in the tree is written
338
+ // before it -- Vite's own writes start with listen() -- so no event can
339
+ // set the history off before this is in place.
340
+ configureServer(server) {
341
+ const { watcher } = server;
342
+ const emit = watcher.emit;
343
+ watcher.emit = function (event, ...args) {
344
+ // chokidar emits each event twice: by its name, and as 'all'.
345
+ const file = event === 'change' ? args[0] : event === 'all' && args[0] === 'change' ? args[1] : null;
346
+ if (typeof file === 'string' && before(file)) return false;
347
+
348
+ return emit.call(this, event, ...args);
349
+ };
350
+ },
351
+ };
352
+ }
353
+
354
+ /**
355
+ * Loads the ssr environment's entry, and every client component the views
356
+ * reach as the ssr environment imports it -- by the key the RSC stream names
357
+ * it with -- so that the first document render loads none of it.
358
+ *
359
+ * Left to that render, it was most of the render: the shop's icon library
360
+ * alone is some 1,900 modules, one per icon, each transformed and evaluated,
361
+ * and on a loaded machine that took 12 to 18 s, inside the 15 s PHP gives a
362
+ * render. The socket had said the renderer was ready, and the first page was
363
+ * a 500. The time is the same here; it is spent before anyone is waiting.
364
+ *
365
+ * The client components are the RSC plugin's to know, through its
366
+ * (experimental) plugin API. One that does not load is not the start's to
367
+ * fail: it is the page's, which shows it in Vite's overlay as it would have.
368
+ *
369
+ * @param {any} vite
370
+ * @param {NodeJS.Require} require the app's, which resolves its own plugins
371
+ */
372
+ async function warmSsr(vite, require) {
373
+ const ssr = vite.environments.ssr;
374
+ if (!ssr?.runner) return;
375
+
376
+ const warm = (id, load) =>
377
+ Promise.resolve()
378
+ .then(() => load(id))
379
+ .catch((error) => {
380
+ console.error(
381
+ `[tinvar:renderer] could not load ${id} ahead of the first render:`,
382
+ error?.message ?? error,
383
+ );
384
+ });
385
+ const imported = (id) => ssr.runner.import(id);
386
+
387
+ const input = ssr.config.build.rollupOptions.input;
388
+ const source = typeof input === 'string' ? input : input?.index;
389
+ const entry = typeof source === 'string' ? await ssr.pluginContainer.resolveId(source, undefined) : null;
390
+ if (entry) await warm(entry.id, imported);
391
+
392
+ let manager;
393
+ try {
394
+ const { getPluginApi } = await import(require.resolve('@vitejs/plugin-rsc'));
395
+ manager = getPluginApi?.(vite.config)?.manager;
396
+ } catch {
397
+ // No RSC plugin of that shape: the ssr environment's entry is warm, and
398
+ // its client components load on the first render, as before.
399
+ }
400
+
401
+ // Through the loader the ssr entry installed for the RSC stream's client
402
+ // references, where there is one: it collects each component's styles and
403
+ // checks the reference as well as importing it, as the render would. With
404
+ // the modules alone warm, the first render still took 6 s on a loaded
405
+ // machine; through the loader, a second or two.
406
+ const loader = globalThis.__vite_rsc_client_require__;
407
+ const load = typeof loader === 'function' ? loader : imported;
408
+ await Promise.all(
409
+ Object.values(manager?.clientReferenceMetaMap ?? {}).map((meta) => warm(meta.referenceKey, load)),
410
+ );
411
+ }
412
+
413
+ /**
414
+ * The views' <Deferred> components, read from the rsc environment's module
415
+ * graph as the build's deferredBoundaries() reads them from the bundle --
416
+ * the same viewsOf(), over what the dev server has resolved -- so a route
417
+ * may mint in dev exactly when it could from a build of the same source
418
+ * (ADR 0009, ADR 0024). And the queries each view and each registered
419
+ * deferred component may call, the same way, so a route's allowlist in dev
420
+ * is the one its build would give it. A call the build would refuse throws
421
+ * here, and the last list that could be read stays.
422
+ *
423
+ * @returns {Promise<{ format: 1, views: Record<string, string[]>, queries: Record<string, string[]>, componentQueries: Record<string, string[]>, actions: Record<string, string[]>, componentActions: Record<string, string[]> }>}
424
+ */
425
+ export async function deferredManifestOf(vite, appRoot, pages = 'resources/pages', queryMap = QUERY_MAP) {
426
+ const rsc = vite.environments.rsc;
427
+ const pagesDir = path.resolve(appRoot, pages);
428
+
429
+ /** @type {Map<string, string[]>} */
430
+ const graph = new Map();
431
+ for (const [id, mod] of rsc.moduleGraph.idToModuleMap) {
432
+ graph.set(
433
+ id,
434
+ [...mod.importedModules].map((imported) => imported.id).filter((v) => typeof v === 'string'),
435
+ );
436
+ }
437
+
438
+ const resolveId = async (specifier, importer) =>
439
+ (await rsc.pluginContainer.resolveId(specifier, importer))?.id ?? null;
440
+ const users = await boundaryUsers(graph, pagesDir, resolveId);
441
+ const callers = await queryUsers(graph, pagesDir, resolveId, readQueryMap(path.resolve(appRoot, queryMap)));
442
+ const actions = await actionsOf(graph, callers.registry, pagesDir, resolveId);
443
+
444
+ return {
445
+ format: 1,
446
+ views: viewsOf(graph, users, pagesDir),
447
+ queries: queriesOfViews(graph, callers.calls, pagesDir),
448
+ componentQueries: queriesOfComponents(graph, callers.calls, callers.registry),
449
+ actions: actions.views,
450
+ componentActions: actions.components,
451
+ };
452
+ }
453
+
454
+ /** Written aside and moved into place, so PHP never reads half a file. */
455
+ function writeAtomically(file, data) {
456
+ mkdirSync(path.dirname(file), { recursive: true });
457
+ const aside = `${file}.${process.pid}.tmp`;
458
+ writeFileSync(aside, JSON.stringify(data, null, 2) + '\n');
459
+ renameSync(aside, file);
460
+ }