@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.
package/src/vite.js ADDED
@@ -0,0 +1,2416 @@
1
+ // Which views contain a <Deferred> boundary, read from the renderer build.
2
+ //
3
+ // ADR 0009: "A page with no deferred boundaries cannot be talked into
4
+ // minting" -- which holds only while PHP knows, before a page renders, which
5
+ // pages have boundaries. Only the build knows that. A boundary is an ordinary
6
+ // server component somewhere in a page's module graph -- in the page, in a
7
+ // component it imports, in its layout -- and the graph exists whole only
8
+ // while the bundler has it. So the rsc build writes it down: for every module
9
+ // under the app's pages directory, the <Deferred> components reachable from
10
+ // it. `tinvar build:fragments` joins that with the route registry, which says
11
+ // which views each route renders, into the FragmentManifest PHP reads.
12
+ //
13
+ // Reachability is the bundler's, not a guess from file names: it follows the
14
+ // imports the rsc environment actually resolved, so an alias, a package or a
15
+ // re-export reaches the boundary the way the render will. A 'use client'
16
+ // module is a client reference in that graph, its own imports gone -- which
17
+ // is right, since a client component cannot render a server one.
18
+ //
19
+ // The component names are read from each module that imports Deferred, as
20
+ // the literal its `component` names. Anything that cannot be read as one --
21
+ // a computed name, Deferred passed around as a value, re-exported -- is
22
+ // written as "*": the route defers, with any component. Unreadable widens;
23
+ // it never narrows, because a route left out would have no minter, and its
24
+ // boundaries would fail to render.
25
+ //
26
+ // The same walk over the same graph also writes down the query names each
27
+ // view can call, and each deferred component -- see "The queries each view
28
+ // may call" below. They go in the same file, so the two halves PHP joins
29
+ // with the routes always come from one build.
30
+
31
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
32
+ import { basename, dirname, extname, relative, resolve, sep } from 'node:path';
33
+ import { getPluginApi } from '@vitejs/plugin-rsc';
34
+ import { parseAst } from 'vite';
35
+ import { QUERIES_MODULE, QUERY_MAP, readQueryMap, UnreadableQuery } from './query-map.js';
36
+ import {
37
+ MANIFEST_STYLES,
38
+ moduleOwnership,
39
+ partitionRules,
40
+ STYLES_MODULE,
41
+ styleXRenderer,
42
+ stylesModule,
43
+ } from './panel-styles.js';
44
+
45
+ // The build's other helper: one chunk per client component.
46
+ export { clientChunkName, clientComponentChunks } from './client-chunks.js';
47
+ // The profile the build was made for, recorded in it (ADR 0066).
48
+ export { BuildProfileRefused, buildProfile, PROFILE_FILE, profileOf } from './build-profile.js';
49
+ // Where the assets are loaded from, and each script's digest (ADR 0097).
50
+ export {
51
+ ASSETS_FILE,
52
+ ASSETS_MODULE,
53
+ AssetOriginRefused,
54
+ assetOrigin,
55
+ assetOriginOf,
56
+ hintsOf,
57
+ integrityAskedOf,
58
+ integrityOf,
59
+ MANIFEST_INTEGRITY,
60
+ withStylesheetIntegrity,
61
+ } from './asset-origin.js';
62
+ // A panel's styles, in a sheet of its own (ADR 0109).
63
+ export {
64
+ clientIdOf,
65
+ MANIFEST_STYLES,
66
+ moduleOwnership,
67
+ partitionRules,
68
+ STYLES_MODULE,
69
+ styleXRenderer,
70
+ stylesModule,
71
+ } from './panel-styles.js';
72
+ // And the check that no server component hands a client one a function.
73
+ export { clientExports, functionProps, serializableProps, UnserializableProp } from './client-props.js';
74
+ // What codegen writes about the queries, read here and by the lint.
75
+ export { QUERY_MAP, readQueryMap, readQueryAuthorization, UnreadableQuery } from './query-map.js';
76
+
77
+ /** Where the manifest goes, beside the rsc entry. */
78
+ export const DEFERRED_MANIFEST = 'deferred.json';
79
+
80
+ /** The specifier the app imports <Deferred> by. */
81
+ export const DEFERRED_SPECIFIER = '@tinvar/react/deferred';
82
+
83
+ /**
84
+ * The package root, which re-exports <Deferred> under `react-server` beside
85
+ * query(), nonce() and the rest -- so importing from it says nothing about
86
+ * boundaries until the import names Deferred.
87
+ */
88
+ export const ROOT_SPECIFIER = '@tinvar/react';
89
+
90
+ /** Any component: a boundary whose name the build could not read. */
91
+ export const ANY_COMPONENT = '*';
92
+
93
+ const SOURCE = /\.(?:[cm]?[jt]sx?)$/;
94
+
95
+ /**
96
+ * The plugin. Only the rsc environment has the server component graph; the
97
+ * client and ssr builds are left alone.
98
+ *
99
+ * @param {{ pages?: string, fileName?: string, queryMap?: string }} [options] `pages` is the
100
+ * directory whose modules are views, relative to the Vite root: a view
101
+ * id is a module's path under it without the extension, which is how
102
+ * `#[Page(view: 'shop/order')]` names `resources/pages/shop/order.tsx`.
103
+ * `queryMap` is where `tinvar codegen` wrote which query each
104
+ * `@queries/*` function calls, relative to the root.
105
+ */
106
+ export function deferredBoundaries({
107
+ pages = 'resources/pages',
108
+ fileName = DEFERRED_MANIFEST,
109
+ queryMap = QUERY_MAP,
110
+ } = {}) {
111
+ let root = process.cwd();
112
+
113
+ return {
114
+ name: 'tinvar:deferred-boundaries',
115
+ apply: 'build',
116
+ configResolved(config) {
117
+ root = config.root;
118
+ },
119
+ applyToEnvironment(environment) {
120
+ return environment.name === 'rsc';
121
+ },
122
+ async generateBundle() {
123
+ const pagesDir = resolve(root, pages);
124
+
125
+ /** @type {Map<string, string[]>} module id => the ids it imports */
126
+ const graph = new Map();
127
+ for (const id of this.getModuleIds()) {
128
+ const info = this.getModuleInfo(id);
129
+ // Dynamic imports too: a server component loaded lazily renders as
130
+ // surely as one imported at the top.
131
+ if (info) graph.set(id, [...info.importedIds, ...info.dynamicallyImportedIds]);
132
+ }
133
+
134
+ const resolveId = async (specifier, importer) => (await this.resolve(specifier, importer))?.id ?? null;
135
+ const users = await boundaryUsers(graph, pagesDir, resolveId);
136
+ // Throws, failing the build, for a call whose name cannot be read.
137
+ const callers = await queryUsers(graph, pagesDir, resolveId, readQueryMap(resolve(root, queryMap)));
138
+ const actions = await actionsOf(graph, callers.registry, pagesDir, resolveId);
139
+
140
+ this.emitFile({
141
+ type: 'asset',
142
+ fileName,
143
+ source:
144
+ JSON.stringify(
145
+ {
146
+ format: 1,
147
+ views: viewsOf(graph, users, pagesDir),
148
+ queries: queriesOfViews(graph, callers.calls, pagesDir),
149
+ componentQueries: queriesOfComponents(graph, callers.calls, callers.registry),
150
+ actions: actions.views,
151
+ componentActions: actions.components,
152
+ },
153
+ null,
154
+ 2,
155
+ ) + '\n',
156
+ });
157
+ },
158
+ };
159
+ }
160
+
161
+ /**
162
+ * The modules that render a <Deferred> boundary, with the components each
163
+ * names: every module importing the Deferred module, or the package root
164
+ * that re-exports it, read from its own source. One reading for the build
165
+ * and for the dev server (dev-server.js), so a route may mint in dev
166
+ * exactly when it could from a build of the same source.
167
+ *
168
+ * The root's own entry module is no user: it re-exports Deferred, which
169
+ * reads as unreadable, and every view importing nonce() from the root would
170
+ * then have minted for any component.
171
+ *
172
+ * @param {Map<string, string[]>} graph module id => imported ids
173
+ * @param {string} pagesDir absolute; what the two specifiers resolve from
174
+ * @param {(specifier: string, importer: string) => Promise<string | null>} resolveId
175
+ * as the rsc environment resolves, under `react-server`
176
+ * @returns {Promise<Map<string, string[]>>}
177
+ */
178
+ export async function boundaryUsers(graph, pagesDir, resolveId) {
179
+ const importer = resolve(pagesDir, 'index.tsx');
180
+ const deferredId = await resolveId(DEFERRED_SPECIFIER, importer);
181
+ const rootId = await resolveId(ROOT_SPECIFIER, importer);
182
+
183
+ /** @type {Map<string, string[]>} */
184
+ const users = new Map();
185
+ if (deferredId === null) return users;
186
+
187
+ for (const [id, imports] of graph) {
188
+ if (id === rootId) continue;
189
+ if (!imports.includes(deferredId) && (rootId === null || !imports.includes(rootId))) continue;
190
+ const file = fileOf(id);
191
+ // A module another plugin made has no source of its own to read, so
192
+ // what it renders cannot be named: any component, rather than a failed
193
+ // build or a boundary left out.
194
+ if (id.startsWith('\0') || !existsSync(file)) {
195
+ users.set(id, [ANY_COMPONENT]);
196
+ continue;
197
+ }
198
+ const source = readFileSync(file, 'utf8');
199
+ const resolved = new Map();
200
+ for (const specifier of importSources(source, file)) {
201
+ resolved.set(specifier, await resolveId(specifier, id));
202
+ }
203
+ users.set(
204
+ id,
205
+ deferredComponents(
206
+ source,
207
+ file,
208
+ (specifier) => resolved.get(specifier) === deferredId,
209
+ (specifier) => rootId !== null && resolved.get(specifier) === rootId,
210
+ ),
211
+ );
212
+ }
213
+ return users;
214
+ }
215
+
216
+ /**
217
+ * Every view under the pages directory, with the components reachable from
218
+ * it, sorted; a view with none is listed with an empty list, so a reader can
219
+ * tell "no boundaries" from "not in this build".
220
+ *
221
+ * @param {Map<string, string[]>} graph module id => imported ids
222
+ * @param {Map<string, string[]>} users module id => the components it names
223
+ * @param {string} pagesDir absolute
224
+ * @returns {Record<string, string[]>}
225
+ */
226
+ export function viewsOf(graph, users, pagesDir) {
227
+ /** @type {Record<string, string[]>} */
228
+ const views = {};
229
+
230
+ for (const id of [...graph.keys()].sort()) {
231
+ const file = fileOf(id);
232
+ const under = relative(pagesDir, file);
233
+ if (id.startsWith('\0') || under.startsWith('..') || under.startsWith(sep) || !SOURCE.test(file))
234
+ continue;
235
+
236
+ const found = new Set();
237
+ const seen = new Set([id]);
238
+ const queue = [id];
239
+ while (queue.length > 0) {
240
+ const current = /** @type {string} */ (queue.shift());
241
+ for (const component of users.get(current) ?? []) found.add(component);
242
+ for (const next of graph.get(current) ?? []) {
243
+ if (!seen.has(next)) {
244
+ seen.add(next);
245
+ queue.push(next);
246
+ }
247
+ }
248
+ }
249
+
250
+ views[under.slice(0, -extname(under).length).split(sep).join('/')] = found.has(ANY_COMPONENT)
251
+ ? [ANY_COMPONENT]
252
+ : [...found].sort();
253
+ }
254
+
255
+ return views;
256
+ }
257
+
258
+ /**
259
+ * The specifiers a module imports or re-exports from, so the caller can
260
+ * resolve each and say which name the Deferred module.
261
+ *
262
+ * @param {string} source
263
+ * @param {string} file
264
+ * @returns {string[]}
265
+ */
266
+ export function importSources(source, file) {
267
+ const sources = new Set();
268
+ walk(parse(source, file), (node) => {
269
+ if (
270
+ (node.type === 'ImportDeclaration' ||
271
+ node.type === 'ExportNamedDeclaration' ||
272
+ node.type === 'ExportAllDeclaration' ||
273
+ node.type === 'ImportExpression') &&
274
+ typeof node.source?.value === 'string'
275
+ ) {
276
+ sources.add(node.source.value);
277
+ }
278
+ });
279
+ return [...sources];
280
+ }
281
+
282
+ /**
283
+ * The components the <Deferred> boundaries in one module name.
284
+ *
285
+ * @param {string} source the module's own source, as written
286
+ * @param {string} file its path, which says how to parse it
287
+ * @param {(specifier: string) => boolean} isDeferred whether an import
288
+ * specifier in this module names the Deferred module
289
+ * @param {(specifier: string) => boolean} [isRoot] whether one names the
290
+ * package root, which exports Deferred among much else: an import
291
+ * from it counts only where it binds Deferred, or the whole module
292
+ * @returns {string[]} sorted, or ["*"] when one cannot be read
293
+ */
294
+ export function deferredComponents(source, file, isDeferred, isRoot = () => false) {
295
+ const program = parse(source, file);
296
+
297
+ // The names Deferred is bound to here: `import { Deferred }`, an alias, or
298
+ // a namespace whose `.Deferred` is the component.
299
+ const names = new Set();
300
+ const namespaces = new Set();
301
+ let unreadable = false;
302
+ // Whether the Deferred module itself is imported. Imported and used in no
303
+ // way this can follow is unreadable; the root imported for query() or
304
+ // nonce() alone is not a boundary at all.
305
+ let importsDeferred = false;
306
+
307
+ for (const node of program.body) {
308
+ const from = typeof node.source?.value === 'string' ? node.source.value : null;
309
+ const deferred = from !== null && isDeferred(from);
310
+ const root = from !== null && !deferred && isRoot(from);
311
+
312
+ if (node.type === 'ImportDeclaration' && (deferred || root)) {
313
+ if (deferred) importsDeferred = true;
314
+ for (const specifier of node.specifiers) {
315
+ if (specifier.type === 'ImportSpecifier' && importedName(specifier) === 'Deferred') {
316
+ names.add(specifier.local.name);
317
+ } else if (specifier.type === 'ImportNamespaceSpecifier') {
318
+ namespaces.add(specifier.local.name);
319
+ }
320
+ }
321
+ }
322
+ // Handed on to another module: whatever renders it there is not read
323
+ // here. From the root, only a re-export that can carry Deferred: all of
324
+ // it, a namespace of it, or Deferred by name.
325
+ if (
326
+ (node.type === 'ExportNamedDeclaration' || node.type === 'ExportAllDeclaration') &&
327
+ (deferred ||
328
+ (root &&
329
+ (node.type === 'ExportAllDeclaration' ||
330
+ node.specifiers.some(
331
+ (/** @type {any} */ specifier) =>
332
+ (specifier.local?.type === 'Identifier' ? specifier.local.name : specifier.local?.value) ===
333
+ 'Deferred',
334
+ ))))
335
+ ) {
336
+ unreadable = true;
337
+ }
338
+ }
339
+
340
+ const found = new Set();
341
+ walk(program, (node, parent) => {
342
+ if (node.type === 'ImportDeclaration') return false;
343
+
344
+ if (
345
+ node.type === 'ImportExpression' &&
346
+ typeof node.source?.value === 'string' &&
347
+ (isDeferred(node.source.value) || isRoot(node.source.value))
348
+ ) {
349
+ unreadable = true;
350
+ }
351
+
352
+ if (node.type === 'JSXOpeningElement' && isDeferredElement(node.name, names, namespaces)) {
353
+ const name = componentName(node.attributes);
354
+ if (name === null) unreadable = true;
355
+ else found.add(name);
356
+ return undefined;
357
+ }
358
+
359
+ // Deferred as a value -- createElement(Deferred), an object of
360
+ // components, a re-export -- renders a boundary this cannot name. A
361
+ // namespace read for another of its names -- the root's `tinvar.nonce()`
362
+ // -- is not one.
363
+ if (
364
+ node.type === 'Identifier' &&
365
+ (names.has(node.name) || namespaces.has(node.name)) &&
366
+ !isPropertyKey(node, parent) &&
367
+ !(namespaces.has(node.name) && readsOtherName(node, parent))
368
+ ) {
369
+ unreadable = true;
370
+ }
371
+ return undefined;
372
+ });
373
+
374
+ // Imported, and used in no way this could follow.
375
+ if (importsDeferred && names.size === 0 && namespaces.size === 0 && found.size === 0) unreadable = true;
376
+
377
+ return unreadable ? [ANY_COMPONENT] : [...found].sort();
378
+ }
379
+
380
+ /** @param {{ imported: { type: string, name?: string, value?: string } }} specifier */
381
+ function importedName(specifier) {
382
+ return specifier.imported.type === 'Identifier' ? specifier.imported.name : specifier.imported.value;
383
+ }
384
+
385
+ function isDeferredElement(name, names, namespaces) {
386
+ if (name.type === 'JSXIdentifier') return names.has(name.name);
387
+ return (
388
+ name.type === 'JSXMemberExpression' &&
389
+ name.object.type === 'JSXIdentifier' &&
390
+ namespaces.has(name.object.name) &&
391
+ name.property.name === 'Deferred'
392
+ );
393
+ }
394
+
395
+ /**
396
+ * The literal a boundary's `component` attribute names, or null when it is
397
+ * not one: an expression, a template with holes, a spread that might set it.
398
+ */
399
+ function componentName(attributes) {
400
+ let name = null;
401
+ for (const attribute of attributes) {
402
+ if (attribute.type === 'JSXSpreadAttribute') return null;
403
+ if (attribute.name?.type !== 'JSXIdentifier' || attribute.name.name !== 'component') continue;
404
+
405
+ const value = attribute.value;
406
+ if (value?.type === 'Literal' && typeof value.value === 'string') {
407
+ name = value.value;
408
+ } else if (value?.type === 'JSXExpressionContainer') {
409
+ const expression = value.expression;
410
+ if (expression.type === 'Literal' && typeof expression.value === 'string') name = expression.value;
411
+ else if (expression.type === 'TemplateLiteral' && expression.expressions.length === 0) {
412
+ name = expression.quasis[0]?.value.cooked ?? null;
413
+ } else return null;
414
+ } else return null;
415
+ }
416
+ return name;
417
+ }
418
+
419
+ /** `ns.nonce`: a namespace read for a name, written out, that is not Deferred. */
420
+ function readsOtherName(node, parent) {
421
+ return (
422
+ parent?.type === 'MemberExpression' &&
423
+ parent.object === node &&
424
+ !parent.computed &&
425
+ parent.property.type === 'Identifier' &&
426
+ parent.property.name !== 'Deferred'
427
+ );
428
+ }
429
+
430
+ /** `{ Deferred: x }` and `obj.Deferred` name a key, not the binding. */
431
+ function isPropertyKey(node, parent) {
432
+ return (
433
+ (parent?.type === 'MemberExpression' && parent.property === node && !parent.computed) ||
434
+ (parent?.type === 'Property' && parent.key === node && !parent.computed && !parent.shorthand)
435
+ );
436
+ }
437
+
438
+ function parse(source, file) {
439
+ const extension = extname(file);
440
+ const lang = extension === '.tsx' ? 'tsx' : /^\.[cm]?ts$/.test(extension) ? 'ts' : 'jsx';
441
+ return parseAst(source, { lang }, file);
442
+ }
443
+
444
+ /** A module id as the file it was read from: no query, no hash. */
445
+ function fileOf(id) {
446
+ return id.replace(/[?#].*$/, '');
447
+ }
448
+
449
+ /** Whether a module's file is a dependency's: anywhere under a node_modules directory. */
450
+ function isDependency(file) {
451
+ return file.split(/[\\/]/).includes('node_modules');
452
+ }
453
+
454
+ /**
455
+ * Depth-first over every node; the visitor returns false to skip a subtree.
456
+ *
457
+ * @param {any} node
458
+ * @param {(node: any, parent: any) => boolean | undefined} visit
459
+ * @param {any} [parent]
460
+ */
461
+ function walk(node, visit, parent = null) {
462
+ if (node === null || typeof node !== 'object') return;
463
+ if (Array.isArray(node)) {
464
+ for (const child of node) walk(child, visit, parent);
465
+ return;
466
+ }
467
+ if (typeof node.type !== 'string') return;
468
+ if (visit(node, parent) === false) return;
469
+ for (const key of Object.keys(node)) {
470
+ if (key === 'type' || key === 'start' || key === 'end' || key === 'range' || key === 'loc') continue;
471
+ const child = node[key];
472
+ if (child !== null && typeof child === 'object') walk(child, visit, node);
473
+ }
474
+ }
475
+
476
+ // ---------------------------------------------------------------------------
477
+ // A panel's client code, in chunks of its own.
478
+ //
479
+ // tinvar-spec.md, "Panels and scope": a panel narrows what a request sees,
480
+ // and the admin panel is staff's. Its pages' server components never leave
481
+ // the server, but the 'use client' components they reach are shipped to the
482
+ // browser -- and @vitejs/plugin-rsc groups every client reference a server
483
+ // chunk reaches into one client chunk, so a customer's page loaded the code
484
+ // of the admin panel's components with its own. Nothing in that code is a
485
+ // secret, and every request it makes is held to the panel's guard; but it
486
+ // maps out what staff can do, it grows every customer's download, and a
487
+ // panel is meant to be a boundary.
488
+ //
489
+ // panelChunks() puts each client reference that only one panel's views reach
490
+ // into a chunk named for that panel, through the RSC plugin's clientChunks
491
+ // option. A reference any other view reaches -- the kit's buttons, the
492
+ // router -- stays shared, which is right: the customer pages ship it anyway.
493
+ // Then it checks what the bundler did with that: no chunk a page outside the
494
+ // panel loads -- the entry and what it imports, the other groups and what
495
+ // they import -- may hold a module only the panel's references reach. A
496
+ // build that breaks that fails. What it found goes in `panels.json` beside
497
+ // the rsc entry, which `tinvar build:fragments` holds to the routes' own
498
+ // panels (PanelBundles), so the views named here and the panels PHP puts
499
+ // the routes in cannot drift apart.
500
+
501
+ /** Where the panel report goes, beside the rsc entry. */
502
+ export const PANELS_MANIFEST = 'panels.json';
503
+
504
+ /** A client-reference group's virtual module, as @vitejs/plugin-rsc names it. */
505
+ const GROUP_PREFIX = '\0virtual:vite-rsc/client-references/group/';
506
+
507
+ /**
508
+ * @param {{ panels?: Record<string, string>, pages?: string, fileName?: string, styles?: any }} [options]
509
+ * `panels` maps each panel to the directory under `pages` its views are
510
+ * in -- `{ admin: 'admin' }` puts `admin/orders` in the admin panel.
511
+ * `styles`, the app's StyleX plugin, splits its rules into a sheet for
512
+ * the pages outside every panel and one for each panel's
513
+ * (panel-styles.js), which the document links through
514
+ * `virtual:tinvar/styles`
515
+ * @returns {import('vite').Plugin & { clientChunks: (meta: { id: string }) => string | undefined }}
516
+ * the plugin, whose `clientChunks` is handed to the RSC plugin
517
+ */
518
+ export function panelChunks({
519
+ panels = {},
520
+ pages = 'resources/pages',
521
+ fileName = PANELS_MANIFEST,
522
+ styles,
523
+ } = {}) {
524
+ for (const [panel, directory] of Object.entries(panels)) {
525
+ if (
526
+ !/^[a-z][a-z0-9-]*$/.test(panel) ||
527
+ typeof directory !== 'string' ||
528
+ directory === '' ||
529
+ directory.startsWith('/')
530
+ ) {
531
+ throw new Error(
532
+ `[tinvar] panelChunks(): '${panel}' needs a lowercase name and a directory under the pages.`,
533
+ );
534
+ }
535
+ }
536
+
537
+ let root = process.cwd();
538
+ /** @type {import('vite').ResolvedConfig | null} */
539
+ let resolved = null;
540
+ /** @type {Map<string, string>} a client reference's import id => its panel */
541
+ const owned = new Map();
542
+ /** @type {Record<string, string[]>} panel => its views, and its references by file */
543
+ const report = { views: {}, references: {} };
544
+
545
+ /** The StyleX side, when the app hands its plugin over: what the rsc build saw, and the sheets made. */
546
+ const split = {
547
+ /** @type {{ graph: Map<string, string[]>, references: Map<string, string>, entries: string[], views: Map<string, string> } | null} */
548
+ rsc: null,
549
+ /** @type {{ fileName: string, base: string } | null} the stylesheet StyleX appended to, and what it held before */
550
+ stylesheet: null,
551
+ /** @type {{ from: string, fileName: string, source: string } | null} that stylesheet with the shared rules alone, under a name of its own */
552
+ linked: null,
553
+ /** @type {{ panels: Record<string, string>, sources: Record<string, string>, owned: Record<string, number>, shared: string } | null} */
554
+ sheets: null,
555
+ };
556
+ const panelOfView = (/** @type {string} */ view) => {
557
+ for (const [panel, directory] of Object.entries(panels)) {
558
+ const prefix = directory.replace(/\/+$/, '');
559
+ if (view === prefix || view.startsWith(`${prefix}/`)) return panel;
560
+ }
561
+ return null;
562
+ };
563
+
564
+ return {
565
+ name: 'tinvar:panel-chunks',
566
+ configResolved(config) {
567
+ root = config.root;
568
+ resolved = config;
569
+ },
570
+ applyToEnvironment(environment) {
571
+ return environment.name === 'rsc' || environment.name === 'client';
572
+ },
573
+ clientChunks(meta) {
574
+ const panel = owned.get(meta.id);
575
+ return panel === undefined ? undefined : `panel-${panel}`;
576
+ },
577
+ resolveId(id) {
578
+ return id === STYLES_MODULE ? `\0${STYLES_MODULE}` : undefined;
579
+ },
580
+ load(id) {
581
+ if (id !== `\0${STYLES_MODULE}`) return undefined;
582
+ return stylesModule(
583
+ styles === undefined ? {} : panels,
584
+ styles !== undefined && resolved?.command === 'build',
585
+ );
586
+ },
587
+ generateBundle(_options, bundle) {
588
+ if (resolved?.command !== 'build') return;
589
+ if (this.environment.name === 'rsc') {
590
+ const manager = resolved === null ? undefined : getPluginApi(resolved)?.manager;
591
+ if (manager === undefined) {
592
+ throw new Error('[tinvar] panelChunks() needs @vitejs/plugin-rsc in the same config.');
593
+ }
594
+ /** @type {Map<string, string[]>} */
595
+ const graph = new Map();
596
+ for (const id of this.getModuleIds()) {
597
+ const info = this.getModuleInfo(id);
598
+ if (info) graph.set(id, [...info.importedIds, ...info.dynamicallyImportedIds]);
599
+ }
600
+ const references = new Map(
601
+ Object.entries(manager.clientReferenceMetaMap).map(([id, meta]) => [id, meta.importId]),
602
+ );
603
+ const entries = [...graph.keys()].filter((id) => this.getModuleInfo(id)?.isEntry === true);
604
+ if (styles !== undefined && !manager.isScanBuild) {
605
+ split.rsc = { graph, references, entries, views: viewIds(graph, resolve(root, pages)) };
606
+ // StyleX appended every rule to the stylesheet the document
607
+ // links. Once the client build has the rest of the graph, it
608
+ // holds the app's own CSS and the shared rules alone.
609
+ split.stylesheet = appendedTo(bundle, styles.__stylexCollectCss?.() ?? '');
610
+ }
611
+ const reached = referencesByView(graph, references, resolve(root, pages));
612
+ const outside = referencesOutsideViews(graph, references, resolve(root, pages), entries);
613
+ const ownership = panelOwnership(reached, panels, outside);
614
+ owned.clear();
615
+ for (const [reference, panel] of ownership.references) owned.set(reference, panel);
616
+ report.views = ownership.views;
617
+ report.references = Object.fromEntries(
618
+ Object.keys(panels).map((panel) => [
619
+ panel,
620
+ [...ownership.references]
621
+ .filter(([, owner]) => owner === panel)
622
+ .map(([reference]) => relative(root, fileOf(reference)).split(sep).join('/'))
623
+ .sort(),
624
+ ]),
625
+ );
626
+ return;
627
+ }
628
+
629
+ if (this.environment.name !== 'client') return;
630
+ if (styles !== undefined && split.rsc !== null) {
631
+ split.sheets = emitSheets(this, split.rsc, styles, panels, panelOfView, root);
632
+ // The stylesheet every page links, again, with the shared rules
633
+ // alone and a name of its own: the RSC plugin still copies StyleX's,
634
+ // every rule under the name that has always meant them, and nothing
635
+ // links it once the manifest names this one instead (writeBundle).
636
+ if (split.stylesheet !== null) {
637
+ const source = withShared(split.stylesheet.base, split.sheets.shared);
638
+ const name = basename(split.stylesheet.fileName).replace(/-[\w-]{8}\.css$/, '.css');
639
+ const fileName = this.getFileName(this.emitFile({ type: 'asset', name, source }));
640
+ split.linked = { from: split.stylesheet.fileName, fileName, source };
641
+ }
642
+ }
643
+
644
+ // The client build: what the bundler made of the groups.
645
+ const chunks = Object.values(bundle)
646
+ .filter((output) => output.type === 'chunk')
647
+ .map((chunk) => ({
648
+ fileName: chunk.fileName,
649
+ isEntry: chunk.isEntry,
650
+ facadeModuleId: chunk.facadeModuleId,
651
+ moduleIds: chunk.moduleIds,
652
+ imports: chunk.imports,
653
+ }));
654
+ /** @type {Map<string, string[]>} */
655
+ const graph = new Map();
656
+ for (const id of this.getModuleIds()) {
657
+ const info = this.getModuleInfo(id);
658
+ if (info) graph.set(id, [...info.importedIds, ...info.dynamicallyImportedIds]);
659
+ }
660
+ const found = checkPanelChunks(chunks, graph, Object.keys(panels));
661
+ if (found.violations.length > 0) {
662
+ throw new Error(
663
+ `[tinvar] A panel's client code is loaded outside it:\n ${found.violations.join('\n ')}`,
664
+ );
665
+ }
666
+
667
+ const rscOut = resolve(root, resolved?.environments?.rsc?.build?.outDir ?? 'dist/rsc');
668
+ const target = resolve(rscOut, fileName);
669
+ mkdirSync(dirname(target), { recursive: true });
670
+ writeFileSync(
671
+ target,
672
+ JSON.stringify(
673
+ {
674
+ format: 1,
675
+ panels: Object.fromEntries(
676
+ Object.keys(panels).map((panel) => [
677
+ panel,
678
+ {
679
+ views: report.views[panel] ?? [],
680
+ references: report.references[panel] ?? [],
681
+ chunks: found.chunks[panel] ?? [],
682
+ ...(split.sheets === null
683
+ ? {}
684
+ : {
685
+ stylesheet: split.sheets.panels[panel] ?? null,
686
+ ownRules: split.sheets.owned[panel] ?? 0,
687
+ }),
688
+ },
689
+ ]),
690
+ ),
691
+ shared: found.shared,
692
+ ...(split.linked === null ? {} : { stylesheet: split.linked.fileName }),
693
+ },
694
+ null,
695
+ 2,
696
+ ) + '\n',
697
+ );
698
+ },
699
+ writeBundle(options) {
700
+ if (resolved?.command !== 'build' || this.environment.name !== 'client' || split.sheets === null)
701
+ return;
702
+ const outDir = options.dir ?? this.environment.config.build.outDir;
703
+ // As written: nothing after this may have added to them -- StyleX
704
+ // appends what it compiled to a stylesheet it finds unless it already
705
+ // has, and a sheet it appended to is the whole of its rules again.
706
+ const sources = { ...split.sheets.sources };
707
+ if (split.linked !== null) sources[split.linked.fileName] = split.linked.source;
708
+ for (const [fileName, source] of Object.entries(sources)) {
709
+ if (readFileSync(resolve(outDir, fileName), 'utf8') !== source) {
710
+ throw new Error(
711
+ `[tinvar] panelChunks(): ${fileName} changed after it was written; its rules are not the panel's.`,
712
+ );
713
+ }
714
+ }
715
+ const manager = resolved === null ? undefined : getPluginApi(resolved)?.manager;
716
+ const manifest = manager?.buildAssetsManifest;
717
+ if (manifest === undefined) {
718
+ throw new Error(
719
+ '[tinvar] panelChunks(): the RSC plugin wrote no assets manifest to name the stylesheets in.',
720
+ );
721
+ }
722
+ manifest[MANIFEST_STYLES] = { panels: split.sheets.panels };
723
+ if (split.linked !== null) {
724
+ const base = this.environment.config.base ?? '/';
725
+ relink(manifest, `${base}${split.linked.from}`, `${base}${split.linked.fileName}`);
726
+ }
727
+ },
728
+ };
729
+ }
730
+
731
+ /**
732
+ * Each view's module, by its name: its path under the pages, without the
733
+ * extension.
734
+ *
735
+ * @param {Map<string, string[]>} graph
736
+ * @param {string} pagesDir absolute
737
+ * @returns {Map<string, string>}
738
+ */
739
+ function viewIds(graph, pagesDir) {
740
+ /** @type {Map<string, string>} */
741
+ const views = new Map();
742
+ for (const id of [...graph.keys()].sort()) {
743
+ const file = fileOf(id);
744
+ const under = relative(pagesDir, file);
745
+ if (id.startsWith('\0') || under.startsWith('..') || under.startsWith(sep) || !SOURCE.test(file))
746
+ continue;
747
+ views.set(under.slice(0, -extname(under).length).split(sep).join('/'), id);
748
+ }
749
+ return views;
750
+ }
751
+
752
+ /**
753
+ * The rsc build's stylesheet StyleX appended its rules to, and what it held
754
+ * before them: the app's own CSS. A build where StyleX compiled rules and
755
+ * no stylesheet ends with them fails: the rules could not be split.
756
+ *
757
+ * @param {Record<string, any>} bundle
758
+ * @param {string} appended what StyleX's plugin collected
759
+ * @returns {{ fileName: string, base: string } | null}
760
+ */
761
+ function appendedTo(bundle, appended) {
762
+ if (appended === '') return null;
763
+ const asset = Object.values(bundle).find(
764
+ (output) =>
765
+ output.type === 'asset' &&
766
+ output.fileName.endsWith('.css') &&
767
+ String(output.source).endsWith(`\n${appended}`),
768
+ );
769
+ if (asset === undefined) {
770
+ throw new Error(
771
+ "[tinvar] panelChunks(): no stylesheet ends with what StyleX compiled, so a panel's rules cannot be taken out of it.",
772
+ );
773
+ }
774
+ return { fileName: asset.fileName, base: String(asset.source).slice(0, -(appended.length + 1)) };
775
+ }
776
+
777
+ /**
778
+ * The stylesheet every page links, with the shared rules in place of every
779
+ * rule.
780
+ *
781
+ * @param {string} base the app's own CSS
782
+ * @param {string} shared the shared rules, rendered
783
+ */
784
+ function withShared(base, shared) {
785
+ // Without Vite's marker of a stylesheet it made, which it takes out of
786
+ // every stylesheet as it writes it: what is written is what is checked.
787
+ const css = base.replaceAll('/*$vite$:1*/', '');
788
+ return shared === '' ? css : `${css}\n${shared}`;
789
+ }
790
+
791
+ /**
792
+ * Every stylesheet list in the RSC plugin's assets manifest with $to in
793
+ * $from's place: what loadCss() links, and what a client component's chunk
794
+ * preinits.
795
+ *
796
+ * @param {Record<string, any>} manifest
797
+ * @param {string} from
798
+ * @param {string} to
799
+ */
800
+ function relink(manifest, from, to) {
801
+ const lists = [
802
+ ...Object.values(manifest.serverResources ?? {}),
803
+ ...Object.values(manifest.clientReferenceDeps ?? {}),
804
+ ...(manifest.clientEntryDeps === undefined ? [] : [manifest.clientEntryDeps]),
805
+ ];
806
+ for (const deps of lists) {
807
+ if (Array.isArray(deps?.css))
808
+ deps.css = deps.css.map((/** @type {unknown} */ url) => (url === from ? to : url));
809
+ }
810
+ }
811
+
812
+ /**
813
+ * Emits a StyleX sheet into the client build for each panel with rules of
814
+ * its own -- the shared rules and the panel's, whole -- and renders the
815
+ * shared rules for the stylesheet every page links.
816
+ *
817
+ * @param {any} context the client build's plugin context
818
+ * @param {{ graph: Map<string, string[]>, references: Map<string, string>, entries: string[], views: Map<string, string> }} rsc
819
+ * @param {any} styles the StyleX plugin
820
+ * @param {Record<string, string>} panels
821
+ * @param {(view: string) => string | null} panelOf
822
+ * @param {string} root
823
+ * @returns {{ shared: string, panels: Record<string, string>, sources: Record<string, string>, owned: Record<string, number> }}
824
+ * the shared rules as CSS, and each panel's sheet by URL
825
+ */
826
+ function emitSheets(context, rsc, styles, panels, panelOf, root) {
827
+ /** @type {Map<string, string[]>} */
828
+ const client = new Map();
829
+ const clientEntries = [];
830
+ for (const id of context.getModuleIds()) {
831
+ const info = context.getModuleInfo(id);
832
+ if (!info) continue;
833
+ client.set(id, [...info.importedIds, ...info.dynamicallyImportedIds]);
834
+ if (info.isEntry) clientEntries.push(id);
835
+ }
836
+
837
+ const store = styles.__stylexGetSharedStore?.();
838
+ if (!(store?.rulesById instanceof Map)) {
839
+ throw new Error(
840
+ "[tinvar] panelChunks(): `styles` is not StyleX's plugin, or not one this reads the rules of.",
841
+ );
842
+ }
843
+
844
+ const ownership = moduleOwnership({
845
+ rsc: rsc.graph,
846
+ client,
847
+ references: rsc.references,
848
+ views: rsc.views,
849
+ rscEntries: rsc.entries,
850
+ clientEntries,
851
+ panelOf,
852
+ });
853
+ const names = Object.keys(panels);
854
+ const parts = partitionRules(store.rulesById, ownership, names);
855
+ const render = styleXRenderer(root);
856
+ const base = context.environment.config.base ?? '/';
857
+
858
+ /** @type {Record<string, string>} */
859
+ const sources = {};
860
+ const emit = (/** @type {string} */ name, /** @type {string} */ source) => {
861
+ const fileName = context.getFileName(context.emitFile({ type: 'asset', name, source }));
862
+ sources[fileName] = source;
863
+ return `${base}${fileName}`;
864
+ };
865
+
866
+ const shared = render(parts.shared);
867
+ /** @type {Record<string, string>} */
868
+ const sheets = {};
869
+ for (const panel of names) {
870
+ if ((parts.owned[panel] ?? 0) > 0)
871
+ sheets[panel] = emit(`styles-${panel}.css`, render(parts.panels[panel] ?? []));
872
+ }
873
+
874
+ return { shared, panels: sheets, sources, owned: parts.owned };
875
+ }
876
+
877
+ /**
878
+ * Every view under the pages directory, with the client references its server
879
+ * graph reaches -- a reference is a leaf there: in the rsc environment a 'use
880
+ * client' module is a proxy, its own imports gone.
881
+ *
882
+ * @param {Map<string, string[]>} graph module id => imported ids (rsc)
883
+ * @param {Map<string, string>} references client reference module id => its import id
884
+ * @param {string} pagesDir absolute
885
+ * @returns {Map<string, Set<string>>} view => import ids
886
+ */
887
+ export function referencesByView(graph, references, pagesDir) {
888
+ /** @type {Map<string, Set<string>>} */
889
+ const views = new Map();
890
+
891
+ for (const id of [...graph.keys()].sort()) {
892
+ const file = fileOf(id);
893
+ const under = relative(pagesDir, file);
894
+ if (id.startsWith('\0') || under.startsWith('..') || under.startsWith(sep) || !SOURCE.test(file))
895
+ continue;
896
+
897
+ const found = new Set();
898
+ const seen = new Set([id]);
899
+ const queue = [id];
900
+ while (queue.length > 0) {
901
+ const current = /** @type {string} */ (queue.shift());
902
+ const reference = references.get(current);
903
+ if (reference !== undefined) {
904
+ found.add(reference);
905
+ continue;
906
+ }
907
+ for (const next of graph.get(current) ?? []) {
908
+ if (!seen.has(next)) {
909
+ seen.add(next);
910
+ queue.push(next);
911
+ }
912
+ }
913
+ }
914
+
915
+ views.set(under.slice(0, -extname(under).length).split(sep).join('/'), found);
916
+ }
917
+
918
+ return views;
919
+ }
920
+
921
+ /**
922
+ * The client references the entries reach without going through a view: what
923
+ * the entry renders around every page -- a document shell, a root layout
924
+ * kept outside the pages, a deferred component's registry -- and so what any
925
+ * page may render, whichever views also reach it.
926
+ *
927
+ * Left out, such a reference reached by one panel's views alone was taken to
928
+ * be that panel's, and went in its chunk: every customer's page rendering it
929
+ * then loaded the panel's chunk, and every panel component with it -- the
930
+ * check on the client bundle cannot see it, since the chunk is loaded on
931
+ * demand through the reference map, as any group is.
932
+ *
933
+ * @param {Map<string, string[]>} graph module id => imported ids (rsc)
934
+ * @param {Map<string, string>} references client reference module id => its import id
935
+ * @param {string} pagesDir absolute
936
+ * @param {string[]} entries the rsc build's entry module ids
937
+ * @returns {Set<string>} import ids
938
+ */
939
+ export function referencesOutsideViews(graph, references, pagesDir, entries) {
940
+ const isView = (/** @type {string} */ id) => {
941
+ const file = fileOf(id);
942
+ const under = relative(pagesDir, file);
943
+ return !id.startsWith('\0') && !under.startsWith('..') && !under.startsWith(sep) && SOURCE.test(file);
944
+ };
945
+
946
+ const found = new Set();
947
+ const seen = new Set(entries);
948
+ const queue = [...entries];
949
+ while (queue.length > 0) {
950
+ const current = /** @type {string} */ (queue.shift());
951
+ const reference = references.get(current);
952
+ if (reference !== undefined) {
953
+ found.add(reference);
954
+ continue;
955
+ }
956
+ // A view's references are counted with its panel, by referencesByView().
957
+ if (isView(current)) continue;
958
+ for (const next of graph.get(current) ?? []) {
959
+ if (!seen.has(next)) {
960
+ seen.add(next);
961
+ queue.push(next);
962
+ }
963
+ }
964
+ }
965
+
966
+ return found;
967
+ }
968
+
969
+ /**
970
+ * Which panel each view is in, by its directory, and which client references
971
+ * belong to one panel: every view that reaches them is in it, and nothing
972
+ * outside the views does. A reference no view reaches -- one an entry imports
973
+ * itself -- is no panel's, and nor is one an entry reaches outside any view
974
+ * (referencesOutsideViews()), whatever views reach it besides.
975
+ *
976
+ * @param {Map<string, Set<string>>} reached view => import ids
977
+ * @param {Record<string, string>} panels panel => directory under the pages
978
+ * @param {Set<string>} [outside] import ids reached outside any view
979
+ * @returns {{ views: Record<string, string[]>, references: Map<string, string> }}
980
+ */
981
+ export function panelOwnership(reached, panels, outside = new Set()) {
982
+ const panelOf = (/** @type {string} */ view) => {
983
+ for (const [panel, directory] of Object.entries(panels)) {
984
+ const prefix = directory.replace(/\/+$/, '');
985
+ if (view === prefix || view.startsWith(`${prefix}/`)) return panel;
986
+ }
987
+ return null;
988
+ };
989
+
990
+ /** @type {Record<string, string[]>} */
991
+ const views = Object.fromEntries(Object.keys(panels).map((panel) => [panel, []]));
992
+ /** @type {Map<string, Set<string | null>>} reference => the panels of the views reaching it */
993
+ const reachers = new Map();
994
+ for (const [view, references] of reached) {
995
+ const panel = panelOf(view);
996
+ if (panel !== null) views[panel]?.push(view);
997
+ for (const reference of references) {
998
+ if (!reachers.has(reference)) reachers.set(reference, new Set());
999
+ reachers.get(reference)?.add(panel);
1000
+ }
1001
+ }
1002
+
1003
+ /** @type {Map<string, string>} */
1004
+ const references = new Map();
1005
+ for (const [reference, owners] of reachers) {
1006
+ const [only] = owners;
1007
+ if (owners.size === 1 && typeof only === 'string' && !outside.has(reference))
1008
+ references.set(reference, only);
1009
+ }
1010
+ for (const list of Object.values(views)) list.sort();
1011
+
1012
+ return { views, references };
1013
+ }
1014
+
1015
+ /**
1016
+ * Holds the client bundle to the panels: no chunk loaded outside a panel may
1017
+ * hold a module only that panel's group reaches.
1018
+ *
1019
+ * Outside panel P is the entry chunk and every chunk it imports, and every
1020
+ * other group's chunk -- shared, or another panel's -- and what those import.
1021
+ * P's own modules are those its group's virtual module reaches in the client
1022
+ * graph that neither the entry module nor any other group reaches: read from
1023
+ * the graph, never from what the bundler put where, which is what is checked.
1024
+ *
1025
+ * @param {Array<{ fileName: string, isEntry: boolean, facadeModuleId: string | null, moduleIds: string[], imports: string[] }>} chunks
1026
+ * @param {Map<string, string[]>} graph module id => imported ids (client)
1027
+ * @param {string[]} panels
1028
+ * @returns {{ violations: string[], chunks: Record<string, string[]>, shared: string[] }}
1029
+ */
1030
+ export function checkPanelChunks(chunks, graph, panels) {
1031
+ const byFile = new Map(chunks.map((chunk) => [chunk.fileName, chunk]));
1032
+ const closure = (/** @type {string[]} */ files) => {
1033
+ const seen = new Set();
1034
+ const queue = [...files];
1035
+ while (queue.length > 0) {
1036
+ const file = /** @type {string} */ (queue.shift());
1037
+ if (seen.has(file) || !byFile.has(file)) continue;
1038
+ seen.add(file);
1039
+ queue.push(...(byFile.get(file)?.imports ?? []));
1040
+ }
1041
+ return seen;
1042
+ };
1043
+ // Never into a group it did not start from: the client-reference map the
1044
+ // entry imports loads every group on demand, which is not loading them.
1045
+ const reach = (/** @type {string[]} */ ids) => {
1046
+ const seen = new Set(ids);
1047
+ const queue = [...ids];
1048
+ while (queue.length > 0) {
1049
+ const id = /** @type {string} */ (queue.shift());
1050
+ for (const next of graph.get(id) ?? []) {
1051
+ if (!seen.has(next) && !next.startsWith(GROUP_PREFIX)) {
1052
+ seen.add(next);
1053
+ queue.push(next);
1054
+ }
1055
+ }
1056
+ }
1057
+ return seen;
1058
+ };
1059
+
1060
+ // The groups, from the graph: a group the bundler inlined into another
1061
+ // chunk has no chunk of its own, and is checked all the same.
1062
+ const groupIds = [...graph.keys()].filter((id) => id.startsWith(GROUP_PREFIX));
1063
+ const chunkOf = (/** @type {string} */ id) => chunks.find((chunk) => chunk.moduleIds.includes(id));
1064
+ const entries = chunks.filter((chunk) => chunk.isEntry);
1065
+ const entryFiles = entries.map((chunk) => chunk.fileName);
1066
+ const entryModules = entries.flatMap((chunk) =>
1067
+ chunk.facadeModuleId === null ? [] : [chunk.facadeModuleId],
1068
+ );
1069
+
1070
+ const violations = [];
1071
+ /** @type {Record<string, string[]>} */
1072
+ const owned = {};
1073
+
1074
+ for (const panel of panels) {
1075
+ const groupId = `${GROUP_PREFIX}panel-${panel}`;
1076
+ if (!graph.has(groupId)) {
1077
+ owned[panel] = [];
1078
+ continue;
1079
+ }
1080
+ const others = groupIds.filter((id) => id !== groupId);
1081
+ const otherFiles = others.flatMap((id) => {
1082
+ const found = chunkOf(id);
1083
+ return found === undefined ? [] : [found.fileName];
1084
+ });
1085
+ const outsideFiles = closure([...entryFiles, ...otherFiles]);
1086
+ const outsideModules = reach([...entryModules, ...others]);
1087
+ const own = new Set([...reach([groupId])].filter((id) => !outsideModules.has(id)));
1088
+
1089
+ for (const file of [...outsideFiles].sort()) {
1090
+ for (const id of byFile.get(file)?.moduleIds ?? []) {
1091
+ if (own.has(id) && !id.startsWith('\0')) {
1092
+ violations.push(`${fileOf(id)} (the ${panel} panel's) is in ${file}, which pages outside it load`);
1093
+ }
1094
+ }
1095
+ }
1096
+ const home = chunkOf(groupId);
1097
+ owned[panel] =
1098
+ home === undefined
1099
+ ? []
1100
+ : [...closure([home.fileName])].filter((file) => !outsideFiles.has(file)).sort();
1101
+ }
1102
+
1103
+ const outsideAll = closure([
1104
+ ...entryFiles,
1105
+ ...groupIds
1106
+ .filter((id) => !id.slice(GROUP_PREFIX.length).startsWith('panel-'))
1107
+ .flatMap((id) => {
1108
+ const found = chunkOf(id);
1109
+ return found === undefined ? [] : [found.fileName];
1110
+ }),
1111
+ ]);
1112
+
1113
+ return { violations, chunks: owned, shared: [...outsideAll].sort() };
1114
+ }
1115
+
1116
+ // ---------------------------------------------------------------------------
1117
+ // The queries each view may call.
1118
+ //
1119
+ // tinvar-spec.md, "Renderer hardening": "Per-route query allowlist. At build,
1120
+ // the server-component graph of each route gives the list of query names it
1121
+ // may call. PHP rejects any other name on that route's socket, so a
1122
+ // compromised dependency can't call queries the page never uses." A list kept
1123
+ // by hand drifts as the deferred routes did (ADR 0024): one that misses a
1124
+ // query fails its page, which is noticed; one that keeps a query the page has
1125
+ // stopped calling lets every render of the route call it, which is not.
1126
+ //
1127
+ // So deferredBoundaries() finds the calls too, over the same graph. A server
1128
+ // component calls a query through query() from @tinvar/react/server -- or the
1129
+ // root, which re-exports it under react-server -- or through a function
1130
+ // codegen typed in @queries/*, whose query `tinvar codegen` writes beside the
1131
+ // types (QUERY_MAP). Every module importing one of those is read with the
1132
+ // bundler's parser, and each call named by the literal it passes; a view may
1133
+ // call what every module reachable from it calls. A 'use client' module is a
1134
+ // client reference in this graph, its imports gone: a client component cannot
1135
+ // call query(), and whatever its source says is not counted.
1136
+ //
1137
+ // A deferred component is not reachable from its page: the page names it by a
1138
+ // string, and a fragment renders it from the rsc entry's registry, under the
1139
+ // allowlist of the route that minted its token. So the registry is read as
1140
+ // well, where the entry declares it with deferredRegistry(): each name, and
1141
+ // the module its component is imported from. The queries reachable from that
1142
+ // module are written beside the views', and PHP adds them to every route that
1143
+ // may mint for the component.
1144
+ //
1145
+ // Only the application's own modules are read: a dependency's are not, even
1146
+ // where the rsc build bundles them -- plugin-rsc bundles every package with a
1147
+ // React peer. The allowlist is there to hold a compromised dependency to
1148
+ // what the page calls; a dependency whose calls were read as the page's would
1149
+ // put on the list whatever names it wrote out, and be allowed them. So a
1150
+ // module under node_modules lists nothing and registers nothing, whatever it
1151
+ // imports. A query a dependency calls is refused at runtime unless the
1152
+ // application's own code calls it too, which is the side to fail on.
1153
+ //
1154
+ // What cannot be read fails the build. A boundary's unreadable name can widen
1155
+ // to "any component", since its route still renders only what the registry
1156
+ // holds; an unreadable query name has no such bound, and a route given every
1157
+ // query is the hole the allowlist exists to close. A name that is not written
1158
+ // out, query() handed on as a value or re-exported, a registry entry that is
1159
+ // not an imported component under a written name: each refuses the build,
1160
+ // naming the file. Scope is followed: a local binding that shadows query()
1161
+ // is not query() (scopedWalk()).
1162
+
1163
+ /** The specifier server components import query() and deferredRegistry() by. */
1164
+ export const SERVER_SPECIFIER = '@tinvar/react/server';
1165
+
1166
+ /** The call the rsc entry declares the components a <Deferred> may name with. */
1167
+ export const REGISTRY_FUNCTION = 'deferredRegistry';
1168
+
1169
+ /** TypeScript's type-only syntax, inside which no value is read. */
1170
+ const TYPE_ONLY = new Set([
1171
+ 'TSTypeAnnotation',
1172
+ 'TSTypeReference',
1173
+ 'TSTypeQuery',
1174
+ 'TSTypeLiteral',
1175
+ 'TSImportType',
1176
+ 'TSTypeParameterInstantiation',
1177
+ 'TSTypeParameterDeclaration',
1178
+ 'TSTypeAliasDeclaration',
1179
+ 'TSInterfaceDeclaration',
1180
+ 'TSDeclareFunction',
1181
+ ]);
1182
+
1183
+ /** What wraps a value and leaves it the same value. */
1184
+ const SAME_VALUE = new Set([
1185
+ 'TSAsExpression',
1186
+ 'TSSatisfiesExpression',
1187
+ 'TSNonNullExpression',
1188
+ 'TSTypeAssertion',
1189
+ 'ParenthesizedExpression',
1190
+ ]);
1191
+
1192
+ /** The id a @queries/* module resolves to: one this plugin makes, with no file behind it. */
1193
+ export const QUERY_MODULE_PREFIX = '\0tinvar-queries:';
1194
+
1195
+ /** What a function in a @queries/* module may be called: codegen writes identifiers. */
1196
+ const QUERY_FUNCTION = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
1197
+
1198
+ /**
1199
+ * The @queries/* modules, made at build time.
1200
+ *
1201
+ * tinvar-spec.md: "Any server component can `await` data by calling a PHP
1202
+ * `#[Query]` class through a generated TypeScript function." `tinvar
1203
+ * codegen` types those functions (resources/types/modules.d.ts) and writes
1204
+ * which query each calls (QUERY_MAP); this plugin is what makes them exist.
1205
+ * Each module is made from the map alone: one function per entry, calling
1206
+ * query() with the name codegen wrote -- the same query() a component could
1207
+ * call itself, so the allowlist, the batching and the render context are
1208
+ * the ones every call goes through.
1209
+ *
1210
+ * Its modules have no file, so the allowlist's reading (queryUsers()) never
1211
+ * reads one as a caller of every query it holds: an import of `shipments`
1212
+ * from '@queries/orders' puts orders.shipments on the importer's route and
1213
+ * nothing else the module has.
1214
+ *
1215
+ * Only in the rsc environment. A query runs in a server component; a
1216
+ * 'use client' module importing one is refused where it is built, rather
1217
+ * than handing the browser a function that could never be answered. A
1218
+ * module codegen has not written fails the build too.
1219
+ *
1220
+ * And it keeps the testing layer out (ADR 0061). Every application has this
1221
+ * plugin -- its server components cannot call a query without it -- so it is
1222
+ * where a graph reaching `@tinvar/react/testing`, or codegen's mocks and
1223
+ * fakes that import it, fails: by the specifier, and by the marker the
1224
+ * runtime carries, which a path around the specifier still loads. In every
1225
+ * environment and in dev as well as the build: a mock answers for an action
1226
+ * without running it, which is a page saying it did what it did not do.
1227
+ *
1228
+ * And `@tinvar/react/server` -- query(), shared(), the render context -- is
1229
+ * server-only: "importing a query helper into a client file fails the
1230
+ * build" (tinvar-spec.md, "Server and client components"). Bundled for the
1231
+ * browser it built, with `node:async_hooks` stubbed out and a warning, into
1232
+ * a module that fails when called. It is refused here outside the rsc
1233
+ * environment instead, as a @queries/* module is. An application's own
1234
+ * server-only modules say so with `import 'server-only'`, which the RSC
1235
+ * plugin refuses outside the rsc environment by itself.
1236
+ *
1237
+ * @param {{ queryMap?: string }} [options] `queryMap` is where `tinvar codegen`
1238
+ * wrote it, relative to the Vite root
1239
+ */
1240
+ export function queryFunctions({ queryMap = QUERY_MAP } = {}) {
1241
+ let root = process.cwd();
1242
+
1243
+ return {
1244
+ name: 'tinvar:query-functions',
1245
+ enforce: 'pre',
1246
+ configResolved(config) {
1247
+ root = config.root;
1248
+ },
1249
+ resolveId(source, importer) {
1250
+ if (TESTING_MODULE.test(source)) {
1251
+ this.error(testingRefused(source, importer));
1252
+ }
1253
+ if (SERVER_MODULE.test(source) && this.environment?.name !== 'rsc') {
1254
+ const from = importer ? ` (imported by ${fileOf(importer)})` : '';
1255
+ this.error(
1256
+ `[tinvar] ${source}${from} is server-only: it runs a server component's queries and render, ` +
1257
+ `which the ${this.environment?.name ?? 'unknown'} environment cannot. A client component asks over HTTP, ` +
1258
+ 'with useHttp(), or is handed what it needs as props.',
1259
+ );
1260
+ }
1261
+ if (!QUERIES_MODULE.test(source)) return null;
1262
+ const from = importer ? ` (imported by ${fileOf(importer)})` : '';
1263
+ if (this.environment?.name !== 'rsc') {
1264
+ this.error(
1265
+ `[tinvar] ${source}${from} is a query, which runs in a server component; ` +
1266
+ `the ${this.environment?.name ?? 'unknown'} environment cannot call one. A client component asks over HTTP, with useHttp().`,
1267
+ );
1268
+ }
1269
+ if (!Object.hasOwn(readQueryMap(resolve(root, queryMap)), source)) {
1270
+ this.error(`[tinvar] ${source}${from} is not a module tinvar codegen has written; run it.`);
1271
+ }
1272
+ return QUERY_MODULE_PREFIX + source;
1273
+ },
1274
+ load(id) {
1275
+ // A module loaded with a query -- `?url`, `?raw`, `?inline` -- is
1276
+ // made into something else before the transform sees it: a URL, and
1277
+ // the file copied into the output as an asset. So the file itself is
1278
+ // read for the marker here, under whichever query it came.
1279
+ if (!id.startsWith('\0') && /[?#]/.test(id)) {
1280
+ const file = fileOf(id);
1281
+ if (SOURCE.test(file) && existsSync(file) && readFileSync(file, 'utf8').includes(TESTING_MARKER)) {
1282
+ this.error(testingRefused(file, null));
1283
+ }
1284
+ }
1285
+ if (!id.startsWith(QUERY_MODULE_PREFIX)) return null;
1286
+ const file = resolve(root, queryMap);
1287
+ this.addWatchFile(file);
1288
+ const module = id.slice(QUERY_MODULE_PREFIX.length);
1289
+ const functions = readQueryMap(file)[module];
1290
+ if (functions === undefined) {
1291
+ this.error(`[tinvar] ${module} is no longer in ${file}; run tinvar codegen.`);
1292
+ }
1293
+ return queryModuleSource(functions);
1294
+ },
1295
+ transform(code, id) {
1296
+ if (code.includes(TESTING_MARKER)) this.error(testingRefused(fileOf(id), null));
1297
+ return null;
1298
+ },
1299
+ };
1300
+ }
1301
+
1302
+ /** The server helpers' specifier, with a query or a hash or without. */
1303
+ const SERVER_MODULE = /^@tinvar\/react\/server(?:[?#]|$)/;
1304
+
1305
+ /** The testing runtime's specifier, any path under it, and either with a query or a hash. */
1306
+ const TESTING_MODULE = /^@tinvar\/react\/testing(?:[/?#]|$)/;
1307
+
1308
+ /** What the testing runtime exports as TESTING_RUNTIME, and so carries in its source. */
1309
+ const TESTING_MARKER = 'tinvar:' + 'testing-runtime';
1310
+
1311
+ /** @param {string} what @param {string | null | undefined} importer */
1312
+ function testingRefused(what, importer) {
1313
+ const from = importer ? ` (imported by ${fileOf(importer)})` : '';
1314
+ return (
1315
+ `[tinvar] ${what}${from} is the testing layer: mocks that answer for queries and actions ` +
1316
+ 'without running them. It loads only in a Vitest worker, and no Vite build or dev server may reach it.'
1317
+ );
1318
+ }
1319
+
1320
+ /**
1321
+ * A @queries/* module's source: each function codegen listed, calling its
1322
+ * query by the name codegen wrote. The names go in as string literals, and
1323
+ * a function name must be an identifier, so nothing in the map becomes code.
1324
+ *
1325
+ * @param {Record<string, string>} functions function => query, from readQueryMap()
1326
+ * @returns {string}
1327
+ * @throws {UnreadableQuery} for a function name that is not an identifier
1328
+ */
1329
+ export function queryModuleSource(functions) {
1330
+ const lines = [`import { query } from ${JSON.stringify(SERVER_SPECIFIER)};`];
1331
+ const exported = [];
1332
+ for (const [index, name] of Object.keys(functions).sort().entries()) {
1333
+ if (!QUERY_FUNCTION.test(name)) {
1334
+ throw new UnreadableQuery(
1335
+ `[tinvar] ${JSON.stringify(name)} is not a function name; run tinvar codegen.`,
1336
+ );
1337
+ }
1338
+ lines.push(`const q${index} = (args = {}) => query(${JSON.stringify(functions[name])}, args);`);
1339
+ exported.push(`q${index} as ${name}`);
1340
+ }
1341
+ lines.push(`export { ${exported.join(', ')} };`);
1342
+ return lines.join('\n') + '\n';
1343
+ }
1344
+
1345
+ /**
1346
+ * The modules that call a query, with the names each calls, and the
1347
+ * components the rsc entry registers for <Deferred>, each with its module --
1348
+ * read from the source of every module importing query(), deferredRegistry()
1349
+ * or a @queries/* function. One reading for the build and the dev server
1350
+ * (dev-server.js), so a route may call in dev exactly what its build allows.
1351
+ *
1352
+ * A module another plugin made has no source to read, and its calls are not
1353
+ * listed: PHP refuses them, which is the side to fail on. Nor is a
1354
+ * dependency's, under node_modules, which is not the page's to allow (see
1355
+ * above).
1356
+ *
1357
+ * @param {Map<string, string[]>} graph module id => imported ids
1358
+ * @param {string} pagesDir absolute; what the specifiers resolve from
1359
+ * @param {(specifier: string, importer: string) => Promise<string | null>} resolveId
1360
+ * as the rsc environment resolves, under `react-server`
1361
+ * @param {Record<string, Record<string, string>>} [queryMap] readQueryMap()'s
1362
+ * @returns {Promise<{ calls: Map<string, string[]>, registry: Map<string, string> }>}
1363
+ * module id => the query names it calls; component => its module id
1364
+ * @throws {UnreadableQuery}
1365
+ */
1366
+ export async function queryUsers(graph, pagesDir, resolveId, queryMap = {}) {
1367
+ const importer = resolve(pagesDir, 'index.tsx');
1368
+ const serverId = await resolveId(SERVER_SPECIFIER, importer);
1369
+ const rootId = await resolveId(ROOT_SPECIFIER, importer);
1370
+ const watched = new Set([serverId, rootId].filter((id) => id !== null));
1371
+ for (const module of Object.keys(queryMap)) {
1372
+ const id = await resolveId(module, importer);
1373
+ if (id !== null) watched.add(id);
1374
+ }
1375
+
1376
+ /** @type {Map<string, string[]>} */
1377
+ const calls = new Map();
1378
+ /** @type {Map<string, string>} */
1379
+ const registry = new Map();
1380
+
1381
+ for (const [id, imports] of graph) {
1382
+ // Where query() is defined, and the root that re-exports it.
1383
+ if (id === serverId || id === rootId) continue;
1384
+ if (!imports.some((imported) => watched.has(imported))) continue;
1385
+ const file = fileOf(id);
1386
+ if (id.startsWith('\0') || isDependency(file) || !existsSync(file)) continue;
1387
+
1388
+ const source = readFileSync(file, 'utf8');
1389
+ const resolved = new Map();
1390
+ for (const specifier of importSources(source, file)) {
1391
+ resolved.set(specifier, await resolveId(specifier, id));
1392
+ }
1393
+ const found = queryCalls(
1394
+ source,
1395
+ file,
1396
+ (specifier) => {
1397
+ const to = resolved.get(specifier) ?? null;
1398
+ return to !== null && (to === serverId || to === rootId);
1399
+ },
1400
+ queryMap,
1401
+ );
1402
+ if (found.queries.length > 0) calls.set(id, found.queries);
1403
+
1404
+ for (const [component, specifier] of found.registry) {
1405
+ const module = resolved.get(specifier) ?? null;
1406
+ if (module === null) {
1407
+ throw new UnreadableQuery(
1408
+ `[tinvar] ${file} registers the deferred component '${component}' from '${specifier}', which does not resolve.`,
1409
+ );
1410
+ }
1411
+ const before = registry.get(component);
1412
+ if (before !== undefined && before !== module) {
1413
+ throw new UnreadableQuery(
1414
+ `[tinvar] The deferred component '${component}' is registered twice: from ${fileOf(before)} and from ${fileOf(module)}.`,
1415
+ );
1416
+ }
1417
+ registry.set(component, module);
1418
+ }
1419
+ }
1420
+
1421
+ return { calls, registry };
1422
+ }
1423
+
1424
+ /**
1425
+ * Every view under the pages directory, with the query names reachable from
1426
+ * it, sorted; a view that calls none is listed with an empty list, as
1427
+ * viewsOf() lists one with no boundary.
1428
+ *
1429
+ * @param {Map<string, string[]>} graph module id => imported ids
1430
+ * @param {Map<string, string[]>} calls module id => the names it calls
1431
+ * @param {string} pagesDir absolute
1432
+ * @returns {Record<string, string[]>}
1433
+ */
1434
+ export function queriesOfViews(graph, calls, pagesDir) {
1435
+ /** @type {Record<string, string[]>} */
1436
+ const views = Object.create(null);
1437
+ for (const id of [...graph.keys()].sort()) {
1438
+ const view = viewOf(id, pagesDir);
1439
+ if (view !== null) views[view] = callsReachable(graph, calls, id);
1440
+ }
1441
+ return views;
1442
+ }
1443
+
1444
+ /**
1445
+ * Every component the rsc entry registers for <Deferred>, with the query
1446
+ * names reachable from its module, sorted.
1447
+ *
1448
+ * @param {Map<string, string[]>} graph module id => imported ids
1449
+ * @param {Map<string, string[]>} calls module id => the names it calls
1450
+ * @param {Map<string, string>} registry component => its module id
1451
+ * @returns {Record<string, string[]>}
1452
+ */
1453
+ export function queriesOfComponents(graph, calls, registry) {
1454
+ /** @type {Record<string, string[]>} */
1455
+ const components = Object.create(null);
1456
+ for (const [component, id] of [...registry].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))) {
1457
+ components[component] = callsReachable(graph, calls, id);
1458
+ }
1459
+ return components;
1460
+ }
1461
+
1462
+ /**
1463
+ * The queries one module calls, and the components a deferredRegistry() call
1464
+ * in it registers.
1465
+ *
1466
+ * @param {string} source the module's own source, as written
1467
+ * @param {string} file its path, which says how to parse it and names it in a refusal
1468
+ * @param {(specifier: string) => boolean} isQuery whether an import specifier
1469
+ * here names the module query() comes from: @tinvar/react/server, or
1470
+ * the root
1471
+ * @param {Record<string, Record<string, string>>} [queryMap] readQueryMap()'s
1472
+ * @returns {{ queries: string[], registry: Array<[string, string]> }} the names,
1473
+ * sorted; each registered component with the specifier its module is
1474
+ * imported by here
1475
+ * @throws {UnreadableQuery} naming the file and line of what cannot be read
1476
+ */
1477
+ export function queryCalls(source, file, isQuery, queryMap = {}) {
1478
+ const program = parse(source, file);
1479
+
1480
+ /** @type {(node: any, why: string) => never} */
1481
+ const refuse = (node, why) => {
1482
+ const line = typeof node?.start === 'number' ? source.slice(0, node.start).split('\n').length : 0;
1483
+ throw new UnreadableQuery(`[tinvar] ${file}${line > 0 ? `:${line}` : ''} ${why}`);
1484
+ };
1485
+ /** @type {(node: any, module: string) => Record<string, string>} */
1486
+ const functionsOf = (node, module) =>
1487
+ Object.hasOwn(queryMap, module)
1488
+ ? queryMap[module]
1489
+ : refuse(
1490
+ node,
1491
+ `imports ${module}, which tinvar codegen has not written: run it, so the build knows which query each of its functions calls.`,
1492
+ );
1493
+ /** @type {(node: any, module: string, name: string) => string} */
1494
+ const queryOf = (node, module, name) => {
1495
+ const functions = functionsOf(node, module);
1496
+ return Object.hasOwn(functions, name)
1497
+ ? functions[name]
1498
+ : refuse(node, `imports ${name}() from ${module}, which tinvar codegen did not write.`);
1499
+ };
1500
+
1501
+ const found = new Set();
1502
+ /** @type {Array<[string, string]>} */
1503
+ const registry = [];
1504
+ // The names query() and deferredRegistry() are bound to here, and the
1505
+ // namespaces whose `.query` is query().
1506
+ const callees = new Set();
1507
+ const registries = new Set();
1508
+ const namespaces = new Set();
1509
+ /** @type {Map<string, string>} each value imported by name, with the specifier it comes from */
1510
+ const imported = new Map();
1511
+
1512
+ for (const node of program.body) {
1513
+ const from = typeof node.source?.value === 'string' ? node.source.value : null;
1514
+ if (from === null || node.importKind === 'type' || node.exportKind === 'type') continue;
1515
+
1516
+ if (node.type === 'ImportDeclaration') {
1517
+ for (const specifier of node.specifiers) {
1518
+ if (specifier.importKind === 'type') continue;
1519
+ const namespace = specifier.type === 'ImportNamespaceSpecifier';
1520
+ if (!namespace) imported.set(specifier.local.name, from);
1521
+
1522
+ if (isQuery(from)) {
1523
+ if (namespace) namespaces.add(specifier.local.name);
1524
+ else if (specifier.type === 'ImportSpecifier' && importedName(specifier) === 'query') {
1525
+ callees.add(specifier.local.name);
1526
+ } else if (specifier.type === 'ImportSpecifier' && importedName(specifier) === REGISTRY_FUNCTION) {
1527
+ registries.add(specifier.local.name);
1528
+ }
1529
+ } else if (QUERIES_MODULE.test(from)) {
1530
+ // Imported is enough: each function calls its one query, whatever
1531
+ // the module then does with it.
1532
+ if (namespace) for (const query of Object.values(functionsOf(node, from))) found.add(query);
1533
+ else if (specifier.type === 'ImportSpecifier')
1534
+ found.add(queryOf(node, from, importedName(specifier)));
1535
+ else refuse(node, `imports a default from ${from}, which has none.`);
1536
+ }
1537
+ }
1538
+ } else if (node.type === 'ExportAllDeclaration' || node.type === 'ExportNamedDeclaration') {
1539
+ if (isQuery(from)) {
1540
+ // Handed on, it is called elsewhere under a name this cannot follow.
1541
+ if (
1542
+ node.type === 'ExportAllDeclaration' ||
1543
+ node.specifiers.some((/** @type {any} */ specifier) =>
1544
+ ['query', REGISTRY_FUNCTION].includes(localName(specifier)),
1545
+ )
1546
+ ) {
1547
+ refuse(
1548
+ node,
1549
+ `re-exports query() from ${from}, so the calls made through it cannot be listed. Import it where it is called.`,
1550
+ );
1551
+ }
1552
+ } else if (QUERIES_MODULE.test(from)) {
1553
+ if (node.type === 'ExportAllDeclaration') {
1554
+ for (const query of Object.values(functionsOf(node, from))) found.add(query);
1555
+ } else {
1556
+ for (const specifier of node.specifiers) found.add(queryOf(node, from, localName(specifier)));
1557
+ }
1558
+ }
1559
+ }
1560
+ }
1561
+
1562
+ /** @type {(callee: any, name: string, scope: Scope | null) => boolean} */
1563
+ const calls = (callee, name, scope) =>
1564
+ (callee.type === 'Identifier' &&
1565
+ (name === 'query' ? callees : registries).has(callee.name) &&
1566
+ !shadowed(callee.name, scope)) ||
1567
+ (callee.type === 'MemberExpression' &&
1568
+ !callee.computed &&
1569
+ callee.object.type === 'Identifier' &&
1570
+ namespaces.has(callee.object.name) &&
1571
+ !shadowed(callee.object.name, scope) &&
1572
+ callee.property.type === 'Identifier' &&
1573
+ callee.property.name === name);
1574
+
1575
+ /** @type {(node: any, parent: any, scope: Scope | null) => boolean | undefined} */
1576
+ const visit = (node, parent, scope) => {
1577
+ if (node.type === 'ImportDeclaration' || TYPE_ONLY.has(node.type)) return false;
1578
+ if ((node.type === 'ExportNamedDeclaration' || node.type === 'ExportAllDeclaration') && node.source) {
1579
+ return false;
1580
+ }
1581
+
1582
+ if (node.type === 'ImportExpression' && typeof node.source?.value === 'string') {
1583
+ const from = node.source.value;
1584
+ if (isQuery(from))
1585
+ refuse(node, `loads ${from} dynamically, so the calls made through it cannot be listed.`);
1586
+ if (QUERIES_MODULE.test(from))
1587
+ for (const query of Object.values(functionsOf(node, from))) found.add(query);
1588
+ }
1589
+
1590
+ if (
1591
+ node.type === 'CallExpression' &&
1592
+ (calls(node.callee, 'query', scope) || calls(node.callee, REGISTRY_FUNCTION, scope))
1593
+ ) {
1594
+ if (calls(node.callee, 'query', scope)) found.add(queryName(node, refuse));
1595
+ else registry.push(...registered(node, imported, refuse));
1596
+ for (const argument of node.arguments) scopedWalk(argument, visit, node, scope);
1597
+ return false;
1598
+ }
1599
+
1600
+ if (
1601
+ node.type === 'Identifier' &&
1602
+ (callees.has(node.name) || registries.has(node.name) || namespaces.has(node.name)) &&
1603
+ !shadowed(node.name, scope) &&
1604
+ !isPropertyKey(node, parent) &&
1605
+ !isMemberKey(node, parent) &&
1606
+ !(namespaces.has(node.name) && readsOtherThanQuery(node, parent))
1607
+ ) {
1608
+ refuse(
1609
+ node,
1610
+ `uses ${node.name} other than by calling it, so the queries called through it cannot be listed. Call query() where it is imported, with the name written out.`,
1611
+ );
1612
+ }
1613
+ return undefined;
1614
+ };
1615
+ scopedWalk(program, visit, null, null);
1616
+
1617
+ return { queries: [...found].sort(), registry };
1618
+ }
1619
+
1620
+ // Which bindings a name means where it is written. A local binding that
1621
+ // shadows query() -- a parameter, a `const`, a function's own `var` --
1622
+ // is not query(), and a call of it puts nothing on the allowlist nor
1623
+ // refuses the build; the import is what every name not bound between it
1624
+ // and the module means. Read by ECMAScript's rules for a module, which is
1625
+ // strict code: `var` and a function's parameters hold for the whole
1626
+ // function, `let`, `const`, `class` and a function declaration for their
1627
+ // block, a catch's parameter for its clause. A binding the reader does not
1628
+ // see is a call it reads as query() -- the old reading, refusing more,
1629
+ // never listing less.
1630
+
1631
+ /** @typedef {{ names: Set<string>, parent: Scope | null }} Scope */
1632
+
1633
+ /**
1634
+ * Depth-first as walk() is, with the scope each node is written in: the
1635
+ * visitor is handed the innermost scope around the node, none at the
1636
+ * module's top, where nothing can shadow an import.
1637
+ *
1638
+ * @param {any} node
1639
+ * @param {(node: any, parent: any, scope: Scope | null) => boolean | undefined} visit
1640
+ * @param {any} parent
1641
+ * @param {Scope | null} scope
1642
+ */
1643
+ function scopedWalk(node, visit, parent, scope) {
1644
+ if (node === null || typeof node !== 'object') return;
1645
+ if (Array.isArray(node)) {
1646
+ for (const child of node) scopedWalk(child, visit, parent, scope);
1647
+ return;
1648
+ }
1649
+ if (typeof node.type !== 'string') return;
1650
+ if (visit(node, parent, scope) === false) return;
1651
+ const inner = scopeOf(node, scope);
1652
+ for (const key of Object.keys(node)) {
1653
+ if (key === 'type' || key === 'start' || key === 'end' || key === 'range' || key === 'loc') continue;
1654
+ const child = node[key];
1655
+ if (child !== null && typeof child === 'object') scopedWalk(child, visit, node, inner);
1656
+ }
1657
+ }
1658
+
1659
+ /**
1660
+ * The scope a node opens for what is written inside it, or the one it is
1661
+ * in where it opens none.
1662
+ *
1663
+ * @param {any} node
1664
+ * @param {Scope | null} scope
1665
+ * @returns {Scope | null}
1666
+ */
1667
+ function scopeOf(node, scope) {
1668
+ /** @type {Set<string>} */
1669
+ const names = new Set();
1670
+ switch (node.type) {
1671
+ case 'FunctionDeclaration':
1672
+ case 'FunctionExpression':
1673
+ case 'ArrowFunctionExpression':
1674
+ for (const param of node.params) bindingNames(param, names);
1675
+ if (node.type === 'FunctionExpression' && node.id) names.add(node.id.name);
1676
+ varNames(node.body, names);
1677
+ if (node.body?.type === 'BlockStatement') lexicalNames(node.body.body, names);
1678
+ break;
1679
+ case 'BlockStatement':
1680
+ case 'StaticBlock':
1681
+ lexicalNames(node.body, names);
1682
+ break;
1683
+ case 'Program':
1684
+ return scope;
1685
+ case 'ForStatement':
1686
+ if (node.init?.type === 'VariableDeclaration') lexicalNames([node.init], names);
1687
+ break;
1688
+ case 'ForInStatement':
1689
+ case 'ForOfStatement':
1690
+ if (node.left?.type === 'VariableDeclaration') lexicalNames([node.left], names);
1691
+ break;
1692
+ case 'SwitchStatement':
1693
+ lexicalNames(
1694
+ node.cases.flatMap((/** @type {any} */ c) => c.consequent),
1695
+ names,
1696
+ );
1697
+ break;
1698
+ case 'CatchClause':
1699
+ if (node.param) bindingNames(node.param, names);
1700
+ break;
1701
+ case 'ClassDeclaration':
1702
+ case 'ClassExpression':
1703
+ if (node.id) names.add(node.id.name);
1704
+ break;
1705
+ default:
1706
+ return scope;
1707
+ }
1708
+ return names.size === 0 ? scope : { names, parent: scope };
1709
+ }
1710
+
1711
+ /** Whether a name is bound by a scope between where it is written and the module's top. */
1712
+ function shadowed(name, scope) {
1713
+ for (let current = scope; current !== null; current = current.parent) {
1714
+ if (current.names.has(name)) return true;
1715
+ }
1716
+ return false;
1717
+ }
1718
+
1719
+ /** The names a parameter or a declaration's pattern binds. */
1720
+ function bindingNames(pattern, names) {
1721
+ if (pattern === null || pattern === undefined) return;
1722
+ switch (pattern.type) {
1723
+ case 'Identifier':
1724
+ names.add(pattern.name);
1725
+ break;
1726
+ case 'ObjectPattern':
1727
+ for (const property of pattern.properties) {
1728
+ bindingNames(property.type === 'RestElement' ? property.argument : property.value, names);
1729
+ }
1730
+ break;
1731
+ case 'ArrayPattern':
1732
+ for (const element of pattern.elements) bindingNames(element, names);
1733
+ break;
1734
+ case 'RestElement':
1735
+ bindingNames(pattern.argument, names);
1736
+ break;
1737
+ case 'AssignmentPattern':
1738
+ bindingNames(pattern.left, names);
1739
+ break;
1740
+ case 'TSParameterProperty':
1741
+ bindingNames(pattern.parameter, names);
1742
+ break;
1743
+ default:
1744
+ break;
1745
+ }
1746
+ }
1747
+
1748
+ /** A block's own `let`, `const`, `using`, `class` and function declarations. */
1749
+ function lexicalNames(statements, names) {
1750
+ for (const statement of statements) {
1751
+ const declaration =
1752
+ statement.type === 'ExportNamedDeclaration' || statement.type === 'ExportDefaultDeclaration'
1753
+ ? statement.declaration
1754
+ : statement;
1755
+ if (declaration?.type === 'VariableDeclaration' && declaration.kind !== 'var' && !declaration.declare) {
1756
+ for (const declarator of declaration.declarations) bindingNames(declarator.id, names);
1757
+ } else if (
1758
+ (declaration?.type === 'FunctionDeclaration' || declaration?.type === 'ClassDeclaration') &&
1759
+ declaration.id &&
1760
+ !declaration.declare
1761
+ ) {
1762
+ names.add(declaration.id.name);
1763
+ }
1764
+ }
1765
+ }
1766
+
1767
+ /** A function's `var`s, wherever in its body, not in a function inside it. */
1768
+ function varNames(node, names) {
1769
+ if (node === null || typeof node !== 'object') return;
1770
+ if (Array.isArray(node)) {
1771
+ for (const child of node) varNames(child, names);
1772
+ return;
1773
+ }
1774
+ if (typeof node.type !== 'string') return;
1775
+ if (
1776
+ node.type === 'FunctionDeclaration' ||
1777
+ node.type === 'FunctionExpression' ||
1778
+ node.type === 'ArrowFunctionExpression' ||
1779
+ TYPE_ONLY.has(node.type)
1780
+ ) {
1781
+ return;
1782
+ }
1783
+ if (node.type === 'VariableDeclaration' && node.kind === 'var' && !node.declare) {
1784
+ for (const declarator of node.declarations) bindingNames(declarator.id, names);
1785
+ }
1786
+ for (const key of Object.keys(node)) {
1787
+ if (key === 'type' || key === 'start' || key === 'end' || key === 'range' || key === 'loc') continue;
1788
+ const child = node[key];
1789
+ if (child !== null && typeof child === 'object') varNames(child, names);
1790
+ }
1791
+ }
1792
+
1793
+ /**
1794
+ * The name a query() call passes: a string written out, and nothing else.
1795
+ *
1796
+ * @param {any} call
1797
+ * @param {(node: any, why: string) => never} refuse
1798
+ * @returns {string}
1799
+ */
1800
+ function queryName(call, refuse) {
1801
+ const [first] = call.arguments;
1802
+ if (first?.type === 'Literal' && typeof first.value === 'string' && first.value !== '') return first.value;
1803
+ if (first?.type === 'TemplateLiteral' && first.expressions.length === 0) {
1804
+ const cooked = first.quasis[0]?.value.cooked;
1805
+ if (typeof cooked === 'string' && cooked !== '') return cooked;
1806
+ }
1807
+ return refuse(
1808
+ call,
1809
+ "calls query() with a name that is not a string literal, so the build cannot put it on its route's allowlist. Write the name out: query('orders.shipments', ...).",
1810
+ );
1811
+ }
1812
+
1813
+ /**
1814
+ * What one deferredRegistry() call registers: an object written out, each
1815
+ * key a name and each value a component imported from its own module.
1816
+ *
1817
+ * @param {any} call
1818
+ * @param {Map<string, string>} imported each value imported by name here => its specifier
1819
+ * @param {(node: any, why: string) => never} refuse
1820
+ * @returns {Array<[string, string]>} component => the specifier its module is imported by
1821
+ */
1822
+ function registered(call, imported, refuse) {
1823
+ const [components] = call.arguments;
1824
+ if (call.arguments.length !== 1 || components?.type !== 'ObjectExpression') {
1825
+ return refuse(call, `calls ${REGISTRY_FUNCTION}() with something other than an object written out.`);
1826
+ }
1827
+
1828
+ /** @type {Array<[string, string]>} */
1829
+ const entries = [];
1830
+ for (const property of components.properties) {
1831
+ if (property.type !== 'Property' || property.computed || property.kind !== 'init' || property.method) {
1832
+ refuse(property, 'registers a deferred component under a name the build cannot read.');
1833
+ }
1834
+ const name =
1835
+ property.key.type === 'Identifier'
1836
+ ? property.key.name
1837
+ : property.key.type === 'Literal' && typeof property.key.value === 'string'
1838
+ ? property.key.value
1839
+ : refuse(property, 'registers a deferred component under a name the build cannot read.');
1840
+
1841
+ let value = property.value;
1842
+ while (SAME_VALUE.has(value.type)) value = value.expression;
1843
+ const from = value.type === 'Identifier' ? imported.get(value.name) : undefined;
1844
+ if (from === undefined) {
1845
+ refuse(
1846
+ property,
1847
+ `registers '${name}' as something other than a component imported from its own module, so the queries it calls cannot be listed.`,
1848
+ );
1849
+ }
1850
+ entries.push([name, /** @type {string} */ (from)]);
1851
+ }
1852
+ return entries;
1853
+ }
1854
+
1855
+ /** The name an export specifier takes from its source module. */
1856
+ function localName(specifier) {
1857
+ return specifier.local?.type === 'Identifier' ? specifier.local.name : specifier.local?.value;
1858
+ }
1859
+
1860
+ /** `class { query() {} }` names a member, not the binding. */
1861
+ function isMemberKey(node, parent) {
1862
+ return (
1863
+ (parent?.type === 'MethodDefinition' || parent?.type === 'PropertyDefinition') &&
1864
+ parent.key === node &&
1865
+ !parent.computed
1866
+ );
1867
+ }
1868
+
1869
+ /** `ns.nonce`: a namespace read for a name, written out, that is not query() or the registry. */
1870
+ function readsOtherThanQuery(node, parent) {
1871
+ return (
1872
+ parent?.type === 'MemberExpression' &&
1873
+ parent.object === node &&
1874
+ !parent.computed &&
1875
+ parent.property.type === 'Identifier' &&
1876
+ parent.property.name !== 'query' &&
1877
+ parent.property.name !== REGISTRY_FUNCTION
1878
+ );
1879
+ }
1880
+
1881
+ // ---------------------------------------------------------------------------
1882
+ // The actions each view may call.
1883
+ //
1884
+ // tinvar-spec.md, "Defaults and build rules": "an action or query reachable
1885
+ // only from admin-panel pages with no panel binding fails the build." Over
1886
+ // /_action an action runs behind its own panel's stack, and one with no
1887
+ // panel runs behind none: an action written for the admin panel's pages and
1888
+ // left unbound answers anyone signed in. Which pages reach an action is a
1889
+ // question about source, so the build answers it, as it answers which
1890
+ // queries a page calls, and PHP refuses to boot where the answer is "only a
1891
+ // panel's" and the action names no panel (ADR 0095).
1892
+ //
1893
+ // An action is called from a client component, which the rsc graph holds as
1894
+ // a reference with its imports gone. So this walk is over source: from each
1895
+ // view, and each component the rsc entry registers for <Deferred>, through
1896
+ // every import of the application's own -- server and client modules alike
1897
+ // -- to each `@actions/*` module codegen writes, by the names imported from
1898
+ // it; and to each `action('name')` written out against
1899
+ // `@tinvar/react/action`. A namespace, a default or a dynamic import of an
1900
+ // actions module counts every action in it, which can only add callers.
1901
+ // Dependencies are not read: what they call is not the page's.
1902
+
1903
+ /** A module codegen types an action class in: `@actions/RackActions`. */
1904
+ const ACTIONS_MODULE = /^@actions\/[A-Za-z_$][A-Za-z0-9_$]*$/;
1905
+
1906
+ /** The specifier `action()` itself is imported by. */
1907
+ export const ACTION_SPECIFIER = '@tinvar/react/action';
1908
+
1909
+ /**
1910
+ * Every view's actions, and every registered deferred component's, read over
1911
+ * the application's source.
1912
+ *
1913
+ * @param {Map<string, string[]>} graph module id => imported ids, the rsc build's
1914
+ * @param {Map<string, string>} registry component => its module id (queryUsers())
1915
+ * @param {string} pagesDir absolute
1916
+ * @param {(specifier: string, importer: string) => Promise<string | null>} resolveId
1917
+ * @returns {Promise<{ views: Record<string, string[]>, components: Record<string, string[]> }>}
1918
+ * @throws {UnreadableQuery} for an actions module whose actions cannot be read
1919
+ */
1920
+ export async function actionsOf(graph, registry, pagesDir, resolveId) {
1921
+ /** @type {Map<string, string[]>} module id => the ids its source imports */
1922
+ const edges = new Map();
1923
+ /** @type {Map<string, string[]>} module id => the actions it calls */
1924
+ const calls = new Map();
1925
+ /** @type {Map<string, Map<string, string>>} actions module id => export => action */
1926
+ const modules = new Map();
1927
+
1928
+ const starts = [...[...graph.keys()].filter((id) => viewOf(id, pagesDir) !== null), ...registry.values()];
1929
+ const queue = [...new Set(starts)];
1930
+ const seen = new Set(queue);
1931
+ while (queue.length > 0) {
1932
+ const id = /** @type {string} */ (queue.shift());
1933
+ const file = fileOf(id);
1934
+ if (id.startsWith('\0') || isDependency(file) || !SOURCE.test(file) || !existsSync(file)) continue;
1935
+
1936
+ const source = readFileSync(file, 'utf8');
1937
+ const program = parse(source, file);
1938
+ const next = [];
1939
+ const called = new Set(actionTargets(program, source, file));
1940
+
1941
+ for (const node of program.body) {
1942
+ const from = typeof node.source?.value === 'string' ? node.source.value : null;
1943
+ if (from === null) continue;
1944
+ const to = await resolveId(from, id);
1945
+
1946
+ if (ACTIONS_MODULE.test(from)) {
1947
+ if (to === null)
1948
+ throw new UnreadableQuery(`[tinvar] ${file} imports ${from}, which does not resolve.`);
1949
+ const exported = await actionsIn(modules, to);
1950
+ const names =
1951
+ node.type === 'ImportDeclaration' || node.type === 'ExportNamedDeclaration'
1952
+ ? (node.specifiers ?? []).map((specifier) =>
1953
+ specifier.type === 'ImportSpecifier'
1954
+ ? importedName(specifier)
1955
+ : specifier.type === 'ExportSpecifier'
1956
+ ? specifier.local.type === 'Identifier'
1957
+ ? specifier.local.name
1958
+ : specifier.local.value
1959
+ : null,
1960
+ )
1961
+ : [null];
1962
+ for (const name of names) {
1963
+ if (name === null) {
1964
+ for (const action of exported.values()) called.add(action);
1965
+ } else if (exported.has(name)) {
1966
+ called.add(/** @type {string} */ (exported.get(name)));
1967
+ }
1968
+ }
1969
+ continue;
1970
+ }
1971
+
1972
+ if (to !== null) next.push(to);
1973
+ }
1974
+
1975
+ walk(program, (node) => {
1976
+ if (node.type === 'ImportExpression' && typeof node.source?.value === 'string') {
1977
+ if (ACTIONS_MODULE.test(node.source.value)) called.add('*');
1978
+ }
1979
+ return undefined;
1980
+ });
1981
+ // A dynamic import of an actions module: every action in it, resolved
1982
+ // as a static one is.
1983
+ if (called.has('*')) {
1984
+ called.delete('*');
1985
+ for (const specifier of importSources(source, file)) {
1986
+ if (!ACTIONS_MODULE.test(specifier)) continue;
1987
+ const to = await resolveId(specifier, id);
1988
+ if (to === null) continue;
1989
+ for (const action of (await actionsIn(modules, to)).values()) called.add(action);
1990
+ }
1991
+ }
1992
+ // Imports a dynamic import() names are followed too.
1993
+ for (const specifier of importSources(source, file)) {
1994
+ if (ACTIONS_MODULE.test(specifier)) continue;
1995
+ const to = await resolveId(specifier, id);
1996
+ if (to !== null) next.push(to);
1997
+ }
1998
+
1999
+ edges.set(id, [...new Set(next)]);
2000
+ if (called.size > 0) calls.set(id, [...called]);
2001
+ for (const to of next) {
2002
+ if (!seen.has(to)) {
2003
+ seen.add(to);
2004
+ queue.push(to);
2005
+ }
2006
+ }
2007
+ }
2008
+
2009
+ /** @type {Record<string, string[]>} */
2010
+ const views = Object.create(null);
2011
+ for (const id of [...graph.keys()].sort()) {
2012
+ const view = viewOf(id, pagesDir);
2013
+ if (view !== null) views[view] = callsReachable(edges, calls, id);
2014
+ }
2015
+ /** @type {Record<string, string[]>} */
2016
+ const components = Object.create(null);
2017
+ for (const [component, id] of [...registry].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))) {
2018
+ components[component] = callsReachable(edges, calls, id);
2019
+ }
2020
+ return { views, components };
2021
+ }
2022
+
2023
+ /** The package whose entries hand out the APIs that take an action by name. */
2024
+ const REACT_PACKAGE = /^@tinvar\/react(?:\/[a-z-]+)?$/;
2025
+
2026
+ /**
2027
+ * What makes an action, or its address, from a name: its argument is the
2028
+ * action's name and nothing else, so it must be written out.
2029
+ */
2030
+ const NAMING_APIS = new Set(['action', 'actionUrl', 'actionName', 'optimisticPatch']);
2031
+
2032
+ /** What takes an ActionTarget -- a reference, or a name -- first. */
2033
+ const TARGET_APIS = new Set(['useHttp']);
2034
+
2035
+ /** The methods of what useForm() returns that take an ActionTarget first. */
2036
+ const FORM_METHODS = new Set(['submit', 'withPrecognition']);
2037
+
2038
+ /** Every name of the package this reader follows. */
2039
+ const FOLLOWED = new Set([...NAMING_APIS, ...TARGET_APIS, 'useForm', 'Form']);
2040
+
2041
+ /**
2042
+ * The actions one module names by string. Every API that takes an
2043
+ * ActionTarget counts one given as a literal, as `action('literal')` is
2044
+ * counted: `<Form action="name">`, `useHttp('name')`, `useForm()`'s
2045
+ * `submit('name')` and `withPrecognition('name')`, and `action()`,
2046
+ * `actionUrl()`, `actionName()` and `optimisticPatch()`, under any alias or
2047
+ * namespace. A reference -- an `@actions/*` import, or a prop holding one --
2048
+ * is counted where it is imported.
2049
+ *
2050
+ * What the build cannot read refuses it, as the query reader does: a name
2051
+ * made at run time, anything but a written-out name handed to action() and
2052
+ * its kin, and any of these APIs re-exported or used other than by calling
2053
+ * it. Guessing would be no safer than missing: an action is held to its
2054
+ * panel when every page that calls it is in one, so a caller missed and a
2055
+ * caller invented can each let an unbound action pass (ADR 0095).
2056
+ *
2057
+ * @param {any} program
2058
+ * @param {string} source
2059
+ * @param {string} file
2060
+ * @returns {string[]} the names, sorted
2061
+ * @throws {UnreadableQuery} naming the file and line of what it cannot read
2062
+ */
2063
+ export function actionTargets(program, source, file) {
2064
+ /** @type {(node: any, why: string) => never} */
2065
+ const refuse = (node, why) => {
2066
+ const line = typeof node?.start === 'number' ? source.slice(0, node.start).split('\n').length : 0;
2067
+ throw new UnreadableQuery(`[tinvar] ${file}${line > 0 ? `:${line}` : ''} ${why}`);
2068
+ };
2069
+
2070
+ /** @type {Map<string, string>} local name => the API it is */
2071
+ const apis = new Map();
2072
+ /** @type {Set<string>} the package's namespaces */
2073
+ const namespaces = new Set();
2074
+ /** @type {Set<string>} local names an @actions module's exports are bound to */
2075
+ const references = new Set();
2076
+ /** @type {Set<string>} @actions modules' namespaces */
2077
+ const referenceSpaces = new Set();
2078
+
2079
+ for (const node of program.body) {
2080
+ const from = typeof node.source?.value === 'string' ? node.source.value : null;
2081
+ if (from === null) continue;
2082
+ if (ACTIONS_MODULE.test(from) && node.type === 'ImportDeclaration') {
2083
+ for (const specifier of node.specifiers) {
2084
+ (specifier.type === 'ImportNamespaceSpecifier' ? referenceSpaces : references).add(
2085
+ specifier.local.name,
2086
+ );
2087
+ }
2088
+ continue;
2089
+ }
2090
+ if (!REACT_PACKAGE.test(from)) continue;
2091
+ if (node.type === 'ImportDeclaration') {
2092
+ if (node.importKind === 'type') continue;
2093
+ for (const specifier of node.specifiers) {
2094
+ if (specifier.importKind === 'type') continue;
2095
+ if (specifier.type === 'ImportNamespaceSpecifier') namespaces.add(specifier.local.name);
2096
+ else if (specifier.type === 'ImportSpecifier' && FOLLOWED.has(importedName(specifier))) {
2097
+ apis.set(specifier.local.name, importedName(specifier));
2098
+ }
2099
+ }
2100
+ } else if (node.exportKind !== 'type') {
2101
+ // Handed on, it is used elsewhere under a name this cannot follow.
2102
+ const handed =
2103
+ node.type === 'ExportAllDeclaration' ||
2104
+ (node.specifiers ?? []).some((specifier) =>
2105
+ FOLLOWED.has(specifier.local.type === 'Identifier' ? specifier.local.name : specifier.local.value),
2106
+ );
2107
+ if (handed) {
2108
+ refuse(
2109
+ node,
2110
+ `re-exports from ${from} what takes an action by name, so the actions named through it cannot be listed. Import it where it is used.`,
2111
+ );
2112
+ }
2113
+ }
2114
+ }
2115
+
2116
+ /** The API a callee or an element's name is, or null. */
2117
+ const apiOf = (/** @type {any} */ callee) => {
2118
+ if (callee?.type === 'Identifier' || callee?.type === 'JSXIdentifier')
2119
+ return apis.get(callee.name) ?? null;
2120
+ if (
2121
+ (callee?.type === 'MemberExpression' || callee?.type === 'JSXMemberExpression') &&
2122
+ !callee.computed &&
2123
+ (callee.object.type === 'Identifier' || callee.object.type === 'JSXIdentifier') &&
2124
+ namespaces.has(callee.object.name) &&
2125
+ FOLLOWED.has(callee.property.name)
2126
+ ) {
2127
+ return callee.property.name;
2128
+ }
2129
+ return null;
2130
+ };
2131
+
2132
+ /** @type {Map<string, any>} what each name this module binds with an initializer is bound to */
2133
+ const constants = new Map();
2134
+ /** @type {Set<string>} names useForm()'s submit and withPrecognition are bound to */
2135
+ const formMethods = new Set();
2136
+ walk(program, (node) => {
2137
+ if (node.type !== 'VariableDeclarator' || !node.init) return undefined;
2138
+ if (node.id.type === 'Identifier') constants.set(node.id.name, node.init);
2139
+ if (
2140
+ node.id.type === 'ObjectPattern' &&
2141
+ node.init.type === 'CallExpression' &&
2142
+ apiOf(node.init.callee) === 'useForm'
2143
+ ) {
2144
+ for (const property of node.id.properties) {
2145
+ const key = property.type === 'Property' && !property.computed ? property.key : null;
2146
+ if (key?.type === 'Identifier' && FORM_METHODS.has(key.name)) {
2147
+ if (property.value.type !== 'Identifier') {
2148
+ refuse(property, `binds useForm()'s ${key.name} so the build cannot follow it.`);
2149
+ }
2150
+ formMethods.add(property.value.name);
2151
+ }
2152
+ }
2153
+ }
2154
+ return undefined;
2155
+ });
2156
+ const formsUsed = [...apis.values()].includes('useForm') || namespaces.size > 0;
2157
+
2158
+ /** @type {Set<string>} */
2159
+ const found = new Set();
2160
+ /** The string a node writes out -- a literal, or a template with no holes -- or undefined. */
2161
+ const written = (/** @type {any} */ node) =>
2162
+ node?.type === 'Literal' && typeof node.value === 'string'
2163
+ ? node.value
2164
+ : node?.type === 'TemplateLiteral' && node.expressions.length === 0
2165
+ ? (node.quasis[0]?.value.cooked ?? undefined)
2166
+ : undefined;
2167
+ /** Whether a node makes a string at run time: a template with holes, a concatenation. */
2168
+ const computed = (/** @type {any} */ node) =>
2169
+ (node?.type === 'TemplateLiteral' && node.expressions.length > 0) ||
2170
+ (node?.type === 'BinaryExpression' && node.operator === '+');
2171
+
2172
+ /**
2173
+ * A name, as action() and its kin take one: written out, or a constant of
2174
+ * this module that is; or a reference from an @actions module, counted by
2175
+ * its import. Anything else -- a parameter, a prop, a name made at run
2176
+ * time -- is refused.
2177
+ */
2178
+ const name = (/** @type {any} */ node, /** @type {string} */ api, seen = new Set()) => {
2179
+ const text = written(node);
2180
+ if (text !== undefined) {
2181
+ found.add(text);
2182
+ return;
2183
+ }
2184
+ if (node?.type === 'Identifier' && references.has(node.name)) return;
2185
+ if (
2186
+ node?.type === 'MemberExpression' &&
2187
+ !node.computed &&
2188
+ node.object.type === 'Identifier' &&
2189
+ referenceSpaces.has(node.object.name)
2190
+ ) {
2191
+ return;
2192
+ }
2193
+ if (node?.type === 'Identifier' && constants.has(node.name) && !seen.has(node.name)) {
2194
+ seen.add(node.name);
2195
+ name(constants.get(node.name), api, seen);
2196
+ return;
2197
+ }
2198
+ if (node?.type === 'ConditionalExpression') {
2199
+ name(node.consequent, api, seen);
2200
+ name(node.alternate, api, seen);
2201
+ return;
2202
+ }
2203
+ refuse(
2204
+ node,
2205
+ `calls ${api}() with a name the build cannot read. Write the action's name out: ${api}('orders.place').`,
2206
+ );
2207
+ };
2208
+
2209
+ /**
2210
+ * An ActionTarget: a name written out is counted; a reference, or a value
2211
+ * holding one, is counted where it was made; a string made at run time,
2212
+ * or a name given to an object's `action`, is read as action() reads one.
2213
+ */
2214
+ const target = (/** @type {any} */ node, /** @type {string} */ api, seen = new Set()) => {
2215
+ if (node === undefined || node === null) return;
2216
+ const text = written(node);
2217
+ if (text !== undefined) {
2218
+ found.add(text);
2219
+ return;
2220
+ }
2221
+ if (computed(node)) {
2222
+ refuse(
2223
+ node,
2224
+ `hands ${api} an action's name made at run time. Name it with an @actions import, or write its name out.`,
2225
+ );
2226
+ }
2227
+ switch (node.type) {
2228
+ case 'Identifier':
2229
+ if (!references.has(node.name) && constants.has(node.name) && !seen.has(node.name)) {
2230
+ seen.add(node.name);
2231
+ target(constants.get(node.name), api, seen);
2232
+ }
2233
+ return;
2234
+ case 'ConditionalExpression':
2235
+ target(node.consequent, api, seen);
2236
+ target(node.alternate, api, seen);
2237
+ return;
2238
+ case 'LogicalExpression':
2239
+ target(node.left, api, seen);
2240
+ target(node.right, api, seen);
2241
+ return;
2242
+ case 'TSAsExpression':
2243
+ case 'TSSatisfiesExpression':
2244
+ case 'TSNonNullExpression':
2245
+ case 'ParenthesizedExpression':
2246
+ target(node.expression, api, seen);
2247
+ return;
2248
+ case 'CallExpression':
2249
+ // action('name') and its kin are read where they are called; any
2250
+ // other call's result is a name or a reference no one can read here.
2251
+ if (NAMING_APIS.has(apiOf(node.callee) ?? '')) return;
2252
+ refuse(
2253
+ node,
2254
+ `hands ${api} what a call returns, which the build cannot read. Name the action with an @actions import, or write its name out.`,
2255
+ );
2256
+ return;
2257
+ case 'ObjectExpression':
2258
+ // `{ action: 'name' }`, an ActionTarget too.
2259
+ for (const property of node.properties) {
2260
+ if (property.type !== 'Property' || property.computed) {
2261
+ refuse(property, `hands ${api} an object whose action the build cannot read.`);
2262
+ }
2263
+ if ((property.key.name ?? property.key.value) === 'action') name(property.value, api);
2264
+ }
2265
+ return;
2266
+ default:
2267
+ // A member, a call's result, a prop: a reference, or a value made
2268
+ // from one, each counted where it came from.
2269
+ return;
2270
+ }
2271
+ };
2272
+
2273
+ walk(program, (node, parent) => {
2274
+ if (node.type === 'ImportDeclaration' || (node.type === 'ExportNamedDeclaration' && node.source))
2275
+ return false;
2276
+ if (node.type === 'CallExpression') {
2277
+ const api = apiOf(node.callee);
2278
+ if (api !== null && NAMING_APIS.has(api)) name(node.arguments[0], api);
2279
+ else if (api !== null && TARGET_APIS.has(api)) target(node.arguments[0], `${api}()`);
2280
+ else if (
2281
+ node.arguments.length > 0 &&
2282
+ node.callee.type === 'Identifier' &&
2283
+ formMethods.has(node.callee.name)
2284
+ ) {
2285
+ target(node.arguments[0], `${node.callee.name}()`);
2286
+ } else if (
2287
+ node.arguments.length > 0 &&
2288
+ formsUsed &&
2289
+ node.callee.type === 'MemberExpression' &&
2290
+ !node.callee.computed &&
2291
+ node.callee.property.type === 'Identifier' &&
2292
+ FORM_METHODS.has(node.callee.property.name)
2293
+ ) {
2294
+ target(node.arguments[0], `${node.callee.property.name}()`);
2295
+ }
2296
+ return undefined;
2297
+ }
2298
+ if (node.type === 'JSXOpeningElement' && apiOf(node.name) === 'Form') {
2299
+ // A spread after the last action= may set the action itself; one
2300
+ // before it is overridden by it.
2301
+ let spread = null;
2302
+ for (const attribute of node.attributes) {
2303
+ if (attribute.type === 'JSXSpreadAttribute') {
2304
+ spread = attribute;
2305
+ } else if (attribute.name?.type === 'JSXIdentifier' && attribute.name.name === 'action') {
2306
+ const value = attribute.value;
2307
+ target(value?.type === 'JSXExpressionContainer' ? value.expression : value, '<Form action>');
2308
+ spread = null;
2309
+ }
2310
+ }
2311
+ if (spread !== null) {
2312
+ refuse(
2313
+ spread,
2314
+ 'spreads props into <Form> after its action=, so the action it posts to cannot be read. Put action= after the spread.',
2315
+ );
2316
+ }
2317
+ return undefined;
2318
+ }
2319
+ // One of these APIs used other than by calling it, or drawing it: handed
2320
+ // on, under a name this cannot follow.
2321
+ const handed =
2322
+ (node.type === 'Identifier' && apis.has(node.name) && !isPropertyKey(node, parent)) ||
2323
+ (node.type === 'MemberExpression' && apiOf(node) !== null);
2324
+ const used =
2325
+ (parent?.type === 'CallExpression' && parent.callee === node) ||
2326
+ (parent?.type === 'ChainExpression' && parent.expression === node) ||
2327
+ (typeof parent?.type === 'string' &&
2328
+ parent.type.startsWith('TS') &&
2329
+ !/^TS(As|Satisfies|NonNull)Expression$/.test(parent.type));
2330
+ if (handed && !used) {
2331
+ refuse(
2332
+ node,
2333
+ `uses ${node.type === 'Identifier' ? node.name : apiOf(node)} other than by calling it, so the actions named through it cannot be listed. Call it where it is imported.`,
2334
+ );
2335
+ }
2336
+ return node.type === 'MemberExpression' && apiOf(node) !== null ? false : undefined;
2337
+ });
2338
+
2339
+ return [...found].sort();
2340
+ }
2341
+
2342
+ /**
2343
+ * The actions an `@actions/*` module codegen wrote exports, by export name:
2344
+ * each `export const name = action<...>('class.method')`. Anything else in
2345
+ * it means it is not codegen's, and nothing it exports can be named.
2346
+ *
2347
+ * @param {Map<string, Map<string, string>>} modules read so far, by id
2348
+ * @param {string} id
2349
+ * @returns {Promise<Map<string, string>>}
2350
+ */
2351
+ async function actionsIn(modules, id) {
2352
+ const known = modules.get(id);
2353
+ if (known !== undefined) return known;
2354
+
2355
+ const file = fileOf(id);
2356
+ if (!existsSync(file)) throw new UnreadableQuery(`[tinvar] ${file}, an actions module, cannot be read.`);
2357
+ const program = parse(readFileSync(file, 'utf8'), file);
2358
+ /** @type {Map<string, string>} */
2359
+ const actions = new Map();
2360
+ for (const node of program.body) {
2361
+ if (node.type !== 'ExportNamedDeclaration' || node.declaration?.type !== 'VariableDeclaration') continue;
2362
+ for (const declarator of node.declaration.declarations) {
2363
+ const init = declarator.init;
2364
+ const name = init?.type === 'CallExpression' ? init.arguments[0] : null;
2365
+ if (
2366
+ declarator.id.type !== 'Identifier' ||
2367
+ init?.type !== 'CallExpression' ||
2368
+ init.callee.type !== 'Identifier' ||
2369
+ name?.type !== 'Literal' ||
2370
+ typeof name.value !== 'string'
2371
+ ) {
2372
+ throw new UnreadableQuery(
2373
+ `[tinvar] ${file} exports something that is not an action codegen wrote; run tinvar codegen to write it again.`,
2374
+ );
2375
+ }
2376
+ actions.set(declarator.id.name, name.value);
2377
+ }
2378
+ }
2379
+ modules.set(id, actions);
2380
+ return actions;
2381
+ }
2382
+
2383
+ /** A module's view id -- its path under the pages directory, without the extension -- or null. */
2384
+ function viewOf(id, pagesDir) {
2385
+ const file = fileOf(id);
2386
+ const under = relative(pagesDir, file);
2387
+ if (id.startsWith('\0') || under.startsWith('..') || under.startsWith(sep) || !SOURCE.test(file))
2388
+ return null;
2389
+
2390
+ return under.slice(0, -extname(under).length).split(sep).join('/');
2391
+ }
2392
+
2393
+ /**
2394
+ * The names called anywhere reachable from one module, itself included, sorted.
2395
+ *
2396
+ * @param {Map<string, string[]>} graph
2397
+ * @param {Map<string, string[]>} calls
2398
+ * @param {string} start
2399
+ * @returns {string[]}
2400
+ */
2401
+ function callsReachable(graph, calls, start) {
2402
+ const found = new Set();
2403
+ const seen = new Set([start]);
2404
+ const queue = [start];
2405
+ while (queue.length > 0) {
2406
+ const current = /** @type {string} */ (queue.shift());
2407
+ for (const name of calls.get(current) ?? []) found.add(name);
2408
+ for (const next of graph.get(current) ?? []) {
2409
+ if (!seen.has(next)) {
2410
+ seen.add(next);
2411
+ queue.push(next);
2412
+ }
2413
+ }
2414
+ }
2415
+ return [...found].sort();
2416
+ }