@avijs/compiler 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/client.ts ADDED
@@ -0,0 +1,519 @@
1
+ import path from 'node:path';
2
+ import ts from 'typescript';
3
+ import { readProgram, unwrap, type Diagnostic } from './check.js';
4
+
5
+ export interface ClientRoute {
6
+ readonly method: string;
7
+ readonly path: string;
8
+ /** The type the generated `Api` gives this route. */
9
+ readonly type: string;
10
+ }
11
+
12
+ export interface GeneratedClient {
13
+ /** The generated file's contents. */
14
+ readonly source: string;
15
+ readonly routes: readonly ClientRoute[];
16
+ readonly diagnostics: readonly Diagnostic[];
17
+ }
18
+
19
+ export interface GenerateClientOptions {
20
+ /** Path to tsconfig.json. */
21
+ readonly project: string;
22
+ /** Where the file will be written; imports are relative to it. */
23
+ readonly out: string;
24
+ /** `file.ts#Export` of the root module. Default: found from `AviFactory.create` or `register`. */
25
+ readonly root?: string;
26
+ }
27
+
28
+ const ROUTE_DECORATORS = new Set(['Get', 'Post', 'Put', 'Patch', 'Delete', 'Head', 'Options']);
29
+
30
+ /**
31
+ * Reads an app's modules and controllers statically and generates the `Api` type for
32
+ * `@avijs/client`. The type references the app's own classes and controllers, so it stays exact
33
+ * and only changes when routes do.
34
+ */
35
+ export function generateClient(options: GenerateClientOptions): GeneratedClient {
36
+ const { program, roots } = readProgram(options.project);
37
+ const checker = program.getTypeChecker();
38
+ const out = path.resolve(options.out);
39
+ const generator = new Generator(checker, roots, out);
40
+
41
+ const appRoots = options.root
42
+ ? generator.namedRoot(program, options.root)
43
+ : generator.findRoots(program);
44
+ for (const root of appRoots) generator.module(root);
45
+
46
+ return generator.finish();
47
+ }
48
+
49
+ interface Found {
50
+ readonly method: string;
51
+ readonly path: string;
52
+ readonly type: string;
53
+ }
54
+
55
+ class Generator {
56
+ readonly #routes = new Map<string, Found>();
57
+ readonly #diagnostics: Diagnostic[] = [];
58
+ readonly #imports = new Map<string, { name: string; file: string; alias: string }>();
59
+ readonly #visited = new Set<ts.Node>();
60
+
61
+ constructor(
62
+ private readonly checker: ts.TypeChecker,
63
+ private readonly roots: ReadonlySet<string>,
64
+ private readonly out: string,
65
+ ) {}
66
+
67
+ /** Module expressions passed to `AviFactory.create` or `app.register` outside tests. */
68
+ findRoots(program: ts.Program): ts.Expression[] {
69
+ const found: ts.Expression[] = [];
70
+ let first: ts.SourceFile | undefined;
71
+ for (const source of program.getSourceFiles()) {
72
+ if (!this.#inProject(source) || /\.(test|spec)\.[cm]?tsx?$/.test(source.fileName)) continue;
73
+ first ??= source;
74
+ const visit = (node: ts.Node): void => {
75
+ if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression)) {
76
+ const name = node.expression.name.text;
77
+ const target = node.expression.expression;
78
+ const arg = node.arguments[0];
79
+ if (arg && name === 'create' && this.#symbolName(target) === 'AviFactory') {
80
+ found.push(arg);
81
+ } else if (arg && name === 'register' && this.#typeName(target) === 'AviApp') {
82
+ found.push(arg);
83
+ }
84
+ }
85
+ ts.forEachChild(node, visit);
86
+ };
87
+ visit(source);
88
+ }
89
+ if (found.length === 0) {
90
+ this.#report(
91
+ first,
92
+ undefined,
93
+ 'error',
94
+ 'AVI101',
95
+ 'No app found: pass --root file.ts#AppModule, or call AviFactory.create or app.register',
96
+ );
97
+ }
98
+ return found;
99
+ }
100
+
101
+ namedRoot(program: ts.Program, root: string): ts.Expression[] {
102
+ const [file = '', name = 'AppModule'] = root.split('#');
103
+ const source = program.getSourceFile(path.resolve(file));
104
+ const symbol = source && this.checker.getSymbolAtLocation(source);
105
+ const exported = symbol && this.checker.getExportsOfModule(symbol).find((s) => s.name === name);
106
+ const declaration = exported && this.#declaration(exported);
107
+ if (!declaration) {
108
+ this.#report(source, undefined, 'error', 'AVI101', `Cannot find export ${name} in ${file}`);
109
+ return [];
110
+ }
111
+ const expression = ts.isVariableDeclaration(declaration)
112
+ ? declaration.initializer
113
+ : ts.isClassDeclaration(declaration) && declaration.name
114
+ ? declaration.name
115
+ : undefined;
116
+ return expression ? [expression] : [];
117
+ }
118
+
119
+ /** Walks a module: `@Module` class, `defineModule(...)`, or a module options object. */
120
+ module(expression: ts.Expression): void {
121
+ const options = this.#moduleOptions(expression);
122
+ if (!options || this.#visited.has(options)) return;
123
+ this.#visited.add(options);
124
+
125
+ const prefix = this.#stringProperty(options, 'prefix') ?? '/';
126
+ for (const ref of this.#arrayProperty(options, 'imports')) this.module(ref);
127
+ for (const ref of this.#arrayProperty(options, 'controllers')) this.#controller(ref, prefix);
128
+ }
129
+
130
+ finish(): GeneratedClient {
131
+ const routes = [...this.#routes.values()].sort((a, b) =>
132
+ a.path === b.path ? a.method.localeCompare(b.method) : a.path.localeCompare(b.path),
133
+ );
134
+ const used = new Set(routes.map((r) => (r.type.startsWith('Method') ? 'Method' : 'Route')));
135
+ const helpers = [
136
+ used.has('Method') && 'MethodEndpoint',
137
+ used.has('Route') && 'RouteEndpoint',
138
+ ].filter(Boolean);
139
+ const imports = [...this.#imports.values()]
140
+ .sort((a, b) => a.file.localeCompare(b.file) || a.alias.localeCompare(b.alias))
141
+ .map(({ name, file, alias }) => {
142
+ const named = name === alias ? name : `${name} as ${alias}`;
143
+ return `import type { ${named} } from '${this.#specifier(file)}';`;
144
+ });
145
+
146
+ const lines = [
147
+ '// Generated by `avi client`. Do not edit: run it again after changing routes.',
148
+ ...(helpers.length > 0 ? [`import type { ${helpers.join(', ')} } from '@avijs/core';`] : []),
149
+ ...imports,
150
+ '',
151
+ 'export interface Api {',
152
+ ...routes.map((r) => ` '${r.method} ${r.path}': ${r.type};`),
153
+ '}',
154
+ '',
155
+ ];
156
+ const diagnostics = this.#diagnostics.sort((a, b) =>
157
+ a.file === b.file ? a.line - b.line || a.column - b.column : a.file.localeCompare(b.file),
158
+ );
159
+ return { source: lines.join('\n'), routes, diagnostics };
160
+ }
161
+
162
+ #moduleOptions(expression: ts.Expression): ts.ObjectLiteralExpression | undefined {
163
+ const node = unwrap(expression);
164
+ if (!node) return undefined;
165
+ if (ts.isObjectLiteralExpression(node)) return node;
166
+ if (ts.isCallExpression(node) && this.#symbolName(node.expression) === 'defineModule') {
167
+ const arg = unwrap(node.arguments[0]);
168
+ return arg && ts.isObjectLiteralExpression(arg) ? arg : undefined;
169
+ }
170
+ if (ts.isIdentifier(node) || ts.isPropertyAccessExpression(node)) {
171
+ const declaration = this.#declarationOf(node);
172
+ // Modules from libraries, such as a config token's module, have no app routes.
173
+ if (!declaration || !this.#inProject(declaration.getSourceFile())) return undefined;
174
+ if (ts.isClassDeclaration(declaration)) {
175
+ const options = this.#decoratorArgs(declaration, (name) => name === 'Module')?.[0];
176
+ const object = unwrap(options);
177
+ if (object && ts.isObjectLiteralExpression(object)) return object;
178
+ if (!options) return undefined;
179
+ } else if (ts.isVariableDeclaration(declaration) && declaration.initializer) {
180
+ return this.#moduleOptions(declaration.initializer);
181
+ }
182
+ }
183
+ this.#report(
184
+ node.getSourceFile(),
185
+ node,
186
+ 'warning',
187
+ 'AVI104',
188
+ `Cannot read module ${node.getText()} statically; its routes are left out`,
189
+ );
190
+ return undefined;
191
+ }
192
+
193
+ #controller(expression: ts.Expression, modulePrefix: string): void {
194
+ const node = unwrap(expression);
195
+ if (!node) return;
196
+ const declaration =
197
+ ts.isIdentifier(node) || ts.isPropertyAccessExpression(node)
198
+ ? this.#declarationOf(node)
199
+ : undefined;
200
+
201
+ if (declaration && ts.isClassDeclaration(declaration) && declaration.name) {
202
+ this.#classController(declaration, declaration.name.text, modulePrefix);
203
+ return;
204
+ }
205
+ const call =
206
+ declaration && ts.isVariableDeclaration(declaration) ? unwrap(declaration.initializer) : node;
207
+ if (
208
+ call &&
209
+ ts.isCallExpression(call) &&
210
+ this.#symbolName(call.expression) === 'defineController'
211
+ ) {
212
+ if (!declaration || !ts.isVariableDeclaration(declaration)) {
213
+ this.#report(
214
+ node.getSourceFile(),
215
+ node,
216
+ 'warning',
217
+ 'AVI102',
218
+ 'Move this controller into an exported const so the client can reference it',
219
+ );
220
+ return;
221
+ }
222
+ this.#definedController(declaration, call, modulePrefix);
223
+ return;
224
+ }
225
+ this.#report(
226
+ node.getSourceFile(),
227
+ node,
228
+ 'warning',
229
+ 'AVI103',
230
+ `Cannot read controller ${node.getText()} statically; its routes are left out`,
231
+ );
232
+ }
233
+
234
+ #classController(node: ts.ClassDeclaration, name: string, modulePrefix: string): void {
235
+ const args = this.#decoratorArgs(node, (n) => n === 'Controller');
236
+ if (!args) return;
237
+ const prefix = args[0] ? this.#literal(args[0]) : '/';
238
+ if (prefix === undefined) return;
239
+ const alias = this.#import(node, name);
240
+ if (!alias) return;
241
+ const base = joinPath(modulePrefix, prefix);
242
+
243
+ for (const member of node.members) {
244
+ if (!ts.isMethodDeclaration(member) || !ts.isIdentifier(member.name)) continue;
245
+ for (const decorator of ts.getDecorators(member) ?? []) {
246
+ const call = decorator.expression;
247
+ if (!ts.isCallExpression(call)) continue;
248
+ const method = this.#symbolName(call.expression);
249
+ if (!method || !ROUTE_DECORATORS.has(method)) continue;
250
+ const routePath = call.arguments[0] ? this.#literal(call.arguments[0]) : '';
251
+ if (routePath === undefined) continue;
252
+ this.#checkSchemaVisible(call, member);
253
+ const full = joinPath(base, routePath);
254
+ this.#add(
255
+ method.toUpperCase(),
256
+ full,
257
+ `MethodEndpoint<'${full}', ${alias}['${member.name.text}']>`,
258
+ call,
259
+ );
260
+ }
261
+ }
262
+ }
263
+
264
+ #definedController(
265
+ declaration: ts.VariableDeclaration,
266
+ call: ts.CallExpression,
267
+ modulePrefix: string,
268
+ ): void {
269
+ const options = unwrap(call.arguments[0]);
270
+ if (!options || !ts.isObjectLiteralExpression(options)) {
271
+ this.#dynamic(call, 'its options are not an object literal');
272
+ return;
273
+ }
274
+ const prefix = this.#stringProperty(options, 'prefix') ?? '/';
275
+ const routes = this.#routeList(options);
276
+ if (!routes || !ts.isIdentifier(declaration.name)) return;
277
+ const alias = this.#import(declaration, declaration.name.text);
278
+ if (!alias) return;
279
+ const base = joinPath(modulePrefix, prefix);
280
+
281
+ routes.elements.forEach((element, index) => {
282
+ let object = unwrap(element);
283
+ if (
284
+ object &&
285
+ ts.isCallExpression(object) &&
286
+ this.#symbolName(object.expression) === 'route'
287
+ ) {
288
+ object = unwrap(object.arguments[0]);
289
+ }
290
+ if (!object || !ts.isObjectLiteralExpression(object)) {
291
+ this.#dynamic(element, 'this route is not an object literal or route({...})');
292
+ return;
293
+ }
294
+ const method = this.#stringProperty(object, 'method');
295
+ const routePath = this.#stringProperty(object, 'path') ?? '';
296
+ if (method === undefined) return;
297
+ const full = joinPath(base, routePath);
298
+ this.#add(
299
+ method,
300
+ full,
301
+ `RouteEndpoint<'${full}', typeof ${alias}, ${String(index)}>`,
302
+ element,
303
+ );
304
+ });
305
+ }
306
+
307
+ /** The routes array: a literal, or a factory whose body returns one. */
308
+ #routeList(options: ts.ObjectLiteralExpression): ts.ArrayLiteralExpression | undefined {
309
+ const value = unwrap(this.#property(options, 'routes'));
310
+ let list: ts.Expression | undefined = value;
311
+ if (value && (ts.isArrowFunction(value) || ts.isFunctionExpression(value))) {
312
+ if (ts.isBlock(value.body)) {
313
+ const returns = value.body.statements.filter(ts.isReturnStatement);
314
+ list = returns.length === 1 ? returns[0]?.expression : undefined;
315
+ } else {
316
+ list = value.body;
317
+ }
318
+ list = unwrap(list);
319
+ }
320
+ if (!list || !ts.isArrayLiteralExpression(list) || list.elements.some(ts.isSpreadElement)) {
321
+ this.#dynamic(value ?? options, 'its routes are not a literal array');
322
+ return undefined;
323
+ }
324
+ return list;
325
+ }
326
+
327
+ /** Warns when a decorator schema cannot reach the client because ctx is not a RouteContext. */
328
+ #checkSchemaVisible(call: ts.CallExpression, method: ts.MethodDeclaration): void {
329
+ const options = unwrap(call.arguments[1]);
330
+ const schema =
331
+ options && ts.isObjectLiteralExpression(options) && this.#property(options, 'schema');
332
+ if (!schema) return;
333
+ // The response type comes from the handler anyway; only inputs need the schema.
334
+ const fields = this.checker.getTypeAtLocation(schema);
335
+ if (!['params', 'query', 'body'].some((name) => fields.getProperty(name))) return;
336
+ const param = method.parameters[0];
337
+ const type = param && this.checker.getTypeAtLocation(param);
338
+ if (type?.getProperty('~schema')) return;
339
+ this.#report(
340
+ method.getSourceFile(),
341
+ method.name,
342
+ 'warning',
343
+ 'AVI105',
344
+ `Type ctx as RouteContext<typeof schema> so the client sees the schema of ${method.name.getText()}`,
345
+ );
346
+ }
347
+
348
+ #add(method: string, full: string, type: string, at: ts.Node): void {
349
+ const key = `${method} ${full}`;
350
+ if (this.#routes.has(key)) {
351
+ this.#report(at.getSourceFile(), at, 'warning', 'AVI106', `Duplicate route ${key}`);
352
+ return;
353
+ }
354
+ this.#routes.set(key, { method, path: full, type });
355
+ }
356
+
357
+ /** Imports a declaration's name into the generated file, or reports why it cannot. */
358
+ #import(node: ts.ClassDeclaration | ts.VariableDeclaration, name: string): string | undefined {
359
+ const source = node.getSourceFile();
360
+ const symbol = this.checker.getSymbolAtLocation(source);
361
+ const exported =
362
+ symbol &&
363
+ this.checker
364
+ .getExportsOfModule(symbol)
365
+ .some((s) => s.name === name && this.#declaration(s) === node);
366
+ if (!exported) {
367
+ this.#report(
368
+ source,
369
+ node,
370
+ 'warning',
371
+ 'AVI102',
372
+ `Export ${name} so the client can reference it; its routes are left out`,
373
+ );
374
+ return undefined;
375
+ }
376
+ const key = `${source.fileName}#${name}`;
377
+ const existing = this.#imports.get(key);
378
+ if (existing) return existing.alias;
379
+ const taken = new Set([...this.#imports.values()].map((i) => i.alias));
380
+ let alias = name;
381
+ for (let n = 2; taken.has(alias); n++) alias = `${name}_${String(n)}`;
382
+ this.#imports.set(key, { name, file: source.fileName, alias });
383
+ return alias;
384
+ }
385
+
386
+ #specifier(file: string): string {
387
+ let relative = path.relative(path.dirname(this.out), file).split(path.sep).join('/');
388
+ if (!relative.startsWith('.')) relative = `./${relative}`;
389
+ return relative.replace(/\.(m|c)?tsx?$/, (_, kind: string | undefined) => `.${kind ?? ''}js`);
390
+ }
391
+
392
+ #decoratorArgs(
393
+ node: ts.ClassDeclaration,
394
+ match: (name: string) => boolean,
395
+ ): readonly ts.Expression[] | undefined {
396
+ for (const decorator of ts.getDecorators(node) ?? []) {
397
+ const call = decorator.expression;
398
+ if (ts.isCallExpression(call)) {
399
+ const name = this.#symbolName(call.expression);
400
+ if (name && match(name)) return call.arguments;
401
+ }
402
+ }
403
+ return undefined;
404
+ }
405
+
406
+ #property(object: ts.ObjectLiteralExpression, name: string): ts.Expression | undefined {
407
+ for (const property of object.properties) {
408
+ if (ts.isPropertyAssignment(property) && property.name.getText() === name) {
409
+ return property.initializer;
410
+ }
411
+ if (ts.isShorthandPropertyAssignment(property) && property.name.text === name) {
412
+ return property.name;
413
+ }
414
+ }
415
+ return undefined;
416
+ }
417
+
418
+ #stringProperty(object: ts.ObjectLiteralExpression, name: string): string | undefined {
419
+ const value = this.#property(object, name);
420
+ return value ? this.#literal(value) : undefined;
421
+ }
422
+
423
+ #arrayProperty(object: ts.ObjectLiteralExpression, name: string): readonly ts.Expression[] {
424
+ const value = this.#property(object, name);
425
+ if (!value) return [];
426
+ const list = this.#resolveValue(value);
427
+ if (!list || !ts.isArrayLiteralExpression(list)) {
428
+ this.#dynamic(value, `${name} is not a literal array`);
429
+ return [];
430
+ }
431
+ return list.elements.filter((element) => {
432
+ if (!ts.isSpreadElement(element)) return true;
433
+ this.#dynamic(element, `spreads in ${name} cannot be followed`);
434
+ return false;
435
+ });
436
+ }
437
+
438
+ /** A string literal, directly or through a const. */
439
+ #literal(expression: ts.Expression): string | undefined {
440
+ const value = this.#resolveValue(expression);
441
+ if (value && ts.isStringLiteralLike(value)) return value.text;
442
+ this.#dynamic(expression, `${expression.getText()} is not a string literal`);
443
+ return undefined;
444
+ }
445
+
446
+ /** Follows a const identifier to its initializer. */
447
+ #resolveValue(expression: ts.Expression): ts.Expression | undefined {
448
+ const node = unwrap(expression);
449
+ if (node && ts.isIdentifier(node)) {
450
+ const declaration = this.#declarationOf(node);
451
+ if (
452
+ declaration &&
453
+ ts.isVariableDeclaration(declaration) &&
454
+ declaration.initializer &&
455
+ (ts.getCombinedNodeFlags(declaration) & ts.NodeFlags.Const) !== 0
456
+ ) {
457
+ return unwrap(declaration.initializer);
458
+ }
459
+ }
460
+ return node;
461
+ }
462
+
463
+ #dynamic(at: ts.Node, why: string): void {
464
+ this.#report(at.getSourceFile(), at, 'warning', 'AVI103', `Cannot read routes: ${why}`);
465
+ }
466
+
467
+ #declarationOf(node: ts.Expression): ts.Declaration | undefined {
468
+ const symbol = this.checker.getSymbolAtLocation(node);
469
+ return symbol && this.#declaration(symbol);
470
+ }
471
+
472
+ #declaration(symbol: ts.Symbol): ts.Declaration | undefined {
473
+ const target =
474
+ symbol.flags & ts.SymbolFlags.Alias ? this.checker.getAliasedSymbol(symbol) : symbol;
475
+ return target.valueDeclaration ?? target.declarations?.[0];
476
+ }
477
+
478
+ #symbolName(node: ts.Expression): string | undefined {
479
+ const symbol = this.checker.getSymbolAtLocation(node);
480
+ if (!symbol) return undefined;
481
+ const target =
482
+ symbol.flags & ts.SymbolFlags.Alias ? this.checker.getAliasedSymbol(symbol) : symbol;
483
+ return target.name;
484
+ }
485
+
486
+ #typeName(node: ts.Expression): string | undefined {
487
+ const type = this.checker.getTypeAtLocation(node);
488
+ return (type.aliasSymbol ?? type.getSymbol())?.name;
489
+ }
490
+
491
+ #inProject(source: ts.SourceFile): boolean {
492
+ return !source.isDeclarationFile && this.roots.has(path.resolve(source.fileName));
493
+ }
494
+
495
+ #report(
496
+ source: ts.SourceFile | undefined,
497
+ at: ts.Node | undefined,
498
+ severity: Diagnostic['severity'],
499
+ code: string,
500
+ message: string,
501
+ ): void {
502
+ const position =
503
+ source && at ? source.getLineAndCharacterOfPosition(at.getStart(source)) : undefined;
504
+ this.#diagnostics.push({
505
+ file: source?.fileName ?? '',
506
+ line: (position?.line ?? 0) + 1,
507
+ column: (position?.character ?? 0) + 1,
508
+ severity,
509
+ code,
510
+ message,
511
+ });
512
+ }
513
+ }
514
+
515
+ // Same as `joinPath` in @avijs/core.
516
+ function joinPath(prefix: string, routePath: string): string {
517
+ const joined = `${prefix}/${routePath}`.replace(/\/{2,}/g, '/');
518
+ return joined.length > 1 && joined.endsWith('/') ? joined.slice(0, -1) : joined;
519
+ }
package/src/index.ts CHANGED
@@ -2,3 +2,5 @@ export { checkInjectLists } from './check.js';
2
2
  export type { Diagnostic } from './check.js';
3
3
  export { main } from './cli.js';
4
4
  export { serializeManifest, writeManifest } from './manifest.js';
5
+ export { generateClient } from './client.js';
6
+ export type { ClientRoute, GenerateClientOptions, GeneratedClient } from './client.js';