@agentuity/cli 1.0.39 → 1.0.40

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.
Files changed (37) hide show
  1. package/dist/cmd/build/app-router-detector.d.ts +42 -0
  2. package/dist/cmd/build/app-router-detector.d.ts.map +1 -0
  3. package/dist/cmd/build/app-router-detector.js +253 -0
  4. package/dist/cmd/build/app-router-detector.js.map +1 -0
  5. package/dist/cmd/build/ast.d.ts +11 -1
  6. package/dist/cmd/build/ast.d.ts.map +1 -1
  7. package/dist/cmd/build/ast.js +273 -29
  8. package/dist/cmd/build/ast.js.map +1 -1
  9. package/dist/cmd/build/entry-generator.d.ts.map +1 -1
  10. package/dist/cmd/build/entry-generator.js +23 -16
  11. package/dist/cmd/build/entry-generator.js.map +1 -1
  12. package/dist/cmd/build/vite/registry-generator.d.ts.map +1 -1
  13. package/dist/cmd/build/vite/registry-generator.js +37 -13
  14. package/dist/cmd/build/vite/registry-generator.js.map +1 -1
  15. package/dist/cmd/build/vite/route-discovery.d.ts +17 -3
  16. package/dist/cmd/build/vite/route-discovery.d.ts.map +1 -1
  17. package/dist/cmd/build/vite/route-discovery.js +91 -3
  18. package/dist/cmd/build/vite/route-discovery.js.map +1 -1
  19. package/dist/cmd/build/vite-bundler.d.ts.map +1 -1
  20. package/dist/cmd/build/vite-bundler.js +3 -0
  21. package/dist/cmd/build/vite-bundler.js.map +1 -1
  22. package/dist/cmd/dev/index.d.ts.map +1 -1
  23. package/dist/cmd/dev/index.js +30 -0
  24. package/dist/cmd/dev/index.js.map +1 -1
  25. package/dist/utils/route-migration.d.ts +61 -0
  26. package/dist/utils/route-migration.d.ts.map +1 -0
  27. package/dist/utils/route-migration.js +662 -0
  28. package/dist/utils/route-migration.js.map +1 -0
  29. package/package.json +6 -6
  30. package/src/cmd/build/app-router-detector.ts +350 -0
  31. package/src/cmd/build/ast.ts +339 -36
  32. package/src/cmd/build/entry-generator.ts +23 -16
  33. package/src/cmd/build/vite/registry-generator.ts +38 -13
  34. package/src/cmd/build/vite/route-discovery.ts +151 -3
  35. package/src/cmd/build/vite-bundler.ts +4 -0
  36. package/src/cmd/dev/index.ts +34 -0
  37. package/src/utils/route-migration.ts +793 -0
@@ -1520,16 +1520,124 @@ function resolveImportPath(fromDir: string, importPath: string): string | null {
1520
1520
  return null;
1521
1521
  }
1522
1522
 
1523
+ /**
1524
+ * Check if a CallExpression is a chained router initializer.
1525
+ * Walks up the callee chain looking for createRouter() or new Hono() at the root.
1526
+ *
1527
+ * Example AST for `createRouter().get('/foo', handler).post('/bar', handler)`:
1528
+ * ```
1529
+ * CallExpression (.post)
1530
+ * callee: MemberExpression
1531
+ * object: CallExpression (.get)
1532
+ * callee: MemberExpression
1533
+ * object: CallExpression (createRouter)
1534
+ * property: "post"
1535
+ * ```
1536
+ */
1537
+ function isChainedRouterInit(node: ASTNode): boolean {
1538
+ let current: ASTNode = node;
1539
+
1540
+ // Walk down the chain: each link is CallExpression → MemberExpression → CallExpression
1541
+ while (current.type === 'CallExpression') {
1542
+ const callee = (current as ASTCallExpression).callee as ASTNode;
1543
+ if (!callee) return false;
1544
+
1545
+ // Direct createRouter()
1546
+ if (callee.type === 'Identifier' && (callee as ASTNodeIdentifier).name === 'createRouter')
1547
+ return true;
1548
+
1549
+ // Chained: .method() → MemberExpression
1550
+ if (callee.type === 'MemberExpression' && (callee as ASTMemberExpression).object) {
1551
+ current = (callee as ASTMemberExpression).object as ASTNode;
1552
+ continue;
1553
+ }
1554
+
1555
+ break;
1556
+ }
1557
+
1558
+ // Check if we landed on createRouter() or new Hono()
1559
+ if (current.type === 'CallExpression') {
1560
+ const callee = (current as ASTCallExpression).callee as ASTNode;
1561
+ if (callee?.type === 'Identifier' && (callee as ASTNodeIdentifier).name === 'createRouter')
1562
+ return true;
1563
+ }
1564
+ if (current.type === 'NewExpression') {
1565
+ const callee = (current as ASTCallExpression).callee as ASTNode;
1566
+ if (callee?.type === 'Identifier' && (callee as ASTNodeIdentifier).name === 'Hono')
1567
+ return true;
1568
+ }
1569
+
1570
+ return false;
1571
+ }
1572
+
1573
+ /**
1574
+ * Flatten a chained call expression into individual method calls.
1575
+ *
1576
+ * Given `createRouter().get('/a', h1).post('/b', h2).route('/c', sub)`, returns:
1577
+ * ```
1578
+ * [
1579
+ * { method: 'get', arguments: ['/a', h1] },
1580
+ * { method: 'post', arguments: ['/b', h2] },
1581
+ * { method: 'route', arguments: ['/c', sub] },
1582
+ * ]
1583
+ * ```
1584
+ */
1585
+ function flattenChainedCalls(node: ASTNode): Array<{ method: string; arguments: unknown[] }> {
1586
+ const calls: Array<{ method: string; arguments: unknown[] }> = [];
1587
+
1588
+ let current: ASTNode = node;
1589
+ while (current.type === 'CallExpression') {
1590
+ const callee = (current as ASTCallExpression).callee as ASTNode;
1591
+ if (!callee) break;
1592
+
1593
+ if (
1594
+ callee.type === 'MemberExpression' &&
1595
+ ((callee as ASTMemberExpression).property as ASTNode)?.type === 'Identifier'
1596
+ ) {
1597
+ calls.unshift({
1598
+ method: ((callee as ASTMemberExpression).property as ASTNodeIdentifier).name,
1599
+ arguments: (current as ASTCallExpression).arguments || [],
1600
+ });
1601
+ current = (callee as ASTMemberExpression).object as ASTNode;
1602
+ continue;
1603
+ }
1604
+
1605
+ break; // Reached the root (createRouter() / new Hono())
1606
+ }
1607
+
1608
+ return calls;
1609
+ }
1610
+
1611
+ export interface ParseRouteOptions {
1612
+ visitedFiles?: Set<string>;
1613
+ mountedSubrouters?: Set<string>;
1614
+ /**
1615
+ * Explicit mount prefix for this router file, derived from code-based routing
1616
+ * (e.g., from `createApp({ router })` or `.route()` calls).
1617
+ * When provided, overrides the filesystem-derived mount path.
1618
+ */
1619
+ mountPrefix?: string;
1620
+ }
1621
+
1523
1622
  export async function parseRoute(
1524
1623
  rootDir: string,
1525
1624
  filename: string,
1526
1625
  projectId: string,
1527
1626
  deploymentId: string,
1528
- visitedFiles?: Set<string>,
1627
+ visitedFilesOrOptions?: Set<string> | ParseRouteOptions,
1529
1628
  mountedSubrouters?: Set<string>
1530
1629
  ): Promise<BuildMetadata['routes']> {
1630
+ // Support both old positional args and new options object
1631
+ let options: ParseRouteOptions;
1632
+ if (visitedFilesOrOptions instanceof Set) {
1633
+ options = { visitedFiles: visitedFilesOrOptions, mountedSubrouters };
1634
+ } else if (visitedFilesOrOptions && typeof visitedFilesOrOptions === 'object') {
1635
+ options = visitedFilesOrOptions;
1636
+ } else {
1637
+ options = { mountedSubrouters };
1638
+ }
1531
1639
  // Track visited files to prevent infinite recursion
1532
- const visited = visitedFiles ?? new Set<string>();
1640
+ const visited = options.visitedFiles ?? new Set<string>();
1533
1641
  const resolvedFilename = resolve(filename);
1534
1642
  if (visited.has(resolvedFilename)) {
1535
1643
  return []; // Already parsed this file, avoid infinite loop
@@ -1643,6 +1751,10 @@ export async function parseRoute(
1643
1751
  message: `could not find default export for ${filename} using ${rootDir}`,
1644
1752
  });
1645
1753
  }
1754
+ // Track the chained init expression (e.g., createRouter().get(...).post(...))
1755
+ // so we can extract routes from it later
1756
+ let chainedInitExpr: ASTNode | undefined;
1757
+
1646
1758
  for (const body of ast.body) {
1647
1759
  if (body.type === 'VariableDeclaration') {
1648
1760
  for (const vardecl of body.declarations) {
@@ -1656,6 +1768,14 @@ export async function parseRoute(
1656
1768
  variableName = identifier.name;
1657
1769
  break;
1658
1770
  }
1771
+ // Support chained calls: createRouter().get(...).post(...)
1772
+ // The init is a CallExpression whose callee is a MemberExpression chain
1773
+ // that eventually roots at createRouter() or new Hono()
1774
+ if (isChainedRouterInit(call)) {
1775
+ variableName = identifier.name;
1776
+ chainedInitExpr = call;
1777
+ break;
1778
+ }
1659
1779
  } else if (vardecl.init?.type === 'NewExpression') {
1660
1780
  const newExpr = vardecl.init as ASTCallExpression;
1661
1781
  // Support new Hono() pattern
@@ -1678,16 +1798,18 @@ export async function parseRoute(
1678
1798
 
1679
1799
  const rel = toForwardSlash(relative(rootDir, filename));
1680
1800
 
1681
- // Compute the API mount path using the shared helper
1682
- // This ensures consistency between route type generation (here) and runtime mounting (entry-generator.ts)
1683
- // Examples:
1684
- // src/api/index.ts -> basePath = '/api'
1685
- // src/api/sessions.ts -> basePath = '/api/sessions'
1686
- // src/api/auth/route.ts -> basePath = '/api/auth'
1687
- // src/api/users/profile/route.ts -> basePath = '/api/users/profile'
1688
- const srcDir = join(rootDir, 'src');
1689
- const relativeApiPath = extractRelativeApiPath(filename, srcDir);
1690
- const basePath = computeApiMountPath(relativeApiPath);
1801
+ // Determine the base mount path for routes in this file.
1802
+ // When mountPrefix is provided (explicit routing via createApp({ router })),
1803
+ // use it directly — the mount path comes from the code, not the filesystem.
1804
+ // Otherwise, derive it from the file's position in src/api/ (file-based routing).
1805
+ let basePath: string;
1806
+ if (options.mountPrefix !== undefined) {
1807
+ basePath = options.mountPrefix;
1808
+ } else {
1809
+ const srcDir = join(rootDir, 'src');
1810
+ const relativeApiPath = extractRelativeApiPath(filename, srcDir);
1811
+ basePath = computeApiMountPath(relativeApiPath);
1812
+ }
1691
1813
 
1692
1814
  const routes: RouteDefinition = [];
1693
1815
 
@@ -1773,40 +1895,33 @@ export async function parseRoute(
1773
1895
  }
1774
1896
 
1775
1897
  try {
1776
- // Parse sub-router's routes
1898
+ // The combined mount point for this sub-router
1899
+ const combinedBase = joinMountAndRoute(basePath, mountPath);
1900
+
1901
+ // Parse sub-router's routes with the code-derived mount prefix.
1902
+ // This ensures the sub-router's routes are prefixed correctly
1903
+ // regardless of where the file lives on disk.
1777
1904
  const subRoutes = await parseRoute(
1778
1905
  rootDir,
1779
1906
  resolvedFile,
1780
1907
  projectId,
1781
1908
  deploymentId,
1782
- visited,
1783
- mountedSubrouters
1909
+ {
1910
+ visitedFiles: visited,
1911
+ mountedSubrouters: options.mountedSubrouters,
1912
+ mountPrefix: combinedBase,
1913
+ }
1784
1914
  );
1785
1915
 
1786
1916
  // Track this file as a mounted sub-router
1787
- if (mountedSubrouters) {
1788
- mountedSubrouters.add(resolve(resolvedFile));
1917
+ if (options.mountedSubrouters) {
1918
+ options.mountedSubrouters.add(resolve(resolvedFile));
1789
1919
  }
1790
1920
 
1791
- // Compute the sub-router's own basePath so we can strip it
1792
- const subSrcDir = join(rootDir, 'src');
1793
- const subRelativeApiPath = extractRelativeApiPath(
1794
- resolvedFile,
1795
- subSrcDir
1796
- );
1797
- const subBasePath = computeApiMountPath(subRelativeApiPath);
1798
-
1799
- // The combined mount point for sub-routes
1800
- const combinedBase = joinMountAndRoute(basePath, mountPath);
1801
-
1802
1921
  for (const subRoute of subRoutes) {
1803
- // Strip the sub-router's own basePath from the route path
1804
- let routeSuffix = subRoute.path;
1805
- if (routeSuffix.startsWith(subBasePath)) {
1806
- routeSuffix = routeSuffix.slice(subBasePath.length) || '/';
1807
- }
1808
-
1809
- const fullPath = joinMountAndRoute(combinedBase, routeSuffix);
1922
+ // Sub-routes already have the correct full path
1923
+ // (the recursive call used combinedBase as mountPrefix)
1924
+ const fullPath = subRoute.path;
1810
1925
  const id = generateRouteId(
1811
1926
  projectId,
1812
1927
  deploymentId,
@@ -1817,11 +1932,20 @@ export async function parseRoute(
1817
1932
  subRoute.version
1818
1933
  );
1819
1934
 
1935
+ // Preserve the sub-route's original filename for schema
1936
+ // import resolution. The registry generator needs to know
1937
+ // which file actually defines/exports the schema variable.
1938
+ const config = { ...subRoute.config };
1939
+ if (subRoute.filename && subRoute.filename !== rel) {
1940
+ config.schemaSourceFile = subRoute.filename;
1941
+ }
1942
+
1820
1943
  routes.push({
1821
1944
  ...subRoute,
1822
1945
  id,
1823
1946
  path: fullPath,
1824
- filename: rel, // Keep parent file as the filename since routes are mounted here
1947
+ filename: rel,
1948
+ config: Object.keys(config).length > 0 ? config : undefined,
1825
1949
  });
1826
1950
  }
1827
1951
  } catch {
@@ -2309,6 +2433,185 @@ export async function parseRoute(
2309
2433
  }
2310
2434
  }
2311
2435
  }
2436
+ // Process routes from chained initialization expressions
2437
+ // e.g., const router = createRouter().get('/foo', handler).post('/bar', handler)
2438
+ if (chainedInitExpr) {
2439
+ const chainedCalls = flattenChainedCalls(chainedInitExpr);
2440
+ for (const chainedCall of chainedCalls) {
2441
+ const { method: chainMethod, arguments: chainArgs } = chainedCall;
2442
+
2443
+ // Skip non-route methods
2444
+ if (
2445
+ chainMethod === 'use' ||
2446
+ chainMethod === 'onError' ||
2447
+ chainMethod === 'notFound' ||
2448
+ chainMethod === 'basePath' ||
2449
+ chainMethod === 'mount'
2450
+ ) {
2451
+ continue;
2452
+ }
2453
+
2454
+ // Handle .route() for sub-router mounting (same as the ExpressionStatement case)
2455
+ if (chainMethod === 'route') {
2456
+ const mountPathArg = chainArgs[0] as ASTNode | undefined;
2457
+ const subRouterArg = chainArgs[1] as ASTNode | undefined;
2458
+
2459
+ if (
2460
+ mountPathArg &&
2461
+ (mountPathArg as ASTLiteral).type === 'Literal' &&
2462
+ subRouterArg &&
2463
+ (subRouterArg as ASTNodeIdentifier).type === 'Identifier'
2464
+ ) {
2465
+ const mountPath = String((mountPathArg as ASTLiteral).value);
2466
+ const subRouterName = (subRouterArg as ASTNodeIdentifier).name;
2467
+ const subRouterImportPath = importMap.get(subRouterName);
2468
+
2469
+ if (subRouterImportPath) {
2470
+ const resolvedFile = resolveImportPath(dirname(filename), subRouterImportPath);
2471
+ if (resolvedFile && !visited.has(resolve(resolvedFile))) {
2472
+ try {
2473
+ const combinedBase = joinMountAndRoute(basePath, mountPath);
2474
+ const subRoutes = await parseRoute(
2475
+ rootDir,
2476
+ resolvedFile,
2477
+ projectId,
2478
+ deploymentId,
2479
+ {
2480
+ visitedFiles: visited,
2481
+ mountedSubrouters: options.mountedSubrouters,
2482
+ mountPrefix: combinedBase,
2483
+ }
2484
+ );
2485
+
2486
+ if (options.mountedSubrouters) {
2487
+ options.mountedSubrouters.add(resolve(resolvedFile));
2488
+ }
2489
+
2490
+ for (const subRoute of subRoutes) {
2491
+ const fullPath = subRoute.path;
2492
+ const id = generateRouteId(
2493
+ projectId,
2494
+ deploymentId,
2495
+ subRoute.type,
2496
+ subRoute.method,
2497
+ rel,
2498
+ fullPath,
2499
+ subRoute.version
2500
+ );
2501
+
2502
+ const config = { ...subRoute.config };
2503
+ if (subRoute.filename && subRoute.filename !== rel) {
2504
+ config.schemaSourceFile = subRoute.filename;
2505
+ }
2506
+
2507
+ routes.push({
2508
+ ...subRoute,
2509
+ id,
2510
+ path: fullPath,
2511
+ filename: rel,
2512
+ config: Object.keys(config).length > 0 ? config : undefined,
2513
+ });
2514
+ }
2515
+ } catch {
2516
+ // Sub-router parse failure — skip
2517
+ }
2518
+ }
2519
+ }
2520
+ }
2521
+ continue;
2522
+ }
2523
+
2524
+ // Handle HTTP methods: get, post, put, patch, delete
2525
+ const CHAINED_HTTP_METHODS = ['get', 'post', 'put', 'delete', 'patch'] as const;
2526
+ if (
2527
+ CHAINED_HTTP_METHODS.includes(
2528
+ chainMethod.toLowerCase() as (typeof CHAINED_HTTP_METHODS)[number]
2529
+ )
2530
+ ) {
2531
+ const pathArg = chainArgs[0] as ASTNode | undefined;
2532
+ if (!pathArg || (pathArg as ASTLiteral).type !== 'Literal') continue;
2533
+
2534
+ const suffix = String((pathArg as ASTLiteral).value);
2535
+ let type = 'api';
2536
+
2537
+ // Check for websocket/sse/stream wrappers in handler args
2538
+ for (const arg of chainArgs.slice(1)) {
2539
+ if ((arg as ASTCallExpression)?.type === 'CallExpression') {
2540
+ const callExpr = arg as ASTCallExpression;
2541
+ if (callExpr.callee?.type === 'Identifier') {
2542
+ const calleeName = (callExpr.callee as ASTNodeIdentifier).name;
2543
+ if (
2544
+ calleeName === 'websocket' ||
2545
+ calleeName === 'sse' ||
2546
+ calleeName === 'stream'
2547
+ ) {
2548
+ type = calleeName;
2549
+ break;
2550
+ }
2551
+ }
2552
+ }
2553
+ }
2554
+
2555
+ const thepath = joinMountAndRoute(basePath, suffix);
2556
+ const id = generateRouteId(
2557
+ projectId,
2558
+ deploymentId,
2559
+ type,
2560
+ chainMethod,
2561
+ rel,
2562
+ thepath,
2563
+ version
2564
+ );
2565
+
2566
+ // Check for validators in chained args
2567
+ const validatorInfo = hasValidatorCall(chainArgs as unknown[]);
2568
+ const routeConfig: Record<string, unknown> = {};
2569
+ if (validatorInfo.hasValidator) {
2570
+ routeConfig.hasValidator = true;
2571
+ if (validatorInfo.agentVariable) {
2572
+ routeConfig.agentVariable = validatorInfo.agentVariable;
2573
+ const agentImportPath = importMap.get(validatorInfo.agentVariable);
2574
+ if (agentImportPath) routeConfig.agentImportPath = agentImportPath;
2575
+ }
2576
+ if (validatorInfo.inputSchemaVariable) {
2577
+ routeConfig.inputSchemaVariable = validatorInfo.inputSchemaVariable;
2578
+ const inputImportInfo = importInfoMap.get(validatorInfo.inputSchemaVariable);
2579
+ if (inputImportInfo) {
2580
+ routeConfig.inputSchemaImportPath = inputImportInfo.modulePath;
2581
+ routeConfig.inputSchemaImportedName = inputImportInfo.importedName;
2582
+ }
2583
+ }
2584
+ if (validatorInfo.outputSchemaVariable) {
2585
+ routeConfig.outputSchemaVariable = validatorInfo.outputSchemaVariable;
2586
+ const outputImportInfo = importInfoMap.get(validatorInfo.outputSchemaVariable);
2587
+ if (outputImportInfo) {
2588
+ routeConfig.outputSchemaImportPath = outputImportInfo.modulePath;
2589
+ routeConfig.outputSchemaImportedName = outputImportInfo.importedName;
2590
+ }
2591
+ }
2592
+ if (validatorInfo.stream !== undefined) routeConfig.stream = validatorInfo.stream;
2593
+ }
2594
+
2595
+ // Fall back to exported schemas
2596
+ if (!routeConfig.inputSchemaVariable && exportedInputSchemaName) {
2597
+ routeConfig.inputSchemaVariable = exportedInputSchemaName;
2598
+ }
2599
+ if (!routeConfig.outputSchemaVariable && exportedOutputSchemaName) {
2600
+ routeConfig.outputSchemaVariable = exportedOutputSchemaName;
2601
+ }
2602
+
2603
+ routes.push({
2604
+ id,
2605
+ method: chainMethod as 'get' | 'post' | 'put' | 'delete' | 'patch',
2606
+ type: type as 'api' | 'sms' | 'email' | 'cron',
2607
+ filename: rel,
2608
+ path: thepath,
2609
+ version,
2610
+ config: Object.keys(routeConfig).length > 0 ? routeConfig : undefined,
2611
+ });
2612
+ }
2613
+ }
2614
+ }
2312
2615
  } catch (error) {
2313
2616
  if (error instanceof InvalidRouterConfigError || error instanceof SchemaNotExportedError) {
2314
2617
  throw error;
@@ -84,6 +84,7 @@ export async function generateEntryFile(options: GenerateEntryOptions): Promise<
84
84
  ` createCompressionMiddleware,`,
85
85
  ` getAppState,`,
86
86
  ` getAppConfig,`,
87
+ ` getUserRouter,`,
87
88
  ` register,`,
88
89
  ` getSpanProcessors,`,
89
90
  ` createServices,`,
@@ -151,8 +152,25 @@ export async function generateEntryFile(options: GenerateEntryOptions): Promise<
151
152
  const apiMount =
152
153
  routeImportsAndMounts.length > 0
153
154
  ? `
154
- // Mount API routes
155
- ${routeImportsAndMounts.join('\n')}
155
+ // Apply middleware and mount API routes
156
+ // If user passed router(s) via createApp({ router }), mount those instead of discovered files
157
+ const __userMounts = getUserRouter();
158
+ if (__userMounts) {
159
+ for (const mount of __userMounts) {
160
+ // Apply Agentuity middleware (CORS, OTel, agent context) to each user-provided prefix
161
+ const prefix = mount.path.endsWith('/') ? mount.path + '*' : mount.path + '/*';
162
+ app.use(prefix, createCorsMiddleware());
163
+ app.use(prefix, createOtelMiddleware());
164
+ app.use(prefix, createAgentMiddleware(''));
165
+ app.route(mount.path, mount.router);
166
+ }
167
+ } else {
168
+ // File-based routing: apply middleware to /api/* and mount discovered route files
169
+ app.use('/api/*', createCorsMiddleware());
170
+ app.use('/api/*', createOtelMiddleware());
171
+ app.use('/api/*', createAgentMiddleware(''));
172
+ ${routeImportsAndMounts.map((line) => `\t${line}`).join('\n')}
173
+ }
156
174
  `
157
175
  : '';
158
176
 
@@ -682,22 +700,11 @@ app.use('*', createBaseMiddleware({
682
700
  meter: otel.meter,
683
701
  }));
684
702
 
685
- // Note: Workbench routes use their own CORS middleware (defined in createWorkbenchRouter)
686
- // which includes signature headers for production authentication
687
- app.use('/api/*', createCorsMiddleware());
688
-
689
- // Critical: otelMiddleware creates session/thread/waitUntilHandler
690
- // Only apply to routes that need full session tracking:
691
- // - /api/* routes (agent/API invocations)
692
- // - /_agentuity/workbench/* routes (workbench API)
693
- // Explicitly excluded (no session tracking, no Catalyst events):
694
- // - /_agentuity/webanalytics/* (web analytics - uses lightweight cookie-only middleware)
695
- // - /_agentuity/health, /_agentuity/ready, /_agentuity/idle (health checks)
703
+ // Workbench routes always get OTel middleware for session tracking
696
704
  app.use('/_agentuity/workbench/*', createOtelMiddleware());
697
- app.use('/api/*', createOtelMiddleware());
698
705
 
699
- // Critical: agentMiddleware sets up agent context
700
- app.use('/api/*', createAgentMiddleware(''));
706
+ // Note: /api/* middleware (CORS, OTel, agent context) is applied in Step 6
707
+ // after app.ts import, so user-provided routers can specify custom prefixes.
701
708
 
702
709
  // Step 4: Import user's app.ts (runs createApp, gets state/config)
703
710
  await import('../../app.js');
@@ -732,7 +732,8 @@ export async function generateRouteRegistry(
732
732
  resolvedPath = `../api/${finalPath}`;
733
733
  } else if (resolvedPath.startsWith('./') || resolvedPath.startsWith('../')) {
734
734
  // Resolve relative import from route file's directory
735
- const normalizedFilename = toForwardSlash(route.filename);
735
+ // Use schemaSourceFile when available (sub-router routes)
736
+ const normalizedFilename = toForwardSlash(route.schemaSourceFile ?? route.filename);
736
737
  const routeDir = normalizedFilename.substring(0, normalizedFilename.lastIndexOf('/'));
737
738
  // Join and normalize the path
738
739
  const joined = `${routeDir}/${resolvedPath}`;
@@ -793,15 +794,19 @@ export async function generateRouteRegistry(
793
794
 
794
795
  if (route.inputSchemaImportPath) {
795
796
  // Schema is imported - rebase the import path from route file to generated file
796
- importPath = rebaseImportPath(route.filename, route.inputSchemaImportPath, srcDir);
797
+ // Use schemaSourceFile if available (sub-router routes carry the original file)
798
+ const sourceFile = route.schemaSourceFile ?? route.filename;
799
+ importPath = rebaseImportPath(sourceFile, route.inputSchemaImportPath, srcDir);
797
800
  // Use the actual exported name (handles aliased imports like `import { A as B }`)
798
801
  schemaNameToImport =
799
802
  route.inputSchemaImportedName === 'default'
800
803
  ? route.inputSchemaVariable
801
804
  : (route.inputSchemaImportedName ?? route.inputSchemaVariable);
802
805
  } else {
803
- // Schema is locally defined - import from the route file
804
- const filename = toForwardSlash(route.filename);
806
+ // Schema is locally defined - import from the file that defines/exports it
807
+ // When route is mounted via .route(), schemaSourceFile points to the sub-router file
808
+ const sourceFile = route.schemaSourceFile ?? route.filename;
809
+ const filename = toForwardSlash(sourceFile);
805
810
  const withoutSrc = filename.startsWith('src/') ? filename.substring(4) : filename;
806
811
  const withoutLeadingDot = withoutSrc.startsWith('./')
807
812
  ? withoutSrc.substring(2)
@@ -828,15 +833,17 @@ export async function generateRouteRegistry(
828
833
 
829
834
  if (route.outputSchemaImportPath) {
830
835
  // Schema is imported - rebase the import path from route file to generated file
831
- importPath = rebaseImportPath(route.filename, route.outputSchemaImportPath, srcDir);
836
+ const sourceFile = route.schemaSourceFile ?? route.filename;
837
+ importPath = rebaseImportPath(sourceFile, route.outputSchemaImportPath, srcDir);
832
838
  // Use the actual exported name (handles aliased imports like `import { A as B }`)
833
839
  schemaNameToImport =
834
840
  route.outputSchemaImportedName === 'default'
835
841
  ? route.outputSchemaVariable
836
842
  : (route.outputSchemaImportedName ?? route.outputSchemaVariable);
837
843
  } else {
838
- // Schema is locally defined - import from the route file
839
- const filename = toForwardSlash(route.filename);
844
+ // Schema is locally defined - import from the file that defines/exports it
845
+ const sourceFile = route.schemaSourceFile ?? route.filename;
846
+ const filename = toForwardSlash(sourceFile);
840
847
  const withoutSrc = filename.startsWith('src/') ? filename.substring(4) : filename;
841
848
  const withoutLeadingDot = withoutSrc.startsWith('./')
842
849
  ? withoutSrc.substring(2)
@@ -858,19 +865,37 @@ export async function generateRouteRegistry(
858
865
  }
859
866
  });
860
867
 
861
- // Generate schema imports with unique aliases to avoid conflicts
868
+ // Generate schema imports, only aliasing when names collide across files
862
869
  const schemaImportAliases = new Map<string, Map<string, string>>(); // importPath -> (schemaName -> alias)
863
- let aliasCounter = 0;
870
+
871
+ // First pass: count how many times each schema name appears across all import paths
872
+ const globalNameCount = new Map<string, number>();
873
+ routeFileImports.forEach((schemas) => {
874
+ for (const schemaName of schemas) {
875
+ globalNameCount.set(schemaName, (globalNameCount.get(schemaName) ?? 0) + 1);
876
+ }
877
+ });
878
+
879
+ // Track aliases assigned to duplicated names for uniqueness
880
+ const duplicateCounters = new Map<string, number>();
864
881
 
865
882
  routeFileImports.forEach((schemas, importPath) => {
866
883
  const aliases = new Map<string, string>();
867
884
  const importParts: string[] = [];
868
885
 
869
886
  for (const schemaName of Array.from(schemas)) {
870
- // Create a unique alias for this schema to avoid collisions
871
- const alias = `${schemaName}_${aliasCounter++}`;
872
- aliases.set(schemaName, alias);
873
- importParts.push(`${schemaName} as ${alias}`);
887
+ if ((globalNameCount.get(schemaName) ?? 0) > 1) {
888
+ // Name appears in multiple import paths — alias to avoid collision
889
+ const counter = duplicateCounters.get(schemaName) ?? 0;
890
+ duplicateCounters.set(schemaName, counter + 1);
891
+ const alias = `${schemaName}_${counter}`;
892
+ aliases.set(schemaName, alias);
893
+ importParts.push(`${schemaName} as ${alias}`);
894
+ } else {
895
+ // Unique name — import directly, no alias needed
896
+ aliases.set(schemaName, schemaName);
897
+ importParts.push(schemaName);
898
+ }
874
899
  }
875
900
 
876
901
  schemaImportAliases.set(importPath, aliases);