From 91fe0f5566ff092a728aab5445162b245b7d87ee Mon Sep 17 00:00:00 2001 From: Julien Ripouteau Date: Tue, 27 Oct 2026 16:29:19 +0000 Subject: [PATCH 1/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/generate_registry.ts | 17 +- .../core/src/backend/registry_generator.ts | 66 +++++- packages/core/src/backend/types.ts | 8 + packages/core/src/client/types/types.ts | 10 +- packages/core/tests/generate_registry.spec.ts | 204 ++++++++++++++++++ 7 files changed, 346 insertions(+), 20 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/generate_registry.ts b/packages/core/src/backend/generate_registry.ts index c7ae9ce..63e4920 100644 --- a/packages/core/src/backend/generate_registry.ts +++ b/packages/core/src/backend/generate_registry.ts @@ -1,13 +1,20 @@ 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' +/** + * The scanner holds a single filter, shared by all registry generators. + */ +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 outputs and route filters. */ export function generateRegistry(options?: GenerateRegistryConfig): AllHooks['init'][number] { const generator = new RegistryGenerator(options) @@ -16,12 +23,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..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 @@ -41,7 +51,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 @@ -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 9e97ba1..1d70d37 100644 --- a/packages/core/tests/generate_registry.spec.ts +++ b/packages/core/tests/generate_registry.spec.ts @@ -1,6 +1,10 @@ +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 { generateRegistry } from '../src/backend/generate_registry.ts' import { RegistryGenerator } from '../src/backend/registry_generator.ts' function makeRoute(overrides: Partial & { name: string }): ScannedRoute { @@ -533,3 +537,203 @@ test.group('generateTypesContent | rawResponse union', () => { assert.match(content, /import type \{[^}]*ExtractRawResponse[^}]*\} from '@tuyau\/core\/types'/) }) }) + +test.group('generateRegistry hook', () => { + test('keeps independent filters, including global and sticky regexes', async ({ assert, fs }) => { + const events = { routesScanning: [] as Function[], routesScanned: [] as Function[] } + const hooks = { + add: (event: keyof typeof events, handler: Function) => events[event].push(handler), + } + const devServer = { ui: { logger: { info() {} } } } + + for (const hook of [ + 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) + } + + 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, "'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, "'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) +}) From 18369242f2f32996de55e5648b85a3cecb32c27b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 27 Oct 2026 16:30:40 +0000 Subject: [PATCH 2/2] chore(release): version packages --- .changeset/registry-local-helpers.md | 7 ------- packages/core/CHANGELOG.md | 8 ++++++++ packages/core/package.json | 2 +- 3 files changed, 9 insertions(+), 8 deletions(-) delete mode 100644 .changeset/registry-local-helpers.md diff --git a/.changeset/registry-local-helpers.md b/.changeset/registry-local-helpers.md deleted file mode 100644 index 9bee486..0000000 --- a/.changeset/registry-local-helpers.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -'@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/CHANGELOG.md b/packages/core/CHANGELOG.md index 8db944e..4c895ca 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -1,5 +1,13 @@ # @tuyau/core +## 1.3.0 + +### Minor Changes + +- 91fe0f5: 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. + ## 1.2.3 ### Patch Changes diff --git a/packages/core/package.json b/packages/core/package.json index 497bf4d..dad08d8 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,7 +1,7 @@ { "name": "@tuyau/core", "type": "module", - "version": "1.2.3", + "version": "1.3.0", "description": "E2E typesafe client for AdonisJS", "author": "Julien Ripouteau ", "license": "MIT",