From f4f70a9195819ac55a622490aad3d3fdd44254c0 Mon Sep 17 00:00:00 2001 From: Simone Stefani Date: Tue, 27 Oct 2026 15:40:37 +0000 Subject: [PATCH 1/2] fix(core): support several generateRegistry hooks The routes scanner holds a single filter, so the last registry's filter won and every output got the same routes. --- .changeset/multiple-registries.md | 5 +++ .../core/src/backend/generate_registry.ts | 20 ++++++++- .../core/src/backend/registry_generator.ts | 2 +- packages/core/tests/generate_registry.spec.ts | 43 +++++++++++++++++++ 4 files changed, 67 insertions(+), 3 deletions(-) create mode 100644 .changeset/multiple-registries.md diff --git a/.changeset/multiple-registries.md b/.changeset/multiple-registries.md new file mode 100644 index 0000000..98e6971 --- /dev/null +++ b/.changeset/multiple-registries.md @@ -0,0 +1,5 @@ +--- +'@tuyau/core': patch +--- + +Allow `generateRegistry` to be registered several times, each with its own `output` and `routes` filter. The assembler's routes scanner holds a single filter, so a second registry overwrote the first one's filter and every output got the same routes. diff --git a/packages/core/src/backend/generate_registry.ts b/packages/core/src/backend/generate_registry.ts index c7ae9ce..51659c7 100644 --- a/packages/core/src/backend/generate_registry.ts +++ b/packages/core/src/backend/generate_registry.ts @@ -1,13 +1,23 @@ import './types.ts' import type { AllHooks } from '@adonisjs/assembler/types' +import type { RoutesScanner } from '@adonisjs/assembler/routes_scanner' import { RegistryGenerator } from './registry_generator.ts' import { GenerateRegistryConfig } from './types.ts' +/** + * Generators sharing a routes scanner. The scanner holds a single filter, + * so it must keep every route that at least one registry needs. + */ +const generatorsByScanner = new WeakMap>() + /** * AdonisJS assembler hook that scans routes and generates * the tuyau typed client registry files (runtime, schema, tree). + * + * Can be registered several times with different `output` and `routes` + * options to generate several registries from the same routes. */ export function generateRegistry(options?: GenerateRegistryConfig): AllHooks['init'][number] { const generator = new RegistryGenerator(options) @@ -16,12 +26,18 @@ export function generateRegistry(options?: GenerateRegistryConfig): AllHooks['in return { async run(_, hooks) { hooks.add('routesScanning', (_, routesScanner) => { - routesScanner.filter((route) => generator.filterRoute(route)) + const generators = generatorsByScanner.get(routesScanner) ?? new Set() + generators.add(generator) + generatorsByScanner.set(routesScanner, generators) + + routesScanner.filter((route) => [...generators].some((each) => each.filterRoute(route))) }) hooks.add('routesScanned', async (devServer, routesScanner) => { const startTime = process.hrtime() - const routes = routesScanner.getScannedRoutes() + const routes = routesScanner + .getScannedRoutes() + .filter((route) => generator.filterRoute(route)) await generator.writeOutput({ outputDir, routes }) diff --git a/packages/core/src/backend/registry_generator.ts b/packages/core/src/backend/registry_generator.ts index b66177f..8b157e6 100644 --- a/packages/core/src/backend/registry_generator.ts +++ b/packages/core/src/backend/registry_generator.ts @@ -41,7 +41,7 @@ export class RegistryGenerator { * Check if a route passes the configured only/except filters. * Returns true when no filters are set. */ - filterRoute(route: RoutesListItem): boolean { + filterRoute(route: Pick): boolean { if (!this.#routesFilter) return true const { only, except } = this.#routesFilter diff --git a/packages/core/tests/generate_registry.spec.ts b/packages/core/tests/generate_registry.spec.ts index 9e97ba1..26fb5ff 100644 --- a/packages/core/tests/generate_registry.spec.ts +++ b/packages/core/tests/generate_registry.spec.ts @@ -2,6 +2,7 @@ import { test } from '@japa/runner' import type { ScannedRoute, RoutesListItem } from '@adonisjs/assembler/types' import { RegistryGenerator } from '../src/backend/registry_generator.ts' +import { generateRegistry } from '../src/backend/generate_registry.ts' function makeRoute(overrides: Partial & { name: string }): ScannedRoute { return { @@ -533,3 +534,45 @@ test.group('generateTypesContent | rawResponse union', () => { assert.match(content, /import type \{[^}]*ExtractRawResponse[^}]*\} from '@tuyau\/core\/types'/) }) }) + +test.group('generateRegistry hook', () => { + test('generates several registries with their own route filters', async ({ assert, fs }) => { + const routes = ['users.index', 'admin.dashboard', 'v1.users'].map((name) => makeRoute({ name })) + + // Like the assembler's scanner, it holds a single filter: the last one set wins + let scannerFilter = (_route: ScannedRoute) => true + const routesScanner = { + filter: (filterFn: typeof scannerFilter) => (scannerFilter = filterFn), + getScannedRoutes: () => routes.filter((route) => scannerFilter(route)), + } + + const scanning: Function[] = [] + const scanned: Function[] = [] + const hooks = { + add: (event: string, handler: Function) => + (event === 'routesScanning' ? scanning : scanned).push(handler), + } + const devServer = { ui: { logger: { info: () => {} } } } + + for (const hook of [ + generateRegistry({ output: `${fs.basePath}/web`, routes: { except: [/^admin\./] } }), + generateRegistry({ output: `${fs.basePath}/admin`, routes: { only: [/^admin\./] } }), + ]) { + await (hook as any).run({}, hooks) + } + + for (const handler of scanning) await handler(devServer, routesScanner) + for (const handler of scanned) await handler(devServer, routesScanner) + + const web = await fs.contents('web/index.ts') + const admin = await fs.contents('admin/index.ts') + + assert.include(web, "'users.index'") + assert.include(web, "'v1.users'") + assert.notInclude(web, "'admin.dashboard'") + + assert.include(admin, "'admin.dashboard'") + assert.notInclude(admin, "'users.index'") + assert.notInclude(admin, "'v1.users'") + }) +}) From 8d1b30bd9e2ffa34af7aaa0b73e97c72b9022c87 Mon Sep 17 00:00:00 2001 From: Julien Ripouteau Date: Tue, 27 Oct 2026 16:29:19 +0000 Subject: [PATCH 2/2] feat(core): add registry-local route type helpers Export Route and Path helpers from each generated registry and allow secondary registries to disable global augmentation. Keep augmentation enabled by default for Inertia compatibility. Apply independent route filters when several registry hooks share a scanner, and make regex filters repeatable. Co-authored-by: Simone Stefani Amp-Thread-ID: https://ampcode.com/threads/T-01a17e12-c61e-7508-ac05-d5676d3d8cf3 --- .changeset/registry-local-helpers.md | 7 + packages/core/README.md | 54 ++++- .../core/src/backend/registry_generator.ts | 64 +++++- packages/core/src/backend/types.ts | 8 + packages/core/src/client/types/types.ts | 10 +- packages/core/tests/generate_registry.spec.ts | 209 ++++++++++++++++-- 6 files changed, 311 insertions(+), 41 deletions(-) create mode 100644 .changeset/registry-local-helpers.md diff --git a/.changeset/registry-local-helpers.md b/.changeset/registry-local-helpers.md new file mode 100644 index 0000000..9bee486 --- /dev/null +++ b/.changeset/registry-local-helpers.md @@ -0,0 +1,7 @@ +--- +'@tuyau/core': minor +--- + +Export registry-local `Route` and `Path` type helpers from each generated registry. Add `typeAugmentation: false` to disable the global `UserRegistry` augmentation for secondary registries. The default remains `true` for compatibility with existing core helpers and AdonisJS Inertia. + +Support multiple `generateRegistry` hooks with independent route filters, and make global and sticky regex filters repeatable across scanning and output generation. diff --git a/packages/core/README.md b/packages/core/README.md index 126b951..6ddcc99 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -2,9 +2,59 @@ Core package for Tuyau. Includes the backend integration and the E2E typesafe client to consume your AdonisJS APIs. -## Documentation +## Registry types -Available soon. +Each generated registry exports its own `Route` and `Path` type helpers. Their names and paths are limited to that registry. + +```ts +import type { Route, Path } from '~generated/registry' + +type User = Route.Response<'users.show'> +type CreateUser = Route.Body<'users.store'> +type UserParams = Path.Params<'GET', '/users/:id'> +``` + +Both helpers provide `Request`, `Response`, `Params`, `Body`, `Query`, and `Error`. `Route` accepts a route name. `Path` accepts an HTTP method and a URL pattern. + +## Multiple registries + +Several `generateRegistry` hooks can generate separate registries with independent route-name filters: + +```ts +import { generateRegistry } from '@tuyau/core/hooks' + +// Inside defineConfig in adonisrc.ts +hooks: { + init: [ + generateRegistry({ + output: './.adonisjs/client/public', + routes: { except: [/^admin\./] }, + typeAugmentation: false, + }), + generateRegistry({ + output: './.adonisjs/client/admin', + routes: { only: [/^admin\./] }, + typeAugmentation: false, + }), + ], +}, +``` + +Each client receives its own registry through `createTuyau({ registry, baseUrl })`. Type imports can coexist in the same TypeScript program: + +```ts +import type { Route as PublicRoute } from '~generated/public' +import type { Route as AdminRoute } from '~generated/admin' + +type User = PublicRoute.Response<'users.show'> +type Dashboard = AdminRoute.Response<'admin.dashboard'> +``` + +`typeAugmentation` defaults to `true` for compatibility with the global `Route` and `Path` helpers exported by `@tuyau/core/types`. At most one registry in a TypeScript program can enable it. This also applies when a `tsconfig` includes generated files without importing them. + +**AdonisJS Inertia still requires the global augmentation for route typing.** Its React and Vue `Link`, `Form`, `useRouter`, and `useHttp` helpers read `UserRegistry`. Keep `typeAugmentation: true` on the registry used by Inertia and disable it on the other registries. The local helpers do not replace Inertia's global route selection. + +Tuyau's React Query, Vue Query, and Svelte Query integrations infer routes from their client and do not require this augmentation. ## License diff --git a/packages/core/src/backend/registry_generator.ts b/packages/core/src/backend/registry_generator.ts index 8b157e6..e315bb9 100644 --- a/packages/core/src/backend/registry_generator.ts +++ b/packages/core/src/backend/registry_generator.ts @@ -13,13 +13,17 @@ const DEFAULT_VALIDATION_ERROR_TYPE = '{ errors: SimpleError[] }' */ export class RegistryGenerator { #validationErrorType: string | false + #typeAugmentation: boolean #routesFilter?: GenerateRegistryConfig['routes'] - constructor(options?: Partial>) { + constructor( + options?: Pick, + ) { this.#validationErrorType = options?.validationErrorType === undefined ? DEFAULT_VALIDATION_ERROR_TYPE : options.validationErrorType + this.#typeAugmentation = options?.typeAugmentation ?? true this.#routesFilter = options?.routes } @@ -31,7 +35,13 @@ export class RegistryGenerator { pattern: string | RegExp | ((routeName: string) => boolean), ): boolean { if (typeof pattern === 'string') return routeName.includes(pattern) - if (pattern instanceof RegExp) return pattern.test(routeName) + if (pattern instanceof RegExp) { + const lastIndex = pattern.lastIndex + pattern.lastIndex = 0 + const matches = pattern.test(routeName) + pattern.lastIndex = lastIndex + return matches + } if (typeof pattern === 'function') return pattern(routeName) return false @@ -282,12 +292,24 @@ export class RegistryGenerator { const registryEntries = routes .map((route) => this.generateRuntimeRegistryEntry(route)) .join(',\n') + const moduleAugmentation = this.#typeAugmentation + ? ` +declare module '@tuyau/core/types' { + export interface UserRegistry { + routes: typeof routes + $tree: ApiDefinition + } +} +` + : '' return `/* eslint-disable prettier/prettier */ import type { AdonisEndpoint } from '@tuyau/core/types' import type { Registry } from './schema.d.ts' import type { ApiDefinition } from './tree.d.ts' +export type { Route, Path } from './schema.d.ts' + const placeholder: any = {} const routes = { @@ -300,14 +322,7 @@ export const registry = { routes, $tree: {} as ApiDefinition, } - -declare module '@tuyau/core/types' { - export interface UserRegistry { - routes: typeof routes - $tree: ApiDefinition - } -} -` +${moduleAugmentation}` } /** @@ -325,6 +340,10 @@ declare module '@tuyau/core/types' { 'ExtractQueryForGet', 'ExtractRawResponse', 'ExtractResponse', + 'Method', + 'PathWithRegistry', + 'PatternsByMethod', + 'RouteWithRegistry', ] const vineImports = ['InferInput'] if (useDefaultValidationType) vineImports.push('SimpleError') @@ -334,12 +353,37 @@ declare module '@tuyau/core/types' { import type { ${coreImports.join(', ')} } from '@tuyau/core/types' import type { ${vineImports.join(', ')} } from '@vinejs/vine/types' +import type { routes } from './index.ts' export type ParamValue = string | number | bigint | boolean export interface Registry { ${registryEntries} } + +export namespace Route { + export type Request = RouteWithRegistry.Request + export type Response = RouteWithRegistry.Response + export type Params = RouteWithRegistry.Params + export type Body = RouteWithRegistry.Body + export type Query = RouteWithRegistry.Query + export type Error = RouteWithRegistry.Error +} + +export namespace Path { + export type Request> = + PathWithRegistry.Request + export type Response> = + PathWithRegistry.Response + export type Params> = + PathWithRegistry.Params + export type Body> = + PathWithRegistry.Body + export type Query> = + PathWithRegistry.Query + export type Error> = + PathWithRegistry.Error +} ` } diff --git a/packages/core/src/backend/types.ts b/packages/core/src/backend/types.ts index 6342d7b..e0a15d7 100644 --- a/packages/core/src/backend/types.ts +++ b/packages/core/src/backend/types.ts @@ -62,6 +62,14 @@ export interface GenerateRegistryConfig { */ output?: string + /** + * Augment the global UserRegistry used by the core Route/Path helpers + * and AdonisJS Inertia. Disable for secondary registries in the same + * TypeScript program and import their local Route/Path helpers instead. + * @default true + */ + typeAugmentation?: boolean + /** * Routes filtering configuration */ diff --git a/packages/core/src/client/types/types.ts b/packages/core/src/client/types/types.ts index 13ba395..18bcdd1 100644 --- a/packages/core/src/client/types/types.ts +++ b/packages/core/src/client/types/types.ts @@ -332,11 +332,11 @@ export type RegValues = R[StrKeys] /** * Gets URL patterns for endpoints matching a specific HTTP method + * Defers indexing until the method is resolved so concrete registries + * can forward their method and pattern generics to PathWithRegistry. */ -export type PatternsByMethod< - Reg extends Record, - M extends Method, -> = EndpointsByMethod['pattern'] +export type PatternsByMethod, M extends Method> = + EndpointsByMethod extends infer E extends AdonisEndpoint ? E['pattern'] : never /** * Finds an endpoint by HTTP method and URL pattern @@ -395,7 +395,7 @@ type UserEndpointByName = UserAdonisRegis type FilterByMethodPathForRegistry< Reg extends Record, M extends Method, - P extends ValueOf['pattern'] & string, + P extends string, > = { [K in keyof Reg]: Reg[K]['pattern'] extends P ? M extends Reg[K]['methods'][number] diff --git a/packages/core/tests/generate_registry.spec.ts b/packages/core/tests/generate_registry.spec.ts index 26fb5ff..1d70d37 100644 --- a/packages/core/tests/generate_registry.spec.ts +++ b/packages/core/tests/generate_registry.spec.ts @@ -1,8 +1,11 @@ +import { fileURLToPath } from 'node:url' +import ts from 'typescript' import { test } from '@japa/runner' +import { RoutesScanner } from '@adonisjs/assembler/routes_scanner' import type { ScannedRoute, RoutesListItem } from '@adonisjs/assembler/types' -import { RegistryGenerator } from '../src/backend/registry_generator.ts' import { generateRegistry } from '../src/backend/generate_registry.ts' +import { RegistryGenerator } from '../src/backend/registry_generator.ts' function makeRoute(overrides: Partial & { name: string }): ScannedRoute { return { @@ -536,43 +539,201 @@ test.group('generateTypesContent | rawResponse union', () => { }) test.group('generateRegistry hook', () => { - test('generates several registries with their own route filters', async ({ assert, fs }) => { - const routes = ['users.index', 'admin.dashboard', 'v1.users'].map((name) => makeRoute({ name })) - - // Like the assembler's scanner, it holds a single filter: the last one set wins - let scannerFilter = (_route: ScannedRoute) => true - const routesScanner = { - filter: (filterFn: typeof scannerFilter) => (scannerFilter = filterFn), - getScannedRoutes: () => routes.filter((route) => scannerFilter(route)), - } - - const scanning: Function[] = [] - const scanned: Function[] = [] + test('keeps independent filters, including global and sticky regexes', async ({ assert, fs }) => { + const events = { routesScanning: [] as Function[], routesScanned: [] as Function[] } const hooks = { - add: (event: string, handler: Function) => - (event === 'routesScanning' ? scanning : scanned).push(handler), + add: (event: keyof typeof events, handler: Function) => events[event].push(handler), } - const devServer = { ui: { logger: { info: () => {} } } } + const devServer = { ui: { logger: { info() {} } } } for (const hook of [ - generateRegistry({ output: `${fs.basePath}/web`, routes: { except: [/^admin\./] } }), - generateRegistry({ output: `${fs.basePath}/admin`, routes: { only: [/^admin\./] } }), + generateRegistry({ + output: `${fs.basePath}/web`, + routes: { only: [/^(users|shared)\./g] }, + typeAugmentation: false, + }), + generateRegistry({ + output: `${fs.basePath}/admin`, + routes: { only: [/^(admin|shared)\./y] }, + typeAugmentation: false, + }), ]) { await (hook as any).run({}, hooks) } - for (const handler of scanning) await handler(devServer, routesScanner) - for (const handler of scanned) await handler(devServer, routesScanner) + const scanner = new RoutesScanner(fs.basePath, []) + for (const handler of events.routesScanning) await handler(devServer, scanner) + await scanner.scan( + ['users.index', 'admin.dashboard', 'shared.health', 'internal.health'].map((name) => + makeRoute({ name }), + ), + ) + assert.deepEqual( + scanner.getScannedRoutes().map((route) => route.name), + ['users.index', 'admin.dashboard', 'shared.health'], + ) + for (const handler of events.routesScanned) await handler(devServer, scanner) const web = await fs.contents('web/index.ts') const admin = await fs.contents('admin/index.ts') - assert.include(web, "'users.index'") - assert.include(web, "'v1.users'") + assert.include(web, "'shared.health'") assert.notInclude(web, "'admin.dashboard'") - + assert.notInclude(web, "'internal.health'") assert.include(admin, "'admin.dashboard'") + assert.include(admin, "'shared.health'") assert.notInclude(admin, "'users.index'") - assert.notInclude(admin, "'v1.users'") + assert.notInclude(admin, "'internal.health'") }) }) + +test.group('generated registry types', () => { + test('exports isolated helpers with optional legacy augmentation') + .with([true, false]) + .run(async ({ assert, fs }, augment) => { + await fs.create('manifest.d.ts', 'export {}') + await new RegistryGenerator(augment ? undefined : { typeAugmentation: false }).writeOutput({ + outputDir: `${fs.basePath}/web`, + routes: [ + makeRoute({ + name: 'users.show', + pattern: '/users/:id', + tokens: [ + { old: '/users/', type: 0, val: 'users', end: '' }, + { old: ':id', type: 1, val: 'id', end: '' }, + ], + request: { type: '{ params: { id: string }; page: number }', imports: [] }, + response: { type: '{ name: string }', imports: [] }, + }), + makeRoute({ + name: 'users.store', + pattern: '/users', + methods: ['POST'], + request: { type: '{ email: string; query: { notify: boolean } }', imports: [] }, + response: { + type: '{ __response: { id: number }; __status: 201 } | { __response: { message: string }; __status: 409 }', + imports: [], + }, + }), + ], + }) + await new RegistryGenerator({ typeAugmentation: false }).writeOutput({ + outputDir: `${fs.basePath}/admin`, + routes: [ + makeRoute({ + name: 'admin.dashboard', + pattern: '/admin', + response: { type: '{ revenue: number }', imports: [] }, + }), + ], + }) + + await fs.create( + 'consumer.ts', + ` +import { registry as webRegistry } from './web/index.ts' +import { registry as adminRegistry } from './admin/index.ts' +import type { Route, Path } from './web/index.ts' +import type { Route as AdminRoute, Path as AdminPath } from './admin/index.ts' +import { createTuyau } from '@tuyau/core/client' +import type { UserRegistry, Route as GlobalRoute, Path as GlobalPath } from '@tuyau/core/types' + +type Equal = (() => T extends A ? 1 : 2) extends + (() => T extends B ? 1 : 2) ? true : false +type Check = T + +export type Contracts = [ + Check, { name: string }>>, + Check, { name: string }>>, + Check, { id: number }>>, + Check, { id: number }>>, + Check, { email: string }>>, + Check, { email: string }>>, + Check, { page: number }>>, + Check, { page: number }>>, + Check, { notify: boolean }>>, + Check, { notify: boolean }>>, + Check, { id: string | number | bigint | boolean }>>, + Check, { id: string | number | bigint | boolean }>>, + Check, { revenue: number }>>, + Check, { revenue: number }>>, + ${ + augment + ? ` + Check>, + Check>, + Check, { id: number }>>, + Check, { name: string }>>, + ` + : 'Check>,' + } +] + +export const request: Route.Request<'users.store'> = { + body: { email: 'alice@example.com' }, query: { notify: true }, +} +export const pathRequest: Path.Request<'POST', '/users'> = request + +export function checkErrors(route: Route.Error<'users.store'>, path: Path.Error<'POST', '/users'>) { + if (route.isStatus(409)) { + const message: string = route.response.message + // @ts-expect-error A conflict response does not have a success payload. + route.response.id + void message + } + if (path.isStatus(409)) { + const message: string = path.response.message + // @ts-expect-error Error payloads must not degrade to any. + path.response.id + void message + } +} + +createTuyau({ baseUrl: 'https://example.test', registry: webRegistry }).api.users.show({ params: { id: 1 }, query: { page: 2 } }) +createTuyau({ baseUrl: 'https://example.test', registry: adminRegistry }).api.admin.dashboard({}) + +// @ts-expect-error Admin routes are not part of the public registry. +type ForeignRoute = Route.Response<'admin.dashboard'> +// @ts-expect-error Public routes are not part of the admin registry. +type ForeignAdminRoute = AdminRoute.Response<'users.show'> +// @ts-expect-error Admin paths are not part of the public registry. +type ForeignPath = Path.Response<'GET', '/admin'> +// @ts-expect-error Public paths are not part of the admin registry. +type ForeignAdminPath = AdminPath.Response<'GET', '/users/:id'> +// @ts-expect-error The path must support the specified method. +type WrongMethod = Path.Response<'POST', '/users/:id'> +// @ts-expect-error The generated request body remains validated. +export const invalidRequest: Path.Request<'POST', '/users'> = { body: { email: 123 } } +`, + ) + + const program = ts.createProgram([`${fs.basePath}/consumer.ts`], { + target: ts.ScriptTarget.ESNext, + module: ts.ModuleKind.NodeNext, + strict: true, + noEmit: true, + esModuleInterop: true, + skipLibCheck: false, + allowImportingTsExtensions: true, + paths: { + '@tuyau/core/types': [ + fileURLToPath(new URL('../src/client/types/index.ts', import.meta.url)), + ], + '@tuyau/core/client': [fileURLToPath(new URL('../src/client/index.ts', import.meta.url))], + '@vinejs/vine/types': [ + fileURLToPath(import.meta.resolve('@vinejs/vine/types')).replace(/\.js$/, '.d.ts'), + ], + }, + }) + assert.deepEqual( + ts + .getPreEmitDiagnostics(program) + .map( + (diagnostic) => + `${diagnostic.file?.fileName}: TS${diagnostic.code}: ${ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n').slice(0, 500)}`, + ), + [], + ) + }) + .timeout(30_000) +})