@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/LICENSE +21 -0
- package/bin/dev-renderer.js +117 -0
- package/bin/renderer-probe.js +52 -0
- package/bin/renderer.js +327 -0
- package/package.json +44 -4
- package/src/asset-origin.js +306 -0
- package/src/build-profile.js +108 -0
- package/src/can-branch.js +1666 -0
- package/src/classify.js +157 -0
- package/src/client-chunks.js +54 -0
- package/src/client-props.js +540 -0
- package/src/codec.js +297 -0
- package/src/dev-app.js +45 -0
- package/src/dev-guard.js +33 -0
- package/src/dev-server.js +460 -0
- package/src/eslint.js +469 -0
- package/src/head.js +524 -0
- package/src/html-stream.js +479 -0
- package/src/log.js +131 -0
- package/src/mint-client.js +142 -0
- package/src/panel-styles.js +254 -0
- package/src/pool.js +664 -0
- package/src/protocol.js +71 -0
- package/src/query-client.js +198 -0
- package/src/query-map.js +116 -0
- package/src/render-session.js +580 -0
- package/src/sandbox.js +710 -0
- package/src/server.js +466 -0
- package/src/socket-group.js +72 -0
- package/src/vite.js +2416 -0
- package/src/vitest.d.ts +20 -0
- package/src/vitest.js +179 -0
- package/src/warm.d.ts +9 -0
- package/src/warm.js +60 -0
- package/src/workbench.d.ts +146 -0
- package/src/workbench.js +754 -0
- package/README.md +0 -3
package/src/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
|
+
}
|