@vercube/scan 1.1.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 ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2025-present - Vercube
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,46 @@
1
+ # @vercube/scan
2
+
3
+ Shared source-scanning and AST-extraction utilities for the [Vercube](https://github.com/vercube/vercube) framework. It discovers `@Controller` routes, `@Injectable` services and `BaseMiddleware` subclasses by parsing TypeScript/JavaScript with [`oxc-parser`](https://www.npmjs.com/package/oxc-parser).
4
+
5
+ This package is the single source of truth used by both the [`@vercube/nitro`](../nitro) and [`@vercube/vite`](../vite) integrations. You usually don't depend on it directly.
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ pnpm add @vercube/scan
11
+ ```
12
+
13
+ ## API
14
+
15
+ ### Pure extractors
16
+
17
+ Operate on a source string and return discovered classes (with placeholder import specifiers):
18
+
19
+ ```ts
20
+ import { extractRoutes, extractServices, extractMiddlewares } from '@vercube/scan';
21
+
22
+ extractRoutes(code); // RouteInfo[]
23
+ extractServices(code); // ServiceInfo[]
24
+ extractMiddlewares(code); // MiddlewareInfo[]
25
+ ```
26
+
27
+ ### Scanning
28
+
29
+ `scanFiles` / `scanDir` walk directories (via [`tinyglobby`](https://www.npmjs.com/package/tinyglobby)); the higher-level helpers read files, extract, and resolve each import to its real path:
30
+
31
+ ```ts
32
+ import { getRoutes, getServices, getMiddlewares, scanProject, scanSource } from '@vercube/scan';
33
+
34
+ // Nitro-style: dedicated api/routes/services subdirectories
35
+ await scanProject({ baseDirs: ['/abs/src'] });
36
+
37
+ // Vercube-style: walk the whole tree, classes can live anywhere
38
+ await scanSource({ dirs: ['/abs/src'] });
39
+ // → { routes, services, middlewares } with imports resolved to real paths
40
+ ```
41
+
42
+ `scanSource` reads each file once and applies all three extractors, deduplicating services that are already registered as routes.
43
+
44
+ ## License
45
+
46
+ MIT
@@ -0,0 +1,338 @@
1
+ //#region src/Ast.d.ts
2
+ /**
3
+ * Unwraps export declarations to retrieve the underlying class node,
4
+ * along with metadata about the export style.
5
+ *
6
+ * Handles the following AST patterns:
7
+ * - `ExportDefaultDeclaration` wrapping a `ClassDeclaration` → `isDefault: true`
8
+ * - `ExportNamedDeclaration` wrapping a `ClassDeclaration` → `isDefault: false`
9
+ * - Direct `ClassDeclaration` or `Class` nodes → `isDefault: false`
10
+ *
11
+ * @param node - An AST node from the program body to inspect.
12
+ * @returns An object containing the class AST node and whether it is a default export, or `null` if not a class.
13
+ */
14
+ declare function getClassNode(node: any): {
15
+ classNode: any;
16
+ isDefault: boolean;
17
+ } | null;
18
+ /**
19
+ * Builds an import statement for a class, choosing default vs named syntax.
20
+ *
21
+ * @param className - The class identifier to import.
22
+ * @param source - The module specifier to import from.
23
+ * @param isDefault - Whether the class is a default export.
24
+ * @returns The generated import statement string.
25
+ */
26
+ declare function buildImport(className: string, source: string, isDefault: boolean): string;
27
+ /**
28
+ * Searches a list of decorators for a `CallExpression` matching the given name
29
+ * and extracts its first string argument.
30
+ *
31
+ * Used to retrieve the path argument from decorators like `@Controller('/api/foo')`.
32
+ * If the decorator is found but has no string argument, an empty string is returned.
33
+ * If no matching decorator is found, `null` is returned.
34
+ *
35
+ * @param decorators - The array of decorator AST nodes to search, or `undefined` if none exist.
36
+ * @param name - The decorator function name to match against (e.g. 'Controller').
37
+ * @returns The string argument value, an empty string if no argument is provided, or `null` if the decorator is not found.
38
+ */
39
+ declare function extractDecoratorArg(decorators: any[] | undefined, name: string): string | null;
40
+ /**
41
+ * Returns true if the given decorators array contains a decorator matching `name`.
42
+ * Supports both `@Name` identifier and `@Name()` call expression styles.
43
+ *
44
+ * @param decorators - The array of decorator AST nodes to search.
45
+ * @param name - The decorator name to match.
46
+ * @returns Whether a matching decorator exists.
47
+ */
48
+ declare function hasDecorator(decorators: any[] | undefined, name: string): boolean;
49
+ /**
50
+ * Returns true if the class node extends a superclass with the given name.
51
+ *
52
+ * @param classNode - The class AST node to inspect.
53
+ * @param name - The expected superclass identifier name.
54
+ * @returns Whether the class extends the named superclass.
55
+ */
56
+ declare function extendsSuperClass(classNode: any, name: string): boolean;
57
+ /**
58
+ * Normalizes a route path by removing duplicate slashes and ensuring
59
+ * the path starts with a single leading slash.
60
+ *
61
+ * @param path - The raw concatenated path string to normalize.
62
+ * @returns A cleaned path string with a leading slash and no duplicate separators.
63
+ */
64
+ declare function normalizePath(path: string): string;
65
+ /**
66
+ * Extracts named route parameters from a path string.
67
+ *
68
+ * Scans the path for segments prefixed with ':' and returns an array of
69
+ * parameter names with the ':' prefix stripped.
70
+ *
71
+ * @param route - The normalized route path to scan for parameters.
72
+ * @returns An array of parameter name strings, empty if none are found.
73
+ */
74
+ declare function extractParams(route: string): string[];
75
+ //#endregion
76
+ //#region src/Types.d.ts
77
+ /**
78
+ * Basic information about a scanned source file.
79
+ */
80
+ type FileInfo = {
81
+ /** The path of the file relative to the directory it was scanned from. */path: string; /** The absolute path of the file on disk. */
82
+ fullPath: string;
83
+ };
84
+ /**
85
+ * Represents a single extracted route from a controller class.
86
+ */
87
+ interface RouteInfo extends FileInfo {
88
+ /** The import statement required to load the controller class. */
89
+ import: string;
90
+ /** The name of the import statement. */
91
+ importClassName: string;
92
+ /** The full normalized route path, including the base controller path and method path. */
93
+ route: string;
94
+ /** The HTTP method in uppercase (e.g. 'GET', 'POST', 'PUT'). */
95
+ method: string;
96
+ /** Array of route parameter names extracted from path segments prefixed with ':'. */
97
+ params: string[];
98
+ }
99
+ /**
100
+ * Represents a single injectable service class discovered during scanning.
101
+ */
102
+ interface ServiceInfo extends FileInfo {
103
+ /** The import statement required to load the service class. */
104
+ import: string;
105
+ /** The name of the imported class. */
106
+ importClassName: string;
107
+ }
108
+ /**
109
+ * Represents a single middleware class discovered during scanning.
110
+ */
111
+ interface MiddlewareInfo extends FileInfo {
112
+ /** The import statement required to load the middleware class. */
113
+ import: string;
114
+ /** The name of the imported class. */
115
+ importClassName: string;
116
+ }
117
+ /**
118
+ * Minimal logger interface used to report non-fatal scanning issues.
119
+ */
120
+ interface ScanLogger {
121
+ warn(message: string): void;
122
+ }
123
+ //#endregion
124
+ //#region src/Extract.d.ts
125
+ /**
126
+ * Placeholder module specifier embedded in generated route imports.
127
+ * Callers resolve it to the real file path once known (see {@link Transform}).
128
+ */
129
+ declare const IMPORT_SOURCE = "#internal/vercube-route-plugin";
130
+ /**
131
+ * Placeholder module specifier embedded in generated service imports.
132
+ */
133
+ declare const SERVICE_IMPORT_SOURCE = "#internal/vercube-service-source";
134
+ /**
135
+ * Placeholder module specifier embedded in generated middleware imports.
136
+ */
137
+ declare const MIDDLEWARE_IMPORT_SOURCE = "#internal/vercube-middleware-source";
138
+ /**
139
+ * Extracts all route definitions from the given TypeScript/JavaScript source code.
140
+ *
141
+ * Parses the source using `oxc-parser` and traverses the resulting AST to find
142
+ * classes decorated with `@Controller(path)`. For each such class, it inspects
143
+ * method definitions for HTTP method decorators (`@Get`, `@Post`, `@Put`, `@Delete`,
144
+ * `@Patch`, `@Options`, `@Head`) and constructs full route paths by concatenating
145
+ * the controller base path with the method-level path.
146
+ *
147
+ * @param code - The raw TypeScript or JavaScript source code string to analyze.
148
+ * @returns An array of {@link RouteInfo} objects representing all discovered routes.
149
+ */
150
+ declare function extractRoutes(code: string): RouteInfo[];
151
+ /**
152
+ * Extracts every `@Controller`-decorated class from the given source code,
153
+ * regardless of whether it declares HTTP method routes.
154
+ *
155
+ * Unlike {@link extractRoutes} (which emits one entry per HTTP route method),
156
+ * this returns one entry per controller class — so controllers that only carry
157
+ * non-HTTP handlers (e.g. WebSocket `@Message` methods) are still discovered for
158
+ * binding into the DI container.
159
+ *
160
+ * @param code - The raw TypeScript or JavaScript source code string to analyze.
161
+ * @returns An array of {@link ServiceInfo} objects for all discovered controller classes.
162
+ */
163
+ declare function extractControllers(code: string): ServiceInfo[];
164
+ /**
165
+ * Extracts all `@Injectable`-decorated class definitions from the given source code.
166
+ *
167
+ * @param code - The raw TypeScript or JavaScript source code string to analyze.
168
+ * @returns An array of {@link ServiceInfo} objects for all discovered injectable classes.
169
+ */
170
+ declare function extractServices(code: string): ServiceInfo[];
171
+ /**
172
+ * Extracts all class definitions that extend `BaseMiddleware` from the given source code.
173
+ *
174
+ * @param code - The raw TypeScript or JavaScript source code string to analyze.
175
+ * @returns An array of {@link MiddlewareInfo} objects for all discovered middleware classes.
176
+ */
177
+ declare function extractMiddlewares(code: string): MiddlewareInfo[];
178
+ //#endregion
179
+ //#region src/Project.d.ts
180
+ /**
181
+ * Options describing where to scan for controllers, services and middleware.
182
+ *
183
+ * `baseDirs` are the project source roots (e.g. `src/`). The remaining options
184
+ * name the subdirectories scanned within each base root.
185
+ */
186
+ interface ScanProjectOptions {
187
+ /** Project source roots to scan within. */
188
+ baseDirs: string[];
189
+ /** Subdirectory holding API controllers. Defaults to `api`. */
190
+ apiDir?: string;
191
+ /** Subdirectory holding route controllers. Defaults to `routes`. */
192
+ routesDir?: string;
193
+ /** Subdirectories scanned for `@Injectable` services. Defaults to `['api', 'routes', 'services', 'repositories']`. */
194
+ serviceDirs?: string[];
195
+ /** Subdirectory holding middleware classes. Defaults to `middleware`. */
196
+ middlewareDir?: string;
197
+ /** Optional logger forwarded to the underlying scanner. */
198
+ logger?: ScanLogger;
199
+ }
200
+ /**
201
+ * Scans the API and routes directories for controllers and returns every route
202
+ * with its `import` resolved to the controller's real file path.
203
+ *
204
+ * @param options - Where to scan. See {@link ScanProjectOptions}.
205
+ * @returns The discovered routes with resolved imports.
206
+ */
207
+ declare function getRoutes(options: ScanProjectOptions): Promise<RouteInfo[]>;
208
+ /**
209
+ * Scans the configured service directories for `@Injectable` classes and returns
210
+ * every service with its `import` resolved to the class's real file path.
211
+ *
212
+ * @param options - Where to scan. See {@link ScanProjectOptions}.
213
+ * @returns The discovered services with resolved imports.
214
+ */
215
+ declare function getServices(options: ScanProjectOptions): Promise<ServiceInfo[]>;
216
+ /**
217
+ * Scans the middleware directory for `BaseMiddleware` subclasses and returns
218
+ * every middleware with its `import` resolved to the class's real file path.
219
+ *
220
+ * @param options - Where to scan. See {@link ScanProjectOptions}.
221
+ * @returns The discovered middleware with resolved imports.
222
+ */
223
+ declare function getMiddlewares(options: ScanProjectOptions): Promise<MiddlewareInfo[]>;
224
+ /**
225
+ * Convenience wrapper that scans routes, services and middleware in one call.
226
+ *
227
+ * Services whose class name is already registered as a route are filtered out
228
+ * so a controller is never imported twice.
229
+ *
230
+ * @param options - Where to scan. See {@link ScanProjectOptions}.
231
+ * @returns The discovered routes, deduplicated services, and middleware.
232
+ */
233
+ declare function scanProject(options: ScanProjectOptions): Promise<{
234
+ routes: RouteInfo[];
235
+ services: ServiceInfo[];
236
+ middlewares: MiddlewareInfo[];
237
+ }>;
238
+ /**
239
+ * Options for {@link scanSource}.
240
+ */
241
+ interface ScanSourceOptions {
242
+ /** Directories whose entire file tree is scanned for decorated classes. */
243
+ dirs: string[];
244
+ /** Optional logger forwarded to the underlying scanner. */
245
+ logger?: ScanLogger;
246
+ }
247
+ /**
248
+ * Recursively scans the given directories for `@Controller` classes,
249
+ * `@Injectable` services and `BaseMiddleware` subclasses, reading each file only
250
+ * once.
251
+ *
252
+ * Unlike {@link scanProject}, this does not assume the Nitro convention of
253
+ * dedicated `api`/`routes`/`services` subdirectories — decorated classes are
254
+ * discovered wherever they live in the tree, which matches how Vercube apps are
255
+ * typically organised. **Every** `@Controller` class is returned for binding
256
+ * (not just those with HTTP routes), so WebSocket-only controllers are bound
257
+ * too, while `routes` carries the concrete HTTP routes (method + path) for
258
+ * request matching. Services already discovered as controllers are filtered out
259
+ * so a class is never imported twice.
260
+ *
261
+ * @param options - Directories to scan. See {@link ScanSourceOptions}.
262
+ * @returns The discovered controllers, HTTP routes, deduplicated services, and middleware, all with resolved imports.
263
+ */
264
+ declare function scanSource(options: ScanSourceOptions): Promise<{
265
+ controllers: ServiceInfo[];
266
+ routes: RouteInfo[];
267
+ services: ServiceInfo[];
268
+ middlewares: MiddlewareInfo[];
269
+ }>;
270
+ //#endregion
271
+ //#region src/Scan.d.ts
272
+ /**
273
+ * Glob pattern matching every source file extension the scanner understands.
274
+ */
275
+ declare const GLOB_SCAN_PATTERN = "**/*.{js,mjs,cjs,ts,mts,cts,tsx,jsx}";
276
+ /**
277
+ * Scans a single base directory for files inside the named subdirectory.
278
+ *
279
+ * Globs `<name>/**` relative to `dir`, returning each match as a {@link FileInfo}
280
+ * with its absolute path and the path relative to `<dir>/<name>`. Results are
281
+ * sorted by relative path for deterministic output.
282
+ *
283
+ * @param dir - The base directory to scan within.
284
+ * @param name - The subdirectory (e.g. 'api', 'routes', 'services') to look in.
285
+ * @param logger - Optional logger used to warn when `<dir>/<name>` is not a directory.
286
+ * @returns A sorted list of discovered files.
287
+ */
288
+ declare function scanDir(dir: string, name: string, logger?: ScanLogger): Promise<FileInfo[]>;
289
+ /**
290
+ * Scans every base directory for files inside the named subdirectory.
291
+ *
292
+ * @param baseDirs - The base directories to scan (e.g. the project source roots).
293
+ * @param name - The subdirectory to look in within each base directory.
294
+ * @param logger - Optional logger forwarded to {@link scanDir}.
295
+ * @returns The flattened list of discovered files across all base directories.
296
+ */
297
+ declare function scanFiles(baseDirs: string[], name: string, logger?: ScanLogger): Promise<FileInfo[]>;
298
+ //#endregion
299
+ //#region src/Transform.d.ts
300
+ /**
301
+ * Reads a controller file from disk and returns all routes defined in classes
302
+ * decorated with `@Controller` and methods decorated with HTTP decorators.
303
+ *
304
+ * The returned `import` statements still contain the placeholder source; resolve
305
+ * them to the real file path with {@link resolveImports} when generating code.
306
+ *
307
+ * @param file - File info (path and fullPath) to analyze.
308
+ * @returns A list of {@link RouteInfo} for every route in the file.
309
+ */
310
+ declare function transformRoute(file: FileInfo): Promise<RouteInfo[]>;
311
+ /**
312
+ * Reads a file from disk and returns all `@Injectable`-decorated classes found within it.
313
+ *
314
+ * @param file - File info (path and fullPath) to analyze.
315
+ * @returns A list of {@link ServiceInfo} for every injectable class in the file.
316
+ */
317
+ declare function transformService(file: FileInfo): Promise<ServiceInfo[]>;
318
+ /**
319
+ * Reads a file from disk and returns all classes extending `BaseMiddleware` found within it.
320
+ *
321
+ * @param file - File info (path and fullPath) to analyze.
322
+ * @returns A list of {@link MiddlewareInfo} for every middleware class in the file.
323
+ */
324
+ declare function transformMiddleware(file: FileInfo): Promise<MiddlewareInfo[]>;
325
+ /**
326
+ * Replaces the placeholder import source in each entry with the entry's real
327
+ * absolute file path, producing import statements that load the actual module.
328
+ *
329
+ * @param entries - Scanned entries whose `import` still references the placeholder source.
330
+ * @param placeholder - The placeholder module specifier to replace.
331
+ * @returns The entries with resolved `import` statements.
332
+ */
333
+ declare function resolveImports<T extends {
334
+ import: string;
335
+ fullPath: string;
336
+ }>(entries: T[], placeholder: string): T[];
337
+ //#endregion
338
+ export { FileInfo, GLOB_SCAN_PATTERN, IMPORT_SOURCE, MIDDLEWARE_IMPORT_SOURCE, MiddlewareInfo, RouteInfo, SERVICE_IMPORT_SOURCE, ScanLogger, ScanProjectOptions, ScanSourceOptions, ServiceInfo, buildImport, extendsSuperClass, extractControllers, extractDecoratorArg, extractMiddlewares, extractParams, extractRoutes, extractServices, getClassNode, getMiddlewares, getRoutes, getServices, hasDecorator, normalizePath, resolveImports, scanDir, scanFiles, scanProject, scanSource, transformMiddleware, transformRoute, transformService };
package/dist/index.mjs ADDED
@@ -0,0 +1,514 @@
1
+ import { parseSync } from "oxc-parser";
2
+ import { readFileSync } from "node:fs";
3
+ import { join, relative } from "pathe";
4
+ import { glob } from "tinyglobby";
5
+ //#region src/Ast.ts
6
+ /**
7
+ * Pre-compiled regular expression for extracting named route parameters.
8
+ * Matches path segments prefixed with ':' (e.g. ':id', ':slug').
9
+ * Uses the global flag for iterative matching via `exec`.
10
+ */
11
+ const PARAM_RE = /:([^/]+)/g;
12
+ /**
13
+ * Unwraps export declarations to retrieve the underlying class node,
14
+ * along with metadata about the export style.
15
+ *
16
+ * Handles the following AST patterns:
17
+ * - `ExportDefaultDeclaration` wrapping a `ClassDeclaration` → `isDefault: true`
18
+ * - `ExportNamedDeclaration` wrapping a `ClassDeclaration` → `isDefault: false`
19
+ * - Direct `ClassDeclaration` or `Class` nodes → `isDefault: false`
20
+ *
21
+ * @param node - An AST node from the program body to inspect.
22
+ * @returns An object containing the class AST node and whether it is a default export, or `null` if not a class.
23
+ */
24
+ function getClassNode(node) {
25
+ if (node.type === "ExportDefaultDeclaration") {
26
+ const decl = node.declaration;
27
+ if (decl?.type === "ClassDeclaration" || decl?.type === "Class") return {
28
+ classNode: decl,
29
+ isDefault: true
30
+ };
31
+ }
32
+ if (node.type === "ExportNamedDeclaration") {
33
+ const decl = node.declaration;
34
+ if (decl?.type === "ClassDeclaration" || decl?.type === "Class") return {
35
+ classNode: decl,
36
+ isDefault: false
37
+ };
38
+ }
39
+ if (node?.type === "ClassDeclaration" || node?.type === "Class") return {
40
+ classNode: node,
41
+ isDefault: false
42
+ };
43
+ return null;
44
+ }
45
+ /**
46
+ * Builds an import statement for a class, choosing default vs named syntax.
47
+ *
48
+ * @param className - The class identifier to import.
49
+ * @param source - The module specifier to import from.
50
+ * @param isDefault - Whether the class is a default export.
51
+ * @returns The generated import statement string.
52
+ */
53
+ function buildImport(className, source, isDefault) {
54
+ return isDefault ? `import ${className} from '${source}';` : `import { ${className} } from '${source}';`;
55
+ }
56
+ /**
57
+ * Searches a list of decorators for a `CallExpression` matching the given name
58
+ * and extracts its first string argument.
59
+ *
60
+ * Used to retrieve the path argument from decorators like `@Controller('/api/foo')`.
61
+ * If the decorator is found but has no string argument, an empty string is returned.
62
+ * If no matching decorator is found, `null` is returned.
63
+ *
64
+ * @param decorators - The array of decorator AST nodes to search, or `undefined` if none exist.
65
+ * @param name - The decorator function name to match against (e.g. 'Controller').
66
+ * @returns The string argument value, an empty string if no argument is provided, or `null` if the decorator is not found.
67
+ */
68
+ function extractDecoratorArg(decorators, name) {
69
+ if (!decorators) return null;
70
+ for (const dec of decorators) {
71
+ const expr = dec.expression;
72
+ if (expr?.type === "CallExpression" && expr.callee?.name === name) {
73
+ const arg = expr.arguments?.[0];
74
+ if (arg?.type === "Literal" && typeof arg.value === "string") return arg.value;
75
+ return "";
76
+ }
77
+ }
78
+ return null;
79
+ }
80
+ /**
81
+ * Returns true if the given decorators array contains a decorator matching `name`.
82
+ * Supports both `@Name` identifier and `@Name()` call expression styles.
83
+ *
84
+ * @param decorators - The array of decorator AST nodes to search.
85
+ * @param name - The decorator name to match.
86
+ * @returns Whether a matching decorator exists.
87
+ */
88
+ function hasDecorator(decorators, name) {
89
+ if (!decorators) return false;
90
+ for (const dec of decorators) {
91
+ const expr = dec.expression;
92
+ if (expr?.type === "CallExpression" && expr.callee?.name === name) return true;
93
+ if (expr?.type === "Identifier" && expr.name === name) return true;
94
+ }
95
+ return false;
96
+ }
97
+ /**
98
+ * Returns true if the class node extends a superclass with the given name.
99
+ *
100
+ * @param classNode - The class AST node to inspect.
101
+ * @param name - The expected superclass identifier name.
102
+ * @returns Whether the class extends the named superclass.
103
+ */
104
+ function extendsSuperClass(classNode, name) {
105
+ return classNode.superClass?.type === "Identifier" && classNode.superClass.name === name;
106
+ }
107
+ /**
108
+ * Normalizes a route path by removing duplicate slashes and ensuring
109
+ * the path starts with a single leading slash.
110
+ *
111
+ * @param path - The raw concatenated path string to normalize.
112
+ * @returns A cleaned path string with a leading slash and no duplicate separators.
113
+ */
114
+ function normalizePath(path) {
115
+ return "/" + path.split("/").filter(Boolean).join("/");
116
+ }
117
+ /**
118
+ * Extracts named route parameters from a path string.
119
+ *
120
+ * Scans the path for segments prefixed with ':' and returns an array of
121
+ * parameter names with the ':' prefix stripped.
122
+ *
123
+ * @param route - The normalized route path to scan for parameters.
124
+ * @returns An array of parameter name strings, empty if none are found.
125
+ */
126
+ function extractParams(route) {
127
+ const params = [];
128
+ let match;
129
+ while (match = PARAM_RE.exec(route)) params.push(match[1]);
130
+ PARAM_RE.lastIndex = 0;
131
+ return params;
132
+ }
133
+ //#endregion
134
+ //#region src/Extract.ts
135
+ /**
136
+ * Placeholder module specifier embedded in generated route imports.
137
+ * Callers resolve it to the real file path once known (see {@link Transform}).
138
+ */
139
+ const IMPORT_SOURCE = "#internal/vercube-route-plugin";
140
+ /**
141
+ * Placeholder module specifier embedded in generated service imports.
142
+ */
143
+ const SERVICE_IMPORT_SOURCE = "#internal/vercube-service-source";
144
+ /**
145
+ * Placeholder module specifier embedded in generated middleware imports.
146
+ */
147
+ const MIDDLEWARE_IMPORT_SOURCE = "#internal/vercube-middleware-source";
148
+ /**
149
+ * Mapping of decorator names to their corresponding uppercase HTTP method strings.
150
+ * Used for O(1) lookup and simultaneous method name resolution.
151
+ */
152
+ const HTTP_METHODS = {
153
+ Get: "GET",
154
+ Post: "POST",
155
+ Put: "PUT",
156
+ Delete: "DELETE",
157
+ Patch: "PATCH",
158
+ Options: "OPTIONS",
159
+ Head: "HEAD"
160
+ };
161
+ /**
162
+ * Extracts all route definitions from the given TypeScript/JavaScript source code.
163
+ *
164
+ * Parses the source using `oxc-parser` and traverses the resulting AST to find
165
+ * classes decorated with `@Controller(path)`. For each such class, it inspects
166
+ * method definitions for HTTP method decorators (`@Get`, `@Post`, `@Put`, `@Delete`,
167
+ * `@Patch`, `@Options`, `@Head`) and constructs full route paths by concatenating
168
+ * the controller base path with the method-level path.
169
+ *
170
+ * @param code - The raw TypeScript or JavaScript source code string to analyze.
171
+ * @returns An array of {@link RouteInfo} objects representing all discovered routes.
172
+ */
173
+ function extractRoutes(code) {
174
+ const ast = parseSync("file.ts", code).program;
175
+ const routes = [];
176
+ for (const node of ast.body) {
177
+ const classInfo = getClassNode(node);
178
+ if (!classInfo) continue;
179
+ const { classNode, isDefault } = classInfo;
180
+ const basePath = extractDecoratorArg(classNode.decorators, "Controller");
181
+ if (basePath === null) continue;
182
+ const className = classNode.id?.name;
183
+ if (!className) continue;
184
+ const importStatement = buildImport(className, IMPORT_SOURCE, isDefault);
185
+ for (const member of classNode.body?.body ?? []) {
186
+ if (member.type !== "MethodDefinition") continue;
187
+ for (const decorator of member.decorators ?? []) {
188
+ const expr = decorator.expression;
189
+ if (expr?.type !== "CallExpression") continue;
190
+ const method = HTTP_METHODS[expr.callee?.name];
191
+ if (!method) continue;
192
+ const arg = expr.arguments?.[0];
193
+ const fullRoute = normalizePath(basePath + (arg?.type === "Literal" && typeof arg.value === "string" ? arg.value : ""));
194
+ routes.push({
195
+ import: importStatement,
196
+ importClassName: className,
197
+ route: fullRoute,
198
+ method,
199
+ fullPath: "",
200
+ path: "",
201
+ params: extractParams(fullRoute)
202
+ });
203
+ }
204
+ }
205
+ }
206
+ return routes;
207
+ }
208
+ /**
209
+ * Extracts every `@Controller`-decorated class from the given source code,
210
+ * regardless of whether it declares HTTP method routes.
211
+ *
212
+ * Unlike {@link extractRoutes} (which emits one entry per HTTP route method),
213
+ * this returns one entry per controller class — so controllers that only carry
214
+ * non-HTTP handlers (e.g. WebSocket `@Message` methods) are still discovered for
215
+ * binding into the DI container.
216
+ *
217
+ * @param code - The raw TypeScript or JavaScript source code string to analyze.
218
+ * @returns An array of {@link ServiceInfo} objects for all discovered controller classes.
219
+ */
220
+ function extractControllers(code) {
221
+ const ast = parseSync("file.ts", code).program;
222
+ const controllers = [];
223
+ for (const node of ast.body) {
224
+ const classInfo = getClassNode(node);
225
+ if (!classInfo) continue;
226
+ const { classNode, isDefault } = classInfo;
227
+ if (extractDecoratorArg(classNode.decorators, "Controller") === null) continue;
228
+ const className = classNode.id?.name;
229
+ if (!className) continue;
230
+ controllers.push({
231
+ import: buildImport(className, IMPORT_SOURCE, isDefault),
232
+ importClassName: className,
233
+ fullPath: "",
234
+ path: ""
235
+ });
236
+ }
237
+ return controllers;
238
+ }
239
+ /**
240
+ * Extracts all `@Injectable`-decorated class definitions from the given source code.
241
+ *
242
+ * @param code - The raw TypeScript or JavaScript source code string to analyze.
243
+ * @returns An array of {@link ServiceInfo} objects for all discovered injectable classes.
244
+ */
245
+ function extractServices(code) {
246
+ const ast = parseSync("file.ts", code).program;
247
+ const services = [];
248
+ for (const node of ast.body) {
249
+ const classInfo = getClassNode(node);
250
+ if (!classInfo) continue;
251
+ const { classNode, isDefault } = classInfo;
252
+ if (!hasDecorator(classNode.decorators, "Injectable")) continue;
253
+ const className = classNode.id?.name;
254
+ if (!className) continue;
255
+ services.push({
256
+ import: buildImport(className, SERVICE_IMPORT_SOURCE, isDefault),
257
+ importClassName: className,
258
+ fullPath: "",
259
+ path: ""
260
+ });
261
+ }
262
+ return services;
263
+ }
264
+ /**
265
+ * Extracts all class definitions that extend `BaseMiddleware` from the given source code.
266
+ *
267
+ * @param code - The raw TypeScript or JavaScript source code string to analyze.
268
+ * @returns An array of {@link MiddlewareInfo} objects for all discovered middleware classes.
269
+ */
270
+ function extractMiddlewares(code) {
271
+ const ast = parseSync("file.ts", code).program;
272
+ const middlewares = [];
273
+ for (const node of ast.body) {
274
+ const classInfo = getClassNode(node);
275
+ if (!classInfo) continue;
276
+ const { classNode, isDefault } = classInfo;
277
+ if (!extendsSuperClass(classNode, "BaseMiddleware")) continue;
278
+ const className = classNode.id?.name;
279
+ if (!className) continue;
280
+ middlewares.push({
281
+ import: buildImport(className, MIDDLEWARE_IMPORT_SOURCE, isDefault),
282
+ importClassName: className,
283
+ fullPath: "",
284
+ path: ""
285
+ });
286
+ }
287
+ return middlewares;
288
+ }
289
+ //#endregion
290
+ //#region src/Scan.ts
291
+ /**
292
+ * Glob pattern matching every source file extension the scanner understands.
293
+ */
294
+ const GLOB_SCAN_PATTERN = "**/*.{js,mjs,cjs,ts,mts,cts,tsx,jsx}";
295
+ /**
296
+ * Scans a single base directory for files inside the named subdirectory.
297
+ *
298
+ * Globs `<name>/**` relative to `dir`, returning each match as a {@link FileInfo}
299
+ * with its absolute path and the path relative to `<dir>/<name>`. Results are
300
+ * sorted by relative path for deterministic output.
301
+ *
302
+ * @param dir - The base directory to scan within.
303
+ * @param name - The subdirectory (e.g. 'api', 'routes', 'services') to look in.
304
+ * @param logger - Optional logger used to warn when `<dir>/<name>` is not a directory.
305
+ * @returns A sorted list of discovered files.
306
+ */
307
+ async function scanDir(dir, name, logger) {
308
+ return (await glob(join(name, GLOB_SCAN_PATTERN), {
309
+ cwd: dir,
310
+ dot: true,
311
+ absolute: true
312
+ }).catch((error) => {
313
+ if (error?.code === "ENOTDIR") {
314
+ logger?.warn(`Ignoring \`${join(dir, name)}\`. It must be a directory.`);
315
+ return [];
316
+ }
317
+ throw error;
318
+ })).map((fullPath) => ({
319
+ fullPath,
320
+ path: relative(join(dir, name), fullPath)
321
+ })).sort((a, b) => a.path.localeCompare(b.path));
322
+ }
323
+ /**
324
+ * Scans every base directory for files inside the named subdirectory.
325
+ *
326
+ * @param baseDirs - The base directories to scan (e.g. the project source roots).
327
+ * @param name - The subdirectory to look in within each base directory.
328
+ * @param logger - Optional logger forwarded to {@link scanDir}.
329
+ * @returns The flattened list of discovered files across all base directories.
330
+ */
331
+ async function scanFiles(baseDirs, name, logger) {
332
+ return Promise.all(baseDirs.map((dir) => scanDir(dir, name, logger))).then((r) => r.flat());
333
+ }
334
+ //#endregion
335
+ //#region src/Transform.ts
336
+ /**
337
+ * Reads a controller file from disk and returns all routes defined in classes
338
+ * decorated with `@Controller` and methods decorated with HTTP decorators.
339
+ *
340
+ * The returned `import` statements still contain the placeholder source; resolve
341
+ * them to the real file path with {@link resolveImports} when generating code.
342
+ *
343
+ * @param file - File info (path and fullPath) to analyze.
344
+ * @returns A list of {@link RouteInfo} for every route in the file.
345
+ */
346
+ async function transformRoute(file) {
347
+ return extractRoutes(readFileSync(file.fullPath, "utf8")).map((route) => ({
348
+ ...route,
349
+ ...file
350
+ }));
351
+ }
352
+ /**
353
+ * Reads a file from disk and returns all `@Injectable`-decorated classes found within it.
354
+ *
355
+ * @param file - File info (path and fullPath) to analyze.
356
+ * @returns A list of {@link ServiceInfo} for every injectable class in the file.
357
+ */
358
+ async function transformService(file) {
359
+ return extractServices(readFileSync(file.fullPath, "utf8")).map((service) => ({
360
+ ...service,
361
+ ...file
362
+ }));
363
+ }
364
+ /**
365
+ * Reads a file from disk and returns all classes extending `BaseMiddleware` found within it.
366
+ *
367
+ * @param file - File info (path and fullPath) to analyze.
368
+ * @returns A list of {@link MiddlewareInfo} for every middleware class in the file.
369
+ */
370
+ async function transformMiddleware(file) {
371
+ return extractMiddlewares(readFileSync(file.fullPath, "utf8")).map((middleware) => ({
372
+ ...middleware,
373
+ ...file
374
+ }));
375
+ }
376
+ /**
377
+ * Replaces the placeholder import source in each entry with the entry's real
378
+ * absolute file path, producing import statements that load the actual module.
379
+ *
380
+ * @param entries - Scanned entries whose `import` still references the placeholder source.
381
+ * @param placeholder - The placeholder module specifier to replace.
382
+ * @returns The entries with resolved `import` statements.
383
+ */
384
+ function resolveImports(entries, placeholder) {
385
+ return entries.map((entry) => ({
386
+ ...entry,
387
+ import: entry.import.replace(placeholder, entry.fullPath)
388
+ }));
389
+ }
390
+ //#endregion
391
+ //#region src/Project.ts
392
+ const DEFAULT_API_DIR = "api";
393
+ const DEFAULT_ROUTES_DIR = "routes";
394
+ const DEFAULT_SERVICE_DIRS = [
395
+ "api",
396
+ "routes",
397
+ "services",
398
+ "repositories"
399
+ ];
400
+ const DEFAULT_MIDDLEWARE_DIR = "middleware";
401
+ /**
402
+ * Scans the API and routes directories for controllers and returns every route
403
+ * with its `import` resolved to the controller's real file path.
404
+ *
405
+ * @param options - Where to scan. See {@link ScanProjectOptions}.
406
+ * @returns The discovered routes with resolved imports.
407
+ */
408
+ async function getRoutes(options) {
409
+ const { baseDirs, apiDir = DEFAULT_API_DIR, routesDir = DEFAULT_ROUTES_DIR, logger } = options;
410
+ const files = await Promise.all([scanFiles(baseDirs, apiDir, logger), scanFiles(baseDirs, routesDir, logger)]).then((r) => r.flat());
411
+ return resolveImports(await Promise.all(files.map((file) => transformRoute(file))).then((r) => r.flat()), IMPORT_SOURCE);
412
+ }
413
+ /**
414
+ * Scans the configured service directories for `@Injectable` classes and returns
415
+ * every service with its `import` resolved to the class's real file path.
416
+ *
417
+ * @param options - Where to scan. See {@link ScanProjectOptions}.
418
+ * @returns The discovered services with resolved imports.
419
+ */
420
+ async function getServices(options) {
421
+ const { baseDirs, serviceDirs = DEFAULT_SERVICE_DIRS, logger } = options;
422
+ const files = await Promise.all(serviceDirs.map((dir) => scanFiles(baseDirs, dir, logger))).then((r) => r.flat());
423
+ return resolveImports(await Promise.all(files.map((file) => transformService(file))).then((r) => r.flat()), SERVICE_IMPORT_SOURCE);
424
+ }
425
+ /**
426
+ * Scans the middleware directory for `BaseMiddleware` subclasses and returns
427
+ * every middleware with its `import` resolved to the class's real file path.
428
+ *
429
+ * @param options - Where to scan. See {@link ScanProjectOptions}.
430
+ * @returns The discovered middleware with resolved imports.
431
+ */
432
+ async function getMiddlewares(options) {
433
+ const { baseDirs, middlewareDir = DEFAULT_MIDDLEWARE_DIR, logger } = options;
434
+ const files = await scanFiles(baseDirs, middlewareDir, logger);
435
+ return resolveImports(await Promise.all(files.map((file) => transformMiddleware(file))).then((r) => r.flat()), MIDDLEWARE_IMPORT_SOURCE);
436
+ }
437
+ /**
438
+ * Convenience wrapper that scans routes, services and middleware in one call.
439
+ *
440
+ * Services whose class name is already registered as a route are filtered out
441
+ * so a controller is never imported twice.
442
+ *
443
+ * @param options - Where to scan. See {@link ScanProjectOptions}.
444
+ * @returns The discovered routes, deduplicated services, and middleware.
445
+ */
446
+ async function scanProject(options) {
447
+ const [routes, services, middlewares] = await Promise.all([
448
+ getRoutes(options),
449
+ getServices(options),
450
+ getMiddlewares(options)
451
+ ]);
452
+ const routeClassNames = new Set(routes.map((r) => r.importClassName));
453
+ return {
454
+ routes,
455
+ services: services.filter((s) => !routeClassNames.has(s.importClassName)),
456
+ middlewares
457
+ };
458
+ }
459
+ /**
460
+ * Recursively scans the given directories for `@Controller` classes,
461
+ * `@Injectable` services and `BaseMiddleware` subclasses, reading each file only
462
+ * once.
463
+ *
464
+ * Unlike {@link scanProject}, this does not assume the Nitro convention of
465
+ * dedicated `api`/`routes`/`services` subdirectories — decorated classes are
466
+ * discovered wherever they live in the tree, which matches how Vercube apps are
467
+ * typically organised. **Every** `@Controller` class is returned for binding
468
+ * (not just those with HTTP routes), so WebSocket-only controllers are bound
469
+ * too, while `routes` carries the concrete HTTP routes (method + path) for
470
+ * request matching. Services already discovered as controllers are filtered out
471
+ * so a class is never imported twice.
472
+ *
473
+ * @param options - Directories to scan. See {@link ScanSourceOptions}.
474
+ * @returns The discovered controllers, HTTP routes, deduplicated services, and middleware, all with resolved imports.
475
+ */
476
+ async function scanSource(options) {
477
+ const files = await scanFiles(options.dirs, ".", options.logger);
478
+ const controllers = [];
479
+ const routes = [];
480
+ const services = [];
481
+ const middlewares = [];
482
+ for (const file of files) {
483
+ const code = readFileSync(file.fullPath, "utf8");
484
+ for (const controller of extractControllers(code)) controllers.push({
485
+ ...controller,
486
+ ...file,
487
+ import: controller.import.replace(IMPORT_SOURCE, file.fullPath)
488
+ });
489
+ for (const route of extractRoutes(code)) routes.push({
490
+ ...route,
491
+ ...file,
492
+ import: route.import.replace(IMPORT_SOURCE, file.fullPath)
493
+ });
494
+ for (const service of extractServices(code)) services.push({
495
+ ...service,
496
+ ...file,
497
+ import: service.import.replace(SERVICE_IMPORT_SOURCE, file.fullPath)
498
+ });
499
+ for (const middleware of extractMiddlewares(code)) middlewares.push({
500
+ ...middleware,
501
+ ...file,
502
+ import: middleware.import.replace(MIDDLEWARE_IMPORT_SOURCE, file.fullPath)
503
+ });
504
+ }
505
+ const controllerClassNames = new Set(controllers.map((c) => c.importClassName));
506
+ return {
507
+ controllers,
508
+ routes,
509
+ services: services.filter((s) => !controllerClassNames.has(s.importClassName)),
510
+ middlewares
511
+ };
512
+ }
513
+ //#endregion
514
+ export { GLOB_SCAN_PATTERN, IMPORT_SOURCE, MIDDLEWARE_IMPORT_SOURCE, SERVICE_IMPORT_SOURCE, buildImport, extendsSuperClass, extractControllers, extractDecoratorArg, extractMiddlewares, extractParams, extractRoutes, extractServices, getClassNode, getMiddlewares, getRoutes, getServices, hasDecorator, normalizePath, resolveImports, scanDir, scanFiles, scanProject, scanSource, transformMiddleware, transformRoute, transformService };
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "@vercube/scan",
3
+ "version": "1.1.0",
4
+ "description": "Source scanning and AST extraction utilities for the Vercube framework",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "https://github.com/vercube/vercube.git",
8
+ "directory": "packages/scan"
9
+ },
10
+ "license": "MIT",
11
+ "sideEffects": false,
12
+ "type": "module",
13
+ "main": "./dist/index.mjs",
14
+ "module": "./dist/index.mjs",
15
+ "exports": {
16
+ ".": "./dist/index.mjs",
17
+ "./package.json": "./package.json"
18
+ },
19
+ "types": "./dist/index.d.mts",
20
+ "files": [
21
+ "dist",
22
+ "README.md"
23
+ ],
24
+ "keywords": [
25
+ "vercube",
26
+ "scan",
27
+ "ast",
28
+ "framework"
29
+ ],
30
+ "dependencies": {
31
+ "oxc-parser": "0.112.0",
32
+ "pathe": "2.0.3",
33
+ "tinyglobby": "0.2.15"
34
+ },
35
+ "publishConfig": {
36
+ "access": "public"
37
+ },
38
+ "scripts": {
39
+ "build": "tsdown --config ../../tsdown.config.ts --config-loader=unrun"
40
+ }
41
+ }