@tinvar/renderer 0.0.0-stage → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,540 @@
1
+ // No function handed from a server component to a client one, refused by
2
+ // the build.
3
+ //
4
+ // tinvar-spec.md, "Client components": "Props across the boundary must be
5
+ // serializable: Data objects, primitives, promises (see queries) and server
6
+ // actions. Closures are refused at build time where detectable and at render
7
+ // time otherwise."
8
+ //
9
+ // At render, React refuses a function prop to a client component as it
10
+ // serializes the tree, and the renderer classifies the failure
11
+ // NOT_SERIALIZABLE (classify.js): the page fails for the visitor who reached
12
+ // it, in whichever branch of it held the function. The build can see most
13
+ // of these first, where they are written: a client component is a module the
14
+ // RSC plugin made a client reference of, and a function written into its
15
+ // props is syntax.
16
+ //
17
+ // So, over the rsc environment's graph once it is whole, each of the
18
+ // application's server modules is read with the bundler's parser, and each
19
+ // JSX element whose tag is imported is resolved: through the import, and
20
+ // through any re-exports on the way -- a kit's barrel -- to the module that
21
+ // defines it. Where that module is a client reference, every prop written to
22
+ // the element is read for a function:
23
+ // - a function or class written in place: `onClick={() => ...}`;
24
+ // - a name bound in the file to one -- a function declaration, or a
25
+ // `const`, `let` or `var` initialized with one -- in the scopes around
26
+ // the element;
27
+ // - `fn.bind(...)`, which makes a function;
28
+ // - any of those inside an object or array written in place, either branch
29
+ // of a conditional, either side of `&&`, `||` or `??`;
30
+ // and a function written as a child, a render prop. A server action is no
31
+ // function here: under `react-server` an `@actions/*` reference is the
32
+ // action's name (action-server.js), and Tinvar has no `'use server'`
33
+ // functions -- the renderer decodes no client calls (tinvar-spec.md, "No
34
+ // client input") -- so one of those is refused like any other function.
35
+ //
36
+ // What it cannot see, React still refuses at render: a function reached
37
+ // through a parameter, an import or a spread, and a tag whose module the
38
+ // build cannot read. Those are unknowns, not refusals: a server component
39
+ // may hand a function to another server component, and widening an unknown
40
+ // to "client" would refuse that. What it can see, it refuses, with every
41
+ // place named; and an application module it cannot parse fails the build.
42
+
43
+ import { existsSync, readFileSync } from 'node:fs';
44
+ import { extname, relative } from 'node:path';
45
+ import { getPluginApi } from '@vitejs/plugin-rsc';
46
+ import { parseAst } from 'vite';
47
+
48
+ const SOURCE = /\.(?:[cm]?[jt]sx?)$/;
49
+
50
+ /** What wraps a value and leaves it the same value. */
51
+ const SAME_VALUE = new Set([
52
+ 'TSAsExpression',
53
+ 'TSSatisfiesExpression',
54
+ 'TSNonNullExpression',
55
+ 'TSTypeAssertion',
56
+ 'TSInstantiationExpression',
57
+ 'ParenthesizedExpression',
58
+ 'ChainExpression',
59
+ ]);
60
+
61
+ /** Why the build refuses a module's props. */
62
+ export class UnserializableProp extends Error {
63
+ /** @param {string} message */
64
+ constructor(message) {
65
+ super(message);
66
+ this.name = 'UnserializableProp';
67
+ }
68
+ }
69
+
70
+ /**
71
+ * The plugin. Only the rsc build has the server component graph, and the
72
+ * client references the RSC plugin made of it.
73
+ *
74
+ * @returns {import('vite').Plugin}
75
+ */
76
+ export function serializableProps() {
77
+ /** @type {import('vite').ResolvedConfig | null} */
78
+ let resolved = null;
79
+
80
+ return {
81
+ name: 'tinvar:serializable-props',
82
+ apply: 'build',
83
+ configResolved(config) {
84
+ resolved = config;
85
+ },
86
+ applyToEnvironment(environment) {
87
+ return environment.name === 'rsc';
88
+ },
89
+ async generateBundle() {
90
+ const manager = resolved === null ? undefined : getPluginApi(resolved)?.manager;
91
+ if (manager === undefined) {
92
+ throw new Error('[tinvar] serializableProps() needs @vitejs/plugin-rsc in the same config.');
93
+ }
94
+ const references = new Set(Object.keys(manager.clientReferenceMetaMap));
95
+ const root = resolved?.root ?? process.cwd();
96
+
97
+ /** @type {string[]} */
98
+ const found = [];
99
+ const clients = clientExports(
100
+ references,
101
+ async (specifier, importer) => (await this.resolve(specifier, importer))?.id ?? null,
102
+ );
103
+ for (const id of this.getModuleIds()) {
104
+ const file = fileOf(id);
105
+ if (id.startsWith('\0') || references.has(id) || isDependency(file) || !SOURCE.test(file)) continue;
106
+ if (!existsSync(file)) continue;
107
+
108
+ const source = readFileSync(file, 'utf8');
109
+ for (const violation of await functionProps(source, file, (specifier, name) =>
110
+ clients(specifier, name, id),
111
+ )) {
112
+ found.push(`${relative(root, file)}:${violation.line} ${violation.message}`);
113
+ }
114
+ }
115
+
116
+ if (found.length > 0) {
117
+ throw new UnserializableProp(
118
+ `[tinvar] A server component hands a client component a function, which React refuses as the page renders (NOT_SERIALIZABLE):\n ${found.join('\n ')}\n` +
119
+ 'Hand it data, or an action from @actions/*, and let the client component make the function it needs.',
120
+ );
121
+ }
122
+ },
123
+ };
124
+ }
125
+
126
+ /**
127
+ * Whether a name imported from a module is a client component: the module
128
+ * is a client reference, or re-exports the name from one. Answers are kept
129
+ * for the build, since every page imports the same kit.
130
+ *
131
+ * @param {Set<string>} references the client references' module ids
132
+ * @param {(specifier: string, importer: string) => Promise<string | null>} resolveId
133
+ * @returns {(specifier: string, name: string, importer: string) => Promise<boolean>}
134
+ */
135
+ export function clientExports(references, resolveId) {
136
+ /** @type {Map<string, Promise<boolean>>} */
137
+ const answers = new Map();
138
+
139
+ /** @type {(id: string, name: string, seen: Set<string>) => Promise<boolean>} */
140
+ const exportsClient = (id, name, seen) => {
141
+ const key = `${id}\0${name}`;
142
+ // The path first: a cycle comes back to an answer still being worked
143
+ // out, and awaiting it would await itself.
144
+ if (seen.has(key)) return Promise.resolve(false);
145
+ const known = answers.get(key);
146
+ if (known !== undefined) return known;
147
+ seen.add(key);
148
+
149
+ const answer = (async () => {
150
+ if (references.has(id)) return true;
151
+ const file = fileOf(id);
152
+ if (id.startsWith('\0') || !SOURCE.test(file) || !existsSync(file)) return false;
153
+
154
+ let program;
155
+ try {
156
+ program = parse(readFileSync(file, 'utf8'), file);
157
+ } catch {
158
+ // The bundler read it, under its own options; what this cannot, it
159
+ // leaves to React at render.
160
+ return false;
161
+ }
162
+
163
+ /** @type {Map<string, { from: string, name: string }>} what the module imports by name */
164
+ const imported = new Map();
165
+ for (const node of program.body) {
166
+ if (node.type !== 'ImportDeclaration' || node.importKind === 'type') continue;
167
+ for (const specifier of node.specifiers) {
168
+ if (specifier.type === 'ImportSpecifier') {
169
+ imported.set(specifier.local.name, { from: node.source.value, name: nameOf(specifier.imported) });
170
+ } else if (specifier.type === 'ImportDefaultSpecifier') {
171
+ imported.set(specifier.local.name, { from: node.source.value, name: 'default' });
172
+ }
173
+ }
174
+ }
175
+
176
+ const follow = async (/** @type {string} */ specifier, /** @type {string} */ inner) => {
177
+ const target = await resolveId(specifier, id);
178
+ return target !== null && exportsClient(target, inner, seen);
179
+ };
180
+
181
+ for (const node of program.body) {
182
+ if (node.type === 'ExportNamedDeclaration' && node.exportKind !== 'type') {
183
+ for (const specifier of node.specifiers ?? []) {
184
+ if (nameOf(specifier.exported) !== name) continue;
185
+ const local = nameOf(specifier.local);
186
+ if (node.source) return follow(node.source.value, local);
187
+ const through = imported.get(local);
188
+ return through === undefined ? false : follow(through.from, through.name);
189
+ }
190
+ } else if (node.type === 'ExportDefaultDeclaration' && name === 'default') {
191
+ const value = node.declaration;
192
+ const through = value?.type === 'Identifier' ? imported.get(value.name) : undefined;
193
+ return through === undefined ? false : follow(through.from, through.name);
194
+ } else if (node.type === 'ExportAllDeclaration' && node.exported === null && name !== 'default') {
195
+ if (await follow(node.source.value, name)) return true;
196
+ }
197
+ }
198
+ return false;
199
+ })();
200
+ answers.set(key, answer);
201
+ return answer;
202
+ };
203
+
204
+ return async (specifier, name, importer) => {
205
+ const id = await resolveId(specifier, importer);
206
+ return id !== null && exportsClient(id, name, new Set());
207
+ };
208
+ }
209
+
210
+ /**
211
+ * Every function a module's server components write into a client
212
+ * component's props.
213
+ *
214
+ * @param {string} source the module's own source, as written
215
+ * @param {string} file its path, which says how to parse it
216
+ * @param {(specifier: string, name: string) => Promise<boolean>} isClient whether
217
+ * the name imported from the specifier is a client component
218
+ * @returns {Promise<Array<{ line: number, message: string }>>}
219
+ * @throws {UnserializableProp} for a module that cannot be parsed
220
+ */
221
+ export async function functionProps(source, file, isClient) {
222
+ let program;
223
+ try {
224
+ program = parse(source, file);
225
+ } catch (error) {
226
+ throw new UnserializableProp(
227
+ `[tinvar] ${file} cannot be parsed, so the props it hands client components cannot be checked: ${/** @type {Error} */ (error).message}`,
228
+ );
229
+ }
230
+ // A client module renders in the browser: what it hands its children is
231
+ // never serialized.
232
+ if (program.body.some((node) => node.type === 'ExpressionStatement' && node.directive === 'use client')) {
233
+ return [];
234
+ }
235
+
236
+ /** @type {Map<string, { from: string, name: string | null }>} each value imported: by name, or whole (null) */
237
+ const imported = new Map();
238
+ for (const node of program.body) {
239
+ if (node.type !== 'ImportDeclaration' || node.importKind === 'type') continue;
240
+ for (const specifier of node.specifiers) {
241
+ if (specifier.importKind === 'type') continue;
242
+ imported.set(specifier.local.name, {
243
+ from: node.source.value,
244
+ name:
245
+ specifier.type === 'ImportSpecifier'
246
+ ? nameOf(specifier.imported)
247
+ : specifier.type === 'ImportDefaultSpecifier'
248
+ ? 'default'
249
+ : null,
250
+ });
251
+ }
252
+ }
253
+
254
+ /** @type {Array<{ element: any, tag: string, ancestors: any[], from: string, name: string }>} */
255
+ const elements = [];
256
+ walk(program, [], (node, ancestors) => {
257
+ if (node.type !== 'JSXElement') return;
258
+ const tag = node.openingElement.name;
259
+ let local;
260
+ let name;
261
+ if (tag.type === 'JSXIdentifier') {
262
+ // A lowercase tag is the platform's element, never a component.
263
+ if (!/^[A-Z_$]/.test(tag.name)) return;
264
+ local = tag.name;
265
+ } else if (tag.type === 'JSXMemberExpression' && tag.object.type === 'JSXIdentifier') {
266
+ local = tag.object.name;
267
+ name = tag.property.name;
268
+ } else {
269
+ return;
270
+ }
271
+ const from = imported.get(local);
272
+ if (from === undefined || shadowed(local, ancestors)) return;
273
+ const exported = from.name === null ? name : name === undefined ? from.name : null;
274
+ if (exported === undefined || exported === null) return;
275
+ elements.push({
276
+ element: node,
277
+ tag: name === undefined ? local : `${local}.${name}`,
278
+ ancestors,
279
+ from: from.from,
280
+ name: exported,
281
+ });
282
+ });
283
+
284
+ /** @type {Array<{ line: number, message: string }>} */
285
+ const found = [];
286
+ for (const { element, tag, ancestors, from, name } of elements) {
287
+ /** @type {Array<{ prop: string, node: any }>} */
288
+ const functions = [];
289
+ for (const attribute of element.openingElement.attributes) {
290
+ if (attribute.type !== 'JSXAttribute' || attribute.value?.type !== 'JSXExpressionContainer') continue;
291
+ const fn = functionIn(attribute.value.expression, [...ancestors, element]);
292
+ if (fn !== null) functions.push({ prop: nameOf(attribute.name), node: fn });
293
+ }
294
+ for (const child of element.children) {
295
+ if (child.type !== 'JSXExpressionContainer') continue;
296
+ const fn = functionIn(child.expression, [...ancestors, element]);
297
+ if (fn !== null) functions.push({ prop: 'children', node: fn });
298
+ }
299
+ if (functions.length === 0 || !(await isClient(from, name))) continue;
300
+
301
+ for (const { prop, node } of functions) {
302
+ found.push({
303
+ line: lineOf(source, node.start),
304
+ message: `hands <${tag}>, a client component from '${from}', a function as '${prop}'.`,
305
+ });
306
+ }
307
+ }
308
+ return found;
309
+ }
310
+
311
+ /**
312
+ * The function an expression is, or holds where it is written -- null for
313
+ * one it is not, or cannot be seen to be.
314
+ *
315
+ * @param {any} expression
316
+ * @param {any[]} ancestors the nodes around it, outermost first
317
+ * @returns {any | null}
318
+ */
319
+ function functionIn(expression, ancestors) {
320
+ let node = expression;
321
+ while (node && SAME_VALUE.has(node.type)) node = node.expression;
322
+ if (!node) return null;
323
+
324
+ switch (node.type) {
325
+ case 'ArrowFunctionExpression':
326
+ case 'FunctionExpression':
327
+ case 'ClassExpression':
328
+ return node;
329
+ case 'Identifier': {
330
+ const binding = bindingOf(node.name, ancestors);
331
+ return binding === null ? null : node;
332
+ }
333
+ case 'CallExpression': {
334
+ const callee = node.callee;
335
+ const bound =
336
+ callee.type === 'MemberExpression' &&
337
+ !callee.computed &&
338
+ callee.property.type === 'Identifier' &&
339
+ callee.property.name === 'bind';
340
+ return bound ? node : null;
341
+ }
342
+ case 'ObjectExpression':
343
+ for (const property of node.properties) {
344
+ if (property.type !== 'Property') continue;
345
+ if (property.method || property.kind === 'get' || property.kind === 'set') return property;
346
+ const inner = functionIn(property.value, ancestors);
347
+ if (inner !== null) return inner;
348
+ }
349
+ return null;
350
+ case 'ArrayExpression':
351
+ for (const element of node.elements) {
352
+ const inner =
353
+ element === null || element.type === 'SpreadElement' ? null : functionIn(element, ancestors);
354
+ if (inner !== null) return inner;
355
+ }
356
+ return null;
357
+ case 'ConditionalExpression':
358
+ return functionIn(node.consequent, ancestors) ?? functionIn(node.alternate, ancestors);
359
+ case 'LogicalExpression':
360
+ return functionIn(node.left, ancestors) ?? functionIn(node.right, ancestors);
361
+ default:
362
+ return null;
363
+ }
364
+ }
365
+
366
+ /**
367
+ * The function a name is bound to, in the nearest scope around a node that
368
+ * declares it: a function or class declaration, or a variable initialized
369
+ * with a function or class. Null for anything else -- a parameter, an
370
+ * import, a value -- and for a name nothing here declares.
371
+ *
372
+ * @param {string} name
373
+ * @param {any[]} ancestors outermost first
374
+ * @returns {any | null}
375
+ */
376
+ function bindingOf(name, ancestors) {
377
+ for (let index = ancestors.length - 1; index >= 0; index--) {
378
+ const scope = ancestors[index];
379
+ if (isFunction(scope)) {
380
+ if (scope.params.some((/** @type {any} */ param) => declares(param, name))) return null;
381
+ if (scope.id?.name === name && scope.type !== 'FunctionDeclaration') return scope;
382
+ }
383
+ if (scope.type === 'CatchClause' && scope.param && declares(scope.param, name)) return null;
384
+ if (scope.type === 'ForStatement' || scope.type === 'ForInStatement' || scope.type === 'ForOfStatement') {
385
+ const declaration = scope.init ?? scope.left;
386
+ if (
387
+ declaration?.type === 'VariableDeclaration' &&
388
+ declaration.declarations.some((d) => declares(d.id, name))
389
+ ) {
390
+ return null;
391
+ }
392
+ }
393
+ const body =
394
+ scope.type === 'Program' || scope.type === 'BlockStatement' || scope.type === 'StaticBlock'
395
+ ? scope.body
396
+ : scope.type === 'SwitchCase'
397
+ ? scope.consequent
398
+ : null;
399
+ if (body === null) continue;
400
+
401
+ for (let statement of body) {
402
+ if (
403
+ (statement.type === 'ExportNamedDeclaration' || statement.type === 'ExportDefaultDeclaration') &&
404
+ statement.declaration
405
+ ) {
406
+ statement = statement.declaration;
407
+ }
408
+ if (
409
+ (statement.type === 'FunctionDeclaration' || statement.type === 'ClassDeclaration') &&
410
+ statement.id?.name === name
411
+ ) {
412
+ return statement;
413
+ }
414
+ if (statement.type === 'VariableDeclaration') {
415
+ for (const declarator of statement.declarations) {
416
+ if (!declares(declarator.id, name)) continue;
417
+ if (declarator.id.type !== 'Identifier') return null;
418
+ let init = declarator.init;
419
+ while (init && SAME_VALUE.has(init.type)) init = init.expression;
420
+ return init &&
421
+ (init.type === 'ArrowFunctionExpression' ||
422
+ init.type === 'FunctionExpression' ||
423
+ init.type === 'ClassExpression')
424
+ ? init
425
+ : null;
426
+ }
427
+ }
428
+ if (statement.type === 'ImportDeclaration' && statement.specifiers.some((s) => s.local.name === name)) {
429
+ return null;
430
+ }
431
+ }
432
+ }
433
+ return null;
434
+ }
435
+
436
+ /** Whether a component's local name is declared again between it and the module's imports. */
437
+ function shadowed(name, ancestors) {
438
+ for (const scope of ancestors) {
439
+ if (scope.type === 'Program') continue;
440
+ if (isFunction(scope) && scope.params.some((/** @type {any} */ param) => declares(param, name)))
441
+ return true;
442
+ const body = scope.type === 'BlockStatement' ? scope.body : null;
443
+ if (body === null) continue;
444
+ for (const statement of body) {
445
+ if (
446
+ statement.type === 'VariableDeclaration' &&
447
+ statement.declarations.some((d) => declares(d.id, name))
448
+ ) {
449
+ return true;
450
+ }
451
+ if (
452
+ (statement.type === 'FunctionDeclaration' || statement.type === 'ClassDeclaration') &&
453
+ statement.id?.name === name
454
+ ) {
455
+ return true;
456
+ }
457
+ }
458
+ }
459
+ return false;
460
+ }
461
+
462
+ /** Whether a binding pattern declares a name. */
463
+ function declares(pattern, name) {
464
+ if (!pattern) return false;
465
+ switch (pattern.type) {
466
+ case 'Identifier':
467
+ return pattern.name === name;
468
+ case 'AssignmentPattern':
469
+ return declares(pattern.left, name);
470
+ case 'RestElement':
471
+ return declares(pattern.argument, name);
472
+ case 'ArrayPattern':
473
+ return pattern.elements.some((/** @type {any} */ element) => declares(element, name));
474
+ case 'ObjectPattern':
475
+ return pattern.properties.some((/** @type {any} */ property) =>
476
+ declares(property.type === 'RestElement' ? property : property.value, name),
477
+ );
478
+ case 'TSParameterProperty':
479
+ return declares(pattern.parameter, name);
480
+ default:
481
+ return false;
482
+ }
483
+ }
484
+
485
+ function isFunction(node) {
486
+ return (
487
+ node.type === 'FunctionDeclaration' ||
488
+ node.type === 'FunctionExpression' ||
489
+ node.type === 'ArrowFunctionExpression'
490
+ );
491
+ }
492
+
493
+ /** An identifier's name, or a string literal's value: how an import or export names a binding. */
494
+ function nameOf(node) {
495
+ if (node.type === 'JSXNamespacedName') return `${node.namespace.name}:${node.name.name}`;
496
+ return node.type === 'Identifier' || node.type === 'JSXIdentifier' ? node.name : String(node.value);
497
+ }
498
+
499
+ function lineOf(source, offset) {
500
+ return typeof offset === 'number' ? source.slice(0, offset).split('\n').length : 0;
501
+ }
502
+
503
+ function parse(source, file) {
504
+ const extension = extname(file);
505
+ const lang = extension === '.tsx' ? 'tsx' : /^\.[cm]?ts$/.test(extension) ? 'ts' : 'jsx';
506
+ return parseAst(source, { lang }, file);
507
+ }
508
+
509
+ /** A module id as the file it was read from: no query, no hash. */
510
+ function fileOf(id) {
511
+ return id.replace(/[?#].*$/, '');
512
+ }
513
+
514
+ /** Whether a module's file is a dependency's: anywhere under a node_modules directory. */
515
+ function isDependency(file) {
516
+ return file.split(/[\\/]/).includes('node_modules');
517
+ }
518
+
519
+ /**
520
+ * Depth-first over every node, with the nodes around each, outermost first.
521
+ *
522
+ * @param {any} node
523
+ * @param {any[]} ancestors
524
+ * @param {(node: any, ancestors: any[]) => void} visit
525
+ */
526
+ function walk(node, ancestors, visit) {
527
+ if (node === null || typeof node !== 'object') return;
528
+ if (Array.isArray(node)) {
529
+ for (const child of node) walk(child, ancestors, visit);
530
+ return;
531
+ }
532
+ if (typeof node.type !== 'string') return;
533
+ visit(node, ancestors);
534
+ const inner = [...ancestors, node];
535
+ for (const key of Object.keys(node)) {
536
+ if (key === 'type' || key === 'start' || key === 'end' || key === 'range' || key === 'loc') continue;
537
+ const child = node[key];
538
+ if (child !== null && typeof child === 'object') walk(child, inner, visit);
539
+ }
540
+ }