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/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/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", 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) +})