diff --git a/.changeset/modular-implementations-and-error-policies.md b/.changeset/modular-implementations-and-error-policies.md new file mode 100644 index 00000000..2f8176aa --- /dev/null +++ b/.changeset/modular-implementations-and-error-policies.md @@ -0,0 +1,9 @@ +--- +"@cleverbrush/server": minor +--- + +Add immutable, contract-bound implementation scopes and feature-module composition +with complete operation coverage, cross-file handler inference, shared/per-operation +DI, and unchanged HTTP/subscription registration. Add reusable, endpoint-checked +error policies and a standalone handler wrapper. Existing registration APIs and +the browser-safe contract entry point remain supported. diff --git a/.github/pr-evidence/contract-implementations.png b/.github/pr-evidence/contract-implementations.png new file mode 100644 index 00000000..ab584dfc Binary files /dev/null and b/.github/pr-evidence/contract-implementations.png differ diff --git a/libs/server-integration-tests/tests/implementations.test.ts b/libs/server-integration-tests/tests/implementations.test.ts new file mode 100644 index 00000000..a59fa3c9 --- /dev/null +++ b/libs/server-integration-tests/tests/implementations.test.ts @@ -0,0 +1,233 @@ +import { jwtScheme, signJwt } from '@cleverbrush/auth'; +import { object, string } from '@cleverbrush/schema'; +import { + ActionResult, + cacheResponse, + createServer, + defineApi, + endpoint, + errorMap, + implement, + type Middleware, + type Server +} from '@cleverbrush/server'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const secret = 'implementation-integration-test-secret'; +const Principal = object({ userId: string(), role: string() }); +const Db = object({ label: string() }); +const Message = object({ message: string() }); +class MissingItem extends Error {} + +describe('modular implementation HTTP pipeline', () => { + let server: Server | undefined; + afterEach(async () => { + await server?.close(); + vi.restoreAllMocks(); + }); + + async function start( + options: { middleware?: Middleware; missingService?: boolean } = {} + ) { + let reads = 0; + const translated = vi.fn(() => + ActionResult.notFound({ message: 'Item not found' }) + ); + const errors = errorMap().on(MissingItem, translated); + const resource = endpoint + .resource('/items') + .authorize(Principal, 'admin'); + const api = defineApi({ + items: { + get: resource + .get() + .responses({ 200: Message }) + .clearsCacheTag('items'), + update: resource + .post() + .body(object({ title: string() })) + .responses({ 200: Message, 404: Message }) + .clearsCacheTag('items'), + upload: endpoint + .post('/upload') + .authorize(Principal, 'admin') + .body(object({})) + .upload() + .responses({ 200: Message }) + } + }); + const scope = implement(api).group('items', { inject: { db: Db } }); + const module = scope.withHandlers({ + get: ({ principal }, { db }) => ({ + message: `${principal.userId}:${db.label}:${++reads}` + }), + update: { + errors, + middlewares: options.middleware ? [options.middleware] : [], + handler: ({ body }, { db }) => { + if (body.title === 'missing') + throw new MissingItem('Do not expose this detail'); + if (body.title === 'crash') + throw new Error('private database credentials'); + return { message: `${body.title}:${db.label}` }; + } + }, + upload: ({ files }, { db }) => ({ + message: `${files.image?.filename}:${db.label}` + }) + }); + const builder = createServer() + .useAuthentication({ + defaultScheme: 'jwt', + schemes: [ + jwtScheme({ + secret, + mapClaims: claims => ({ + userId: claims.sub as string, + role: claims.role as string + }) + }) + ] + }) + .useAuthorization() + .use(cacheResponse()) + .useBatching() + .handleAll(implement(api).use(module).complete()); + if (!options.missingService) + builder.services(services => + services.addSingleton(Db, { label: 'db' }) + ); + server = await builder.listen(0); + const base = `http://127.0.0.1:${server.address!.port}`; + const authorization = `Bearer ${signJwt({ sub: 'user-1', role: 'admin' }, secret)}`; + const request = (path: string, body?: unknown, auth = authorization) => + fetch(`${base}${path}`, { + method: body === undefined ? 'GET' : 'POST', + headers: { + authorization: auth, + 'content-type': 'application/json' + }, + body: body === undefined ? undefined : JSON.stringify(body) + }); + return { base, authorization, request, translated }; + } + + it('preserves authentication, role checks, validation and merged DI', async () => { + const { request, translated } = await start(); + const anonymous = await request('/items', undefined, ''); + expect(anonymous.status).toBe(401); + await anonymous.text(); + const viewer = await request( + '/items', + undefined, + `Bearer ${signJwt({ sub: 'viewer', role: 'viewer' }, secret)}` + ); + expect(viewer.status).toBe(403); + await viewer.text(); + const invalid = await request('/items', { title: 42 }); + expect(invalid.status).toBe(400); + expect(invalid.headers.get('content-type')).toContain( + 'application/problem+json' + ); + await invalid.text(); + expect(translated).not.toHaveBeenCalled(); + expect(await (await request('/items')).json()).toEqual({ + message: 'user-1:db:1' + }); + }); + + it('maps only known handler failures and logs sanitized unexpected failures', async () => { + const logged = vi.spyOn(console, 'error').mockImplementation(() => {}); + const { request, translated } = await start(); + const known = await request('/items', { title: 'missing' }); + expect(known.status).toBe(404); + expect(await known.json()).toEqual({ message: 'Item not found' }); + expect(logged).not.toHaveBeenCalled(); + const unknown = await request('/items', { title: 'crash' }); + expect(unknown.status).toBe(500); + const text = await unknown.text(); + expect(text).not.toContain('private database credentials'); + expect(logged).toHaveBeenCalledWith( + '[server] Unhandled error:', + expect.objectContaining({ message: 'private database credentials' }) + ); + expect(translated).toHaveBeenCalledTimes(1); + }); + + it('does not translate middleware or DI failures', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + const { request, translated } = await start({ + middleware: async () => { + throw new MissingItem('middleware'); + } + }); + const response = await request('/items', { title: 'valid' }); + expect(response.status).toBe(500); + await response.text(); + expect(translated).not.toHaveBeenCalled(); + await server!.close(); + const withoutService = await start({ missingService: true }); + const missing = await withoutService.request('/items', { + title: 'valid' + }); + expect(missing.status).toBe(500); + await missing.text(); + expect(withoutService.translated).not.toHaveBeenCalled(); + }); + + it('preserves cache invalidation, failed mutations and batched error responses', async () => { + const { request, authorization } = await start(); + expect(await (await request('/items')).json()).toEqual({ + message: 'user-1:db:1' + }); + expect(await (await request('/items')).json()).toEqual({ + message: 'user-1:db:1' + }); + await (await request('/items', { title: 'missing' })).text(); + expect(await (await request('/items')).json()).toEqual({ + message: 'user-1:db:1' + }); + await (await request('/items', { title: 'changed' })).text(); + expect(await (await request('/items')).json()).toEqual({ + message: 'user-1:db:2' + }); + const batch = await request('/__batch', { + requests: [ + { + method: 'POST', + url: '/items', + body: JSON.stringify({ title: 'missing' }), + headers: { + 'content-type': 'application/json', + authorization + } + } + ] + }); + expect(batch.status).toBe(200); + const { responses: result } = (await batch.json()) as { + responses: { status: number; body: string }[]; + }; + expect(result[0].status).toBe(404); + expect(JSON.parse(result[0].body)).toEqual({ + message: 'Item not found' + }); + }); + + it('preserves multipart upload contexts', async () => { + const { base, authorization } = await start(); + const data = new FormData(); + data.set( + 'image', + new Blob(['image data'], { type: 'text/plain' }), + 'sample.txt' + ); + const response = await fetch(`${base}/upload`, { + method: 'POST', + headers: { authorization }, + body: data + }); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ message: 'sample.txt:db' }); + }); +}); diff --git a/libs/server-openapi/src/implementations.test.ts b/libs/server-openapi/src/implementations.test.ts new file mode 100644 index 00000000..e0238975 --- /dev/null +++ b/libs/server-openapi/src/implementations.test.ts @@ -0,0 +1,83 @@ +import { object, string } from '@cleverbrush/schema'; +import { + ActionResult, + createServer, + defineApi, + endpoint, + errorMap, + implement, + mapHandlers +} from '@cleverbrush/server'; +import { expect, it } from 'vitest'; +import { generateOpenApiSpec } from './generateOpenApiSpec.js'; + +it('emits the same OpenAPI contract for modular and existing registrations', () => { + const Principal = object({ userId: string() }); + const Db = object({ name: string() }); + const Message = object({ message: string() }); + const api = defineApi({ + items: { + create: endpoint + .post('/items') + .body(object({ title: string() })) + .authorize(Principal, 'admin') + .responses({ 201: Message, 404: Message }) + .clearsCacheTag('items') + } + }); + const scope = implement(api).group('items', { + inject: { db: Db }, + tags: ['items'], + operations: { + create: { + summary: 'Create item', + description: 'Create a new item.', + operationId: 'createItem', + deprecated: true + } + } + }); + const handler = () => ActionResult.created({ message: 'created' }); + const modular = createServer().handleAll( + implement(api) + .use( + scope.withHandlers({ + create: { + handler, + errors: errorMap().on(Error, () => + ActionResult.notFound({ message: 'Missing' }) + ) + } + }) + ) + .complete() + ); + const previous = createServer().handleAll( + mapHandlers( + { + items: { + create: api.items.create + .inject({ db: Db }) + .tags('items') + .summary('Create item') + .description('Create a new item.') + .operationId('createItem') + .deprecated() + } + }, + { items: { create: handler } } + ) + ); + const info = { title: 'Items', version: '1' }; + expect( + generateOpenApiSpec({ + info, + registrations: [...modular.getRegistrations()] + }) + ).toEqual( + generateOpenApiSpec({ + info, + registrations: [...previous.getRegistrations()] + }) + ); +}); diff --git a/libs/server/README.md b/libs/server/README.md index 52a224bf..ba422ef2 100644 --- a/libs/server/README.md +++ b/libs/server/README.md @@ -20,6 +20,232 @@ A schema-first HTTP server framework for Node.js. Combines [`@cleverbrush/schema - **Health check** — optional `/health` endpoint via `server.withHealthcheck()`. - **WebSocket subscriptions** — `endpoint.subscription('/ws/path')` with typed incoming/outgoing schemas, `tracked()` events, and async generator handlers. - **Contract composition** — `mergeContracts`, `pickGroups`, and `omitGroups` enable audience-scoped bundles: ship only the endpoints each consumer needs. +- **Modular implementations** — `implement(api)` derives server-configured scopes, keeps separate handler files strongly typed, and checks full contract coverage at final registration. +- **Typed error policies** — `errorMap()` and `withErrors()` translate known handler exceptions without repeated catch blocks or widening endpoint responses. + +## Large APIs and shared error handling + +`implement` adds server-only configuration and complete handler registration to +an existing shared API contract. It does not replace endpoint builders, change +the wire contract, or require business logic inside one fluent expression. +`errorMap` and `withErrors` also work independently of this registration API. + +### A complete multi-file example + +The application functions and service registration below are application-owned. +The contract in this example has exactly two operations, so its root is complete. + +#### Shared contract + +```ts +// contracts.ts — browser-safe; no server configuration or handler imports +import { defineApi, endpoint, route } from '@cleverbrush/server/contract'; +import { array, number, object, string } from '@cleverbrush/schema'; + +export const Item = object({ id: number(), title: string() }); +const Message = object({ message: string() }); +export const Principal = object({ userId: string() }); +const resource = endpoint.resource('/items').authorize(Principal); + +export const api = defineApi({ + items: { + list: resource.get().responses({ 200: array(Item) }), + remove: resource.delete( + route({ id: number().coerce() })`/${p => p.id}` + ).responses({ 204: null, 404: Message }) + } +}); +``` + +#### Server configuration without handlers + +```ts +// features/items/scope.ts +import { implement } from '@cleverbrush/server'; +import { api } from '../../contracts.js'; +import { DbToken } from '../../di/tokens.js'; + +export const items = implement(api).group('items', { + inject: { db: DbToken }, + tags: ['items'], + operations: { + list: { summary: 'List items', operationId: 'listItems' }, + remove: { summary: 'Remove an item', operationId: 'removeItem' } + } +}); +``` + +`items.endpoints` contains immutable, server-configured builders. Export the +scope constant and reference it with `typeof`; do not annotate it with a broad +base type that erases operation names or service types. + +#### Separate handlers + +```ts +// features/items/handlers/list.ts +import type { Handler } from '@cleverbrush/server'; +import type { items } from '../scope.js'; +import { listItems } from '../../../application/items.js'; + +export const list: Handler = ( + { principal }, { db } +) => listItems(db, principal.userId); +``` + +```ts +// features/items/handlers/remove.ts +import { ActionResult, type Handler } from '@cleverbrush/server'; +import type { items } from '../scope.js'; +import { removeItem } from '../../../application/items.js'; + +export const remove: Handler = async ( + { params, principal }, { db } +) => { + await removeItem(db, principal.userId, params.id); + return ActionResult.noContent(); +}; +``` + +The application function can throw a domain exception. Handlers do not need a +repeated catch block. Requests, principals, injected services, and responses are +derived at the handler's declaration site, not inferred retrospectively from a +later registration call. Type-only scope imports avoid runtime import cycles. + +#### Application-owned policy and feature registration + +```ts +// features/items/errors.ts +import { ActionResult, errorMap } from '@cleverbrush/server'; +import { MissingItemError } from '../../application/errors.js'; + +export const itemErrors = errorMap().on(MissingItemError, () => + ActionResult.notFound({ message: 'Item not found' }) +); +``` + +```ts +// features/items/index.ts +import { items } from './scope.js'; +import { itemErrors } from './errors.js'; +import { list } from './handlers/list.js'; +import { remove } from './handlers/remove.js'; + +export const itemsModule = items.withHandlers({ + list, + remove: { handler: remove, errors: itemErrors } +}); +``` + +Attaching this policy to `list` is a type error: `list` does not declare 404. +Declaring 204 on `remove` does not permit arbitrary other statuses or bodies. + +```ts +// server.ts — after the application's ordinary server/DI setup +import { implement } from '@cleverbrush/server'; +import { api } from './contracts.js'; +import { itemsModule } from './features/items/index.js'; + +server.handleAll(implement(api).use(itemsModule).complete()); +``` + +`complete()` fails at compile time if an operation is missing, and checks again +at runtime for JavaScript callers and erased types. The output is the existing +`HandlerMapping`. Authentication, validation, caching, batching, middleware, +serialization, logging, and OpenAPI generation use their existing paths. + +### Split large features and compose contract slices + +```ts +export const reads = items.pick('list').withHandlers({ list }); +export const writes = items.pick('remove').withHandlers({ + remove: { handler: remove, errors: itemErrors } +}); +export const feature = implement(api).use(reads, writes); + +// Partial roots can be exported and composed; finalize only at the full root. +server.handleAll(implement(api).use(feature).complete()); +``` + +Other groups are separate feature modules added to the same `use(...)` call. +Pass modules directly or in a literal tuple, not an array widened to a generic +module type. Duplicate bindings are rejected across modules and calls. Source +endpoint identity is checked at runtime as well: independently recreated +endpoints are not interchangeable just because their TypeScript shapes match. +`pickGroups`, `omitGroups`, and `mergeContracts` preserve endpoint references, +so intentional contract slices can be implemented and recomposed. + +Subscriptions use the same scopes, `SubscriptionHandler`, +and existing subscription handler descriptors. They count toward completeness. +HTTP error policies are not accepted on subscription handlers and do not change +the WebSocket error protocol. + +### Configuration rules + +- `inject` merges contract bindings, then group bindings, then operation bindings. + A same-name binding is replaced by the later value in both the type and runtime + service map. Other dependencies remain available. Endpoint `.inject()` itself + retains its existing replacement behavior. +- An operation's `authorize` schema takes precedence over a group's schema; + existing endpoint roles are retained. Supplying authorization explicitly makes + even a previously public operation authenticated. Omit it when authorization + already lives entirely in the shared contract. There is no implicit auth default. +- Operation `tags` replace group tags; group tags replace existing tags. Other + metadata changes only when supplied: `summary`, `description`, `operationId`, + and `deprecated: true`. Deprecation is additive and cannot be cleared here. +- Request/response schemas, cache definitions, upload settings, links, examples, + and other endpoint metadata remain on the shared endpoint. Scopes do not edit + the wire contract or silently add error responses. +- Handler descriptors support existing per-operation `middlewares`. Configuration + scopes do not register anything until explicitly bound and composed. + +### Error policy behavior and limits + +Policies are immutable. Each `.on(ErrorClass, translator)` returns a new policy. +Rules match by `instanceof` in declaration order; put subclasses before base +classes. Both handlers and translators may be synchronous or asynchronous. + +Translators return explicit JSON or bodyless `ActionResult` values. Every result +must fit the target endpoint's `.responses()` status/body map. Policies cannot +use raw/file/stream results to escape that check, and endpoints without explicit +responses cannot attach a policy. TypeScript escape hatches (`any`, assertions, +or deliberately erased annotations) still bypass static guarantees; this is not +a replacement for runtime response validation. + +Only exceptions from the handler invocation are translated. Authentication, +request validation, DI resolution, middleware, and response serialization are +outside the wrapper. Unknown thrown values are rethrown unchanged. If a translator +fails, its failure propagates without recursively applying the policy again. +Existing centralized handling still logs unexpected failures and sends safe 500 +Problem Details. There is no automatic catch-all or exposure of `Error.message`. + +Application authors decide which errors are safe to map and which details can +be returned. Framework has no knowledge of domain ownership or privacy rules. + +### Incremental adoption + +The old `handle` and `mapHandlers` APIs remain unchanged. Adopt just error policies +without migrating registration: + +```ts +import { withErrors } from '@cleverbrush/server'; + +server.handle( + RemoveItemEndpoint, + withErrors(RemoveItemEndpoint, itemErrors, removeHandler) +); +``` + +The returned function can also be placed in an existing handler map. To migrate +registration, move server-only metadata/DI into scopes, type the existing handler +files against `scope.endpoints`, bind feature modules, and finalize at the root. +Keep the shared contract in `/contract` imports; implementation facilities and +policies are server-entry-point exports only. + +The repository tests include strict type assertions against emitted package +declarations and a generated 1,000-operation consumer with one handler per file. +The generated consumer verifies cross-file inference and records compiler +diagnostics, memory, and timings for both existing and modular registration +without using environment-dependent timing thresholds. ## Installation diff --git a/libs/server/src/ErrorMap.test.ts b/libs/server/src/ErrorMap.test.ts new file mode 100644 index 00000000..b7b7858b --- /dev/null +++ b/libs/server/src/ErrorMap.test.ts @@ -0,0 +1,121 @@ +import { object, string } from '@cleverbrush/schema'; +import { describe, expect, it, vi } from 'vitest'; +import { ActionResult, endpoint, errorMap, withErrors } from './index.js'; + +class Missing extends Error {} +class SpecialMissing extends Missing {} +const message = object({ message: string() }); +const ep = endpoint + .get('/items') + .responses({ 200: string(), 404: message, 204: null }); + +describe('errorMap and withErrors', () => { + it('translates sync failures and preserves successful results and arguments', async () => { + const configured = ep.inject({ db: object({ name: string() }) }); + const policy = errorMap().on(Missing, () => + ActionResult.notFound({ message: 'Not found' }) + ); + const handler = vi.fn( + (_context, { db }: { db: { name: string } }) => db.name + ); + const wrapped = withErrors(configured, policy, handler); + const context = {} as never; + const services = { db: { name: 'Connected' } }; + expect(await wrapped(context, services)).toBe('Connected'); + expect(handler).toHaveBeenCalledWith(context, services); + const failing = withErrors(ep, policy, () => { + throw new Missing('private details'); + }); + expect(await failing(context)).toMatchObject({ + status: 404, + body: { message: 'Not found' } + }); + }); + + it('supports async handlers and translators', async () => { + const policy = errorMap().on(Missing, async () => + ActionResult.notFound({ message: 'Async' }) + ); + const wrapped = withErrors(ep, policy, async () => { + throw new Missing(); + }); + expect(await wrapped({} as never)).toMatchObject({ + status: 404, + body: { message: 'Async' } + }); + }); + + it('is immutable and uses the first matching constructor including subclasses', async () => { + const empty = errorMap(); + const specific = empty.on(SpecialMissing, () => + ActionResult.notFound({ message: 'Specific' }) + ); + const all = specific.on(Missing, () => + ActionResult.notFound({ message: 'General' }) + ); + const error = new Missing(); + await expect(empty.translate(error)).rejects.toBe(error); + await expect(specific.translate(error)).rejects.toBe(error); + expect(await all.translate(new SpecialMissing())).toMatchObject({ + body: { message: 'Specific' } + }); + const first = errorMap() + .on(Missing, () => ActionResult.notFound({ message: 'First' })) + .on(SpecialMissing, () => + ActionResult.notFound({ message: 'Later' }) + ); + expect(await first.translate(new SpecialMissing())).toMatchObject({ + body: { message: 'First' } + }); + }); + + it.each([ + new Error('Database password'), + 'non-error', + undefined, + null + ])('rethrows unmatched values without changing identity: %s', async error => { + const wrapped = withErrors( + ep, + errorMap().on(Missing, () => ActionResult.noContent()), + () => { + throw error; + } + ); + await expect(wrapped({} as never)).rejects.toBe(error); + }); + + it('does not feed translator failures back into the policy', async () => { + const failure = new SpecialMissing('translator failed'); + const later = vi.fn(() => ActionResult.noContent()); + const policy = errorMap() + .on(Missing, async () => { + throw failure; + }) + .on(SpecialMissing, later); + const wrapped = withErrors(ep, policy, () => { + throw new Missing(); + }); + await expect(wrapped({} as never)).rejects.toBe(failure); + expect(later).not.toHaveBeenCalled(); + }); + + it('requires declared responses for erased/JavaScript callers', () => { + expect(() => + (withErrors as any)(endpoint.get('/legacy'), errorMap(), () => null) + ).toThrow('explicit endpoint responses'); + }); + + it('rejects raw, arbitrary, and plain-object translator results at runtime', async () => { + for (const response of [ + ActionResult.raw(() => {}), + ActionResult.content('text'), + { message: 'not an ActionResult' } + ]) { + const policy = errorMap().on(Missing, () => response as any); + await expect(policy.translate(new Missing())).rejects.toThrow( + 'explicit JSON or bodyless' + ); + } + }); +}); diff --git a/libs/server/src/ErrorMap.ts b/libs/server/src/ErrorMap.ts new file mode 100644 index 00000000..655ea8ee --- /dev/null +++ b/libs/server/src/ErrorMap.ts @@ -0,0 +1,166 @@ +import { + type ContentResult, + type FileResult, + JsonResult, + NoContentResult, + type RawResult, + type RedirectResult, + StatusCodeResult, + type StreamResult +} from './ActionResult.js'; +import type { EndpointBuilder, Handler, ResponsesOf } from './Endpoint.js'; + +/** Explicit, contract-checkable responses supported by error policies. */ +export type ErrorResponse = + | JsonResult + | NoContentResult + | StatusCodeResult; + +/** The declared JSON and bodyless responses of an HTTP endpoint. */ +export type ErrorResponsesOf = { + [K in keyof ResponsesOf & number]: ResponsesOf[K] extends null + ? K extends 204 + ? NoContentResult + : StatusCodeResult + : JsonResult[K]>; +}[keyof ResponsesOf & number]; + +type ErrorConstructor = abstract new (...args: any[]) => E; +const responseTypes: unique symbol = Symbol('errorResponseTypes'); +// Result classes are structural (NoContentResult only has executeAsync). +// Keep a discriminated description as well as R so a declared 204 cannot +// accidentally admit every other ActionResult through structural assignability. +type ResponseSignature = R extends + | FileResult + | StreamResult + | ContentResult + | RedirectResult + | RawResult + ? { kind: 'unsupported' } + : R extends JsonResult + ? { kind: 'json'; status: S; body: B } + : R extends StatusCodeResult + ? { kind: 'empty'; status: S } + : R extends NoContentResult + ? { kind: 'empty'; status: 204 } + : never; +type Rule = { + matches: (error: unknown) => boolean; + translate: (error: any) => ErrorResponse | Promise; +}; + +/** + * Immutable, ordered exception translators. Create one with {@link errorMap}. + * + * Policies retain their exact response union until attached to an endpoint. + * They do not change that endpoint's responses, catch infrastructure errors, + * or automatically expose exception messages. + * + * @typeParam R - Union of all responses produced by this policy. + */ +export class ErrorMap< + R extends ErrorResponse = never, + Signature = ResponseSignature +> { + /** @internal Retains status/body discrimination across exported policies. */ + declare readonly [responseTypes]: Signature; + readonly #rules: readonly Rule[]; + + /** @internal Use {@link errorMap}. */ + constructor(rules: readonly Rule[] = []) { + this.#rules = [...rules]; + } + + /** + * Return a new policy with a translator appended. First matching rule wins. + * Put subclass rules before base-class rules. Translators may be async; + * translator failures propagate without re-entering this policy. + * + * @param errorType - Error constructor matched using `instanceof`. + * @param translate - Application-owned conversion to an explicit response. + */ + on( + errorType: ErrorConstructor, + translate: (error: E) => T | Promise + ): ErrorMap> { + return new ErrorMap([ + ...this.#rules, + { + matches: error => error instanceof errorType, + translate + } + ]); + } + + /** + * Translate a recognized exception or rethrow the original value unchanged. + * Usually invoked by {@link withErrors}, not directly by an application. + */ + async translate(error: unknown): Promise { + const rule = this.#rules.find(candidate => candidate.matches(error)); + if (!rule) throw error; + const result = await rule.translate(error); + if ( + !(result instanceof JsonResult) && + !(result instanceof NoContentResult) && + !(result instanceof StatusCodeResult) + ) { + throw new TypeError( + 'Error translators must return an explicit JSON or bodyless status result' + ); + } + return result as R; + } +} + +/** + * Create an empty, immutable error policy. Unknown exceptions are rethrown. + * + * @example + * ```ts + * const errors = errorMap().on(MissingItemError, () => + * ActionResult.notFound({ message: 'Item not found' }) + * ); + * ``` + */ +export function errorMap(): ErrorMap { + return new ErrorMap(); +} + +/** + * Wrap only a handler's invocation in an endpoint-checked error policy. + * + * Requires explicit `.responses()` declarations. Every possible translated + * status/body must fit the endpoint; file/raw/stream escape hatches are not + * accepted. Request and injected-service types are unchanged. Authentication, + * validation, middleware, DI and response serialization remain outside this + * wrapper and use the server's normal error handling. + * + * @param endpoint - Configured HTTP endpoint, the sole source of handler types. + * @param policy - Reusable policy compatible with the endpoint's responses. + * @param handler - Named or inline handler to wrap. + * @returns A handler suitable for `handle`, `mapHandlers`, or implementation scopes. + */ +export function withErrors< + E extends EndpointBuilder +>( + endpoint: E, + policy: keyof ResponsesOf> extends never + ? never + : ErrorMap>>, + handler: Handler> +): Handler { + const responses = endpoint.introspect().responsesSchemas; + if (!responses || Object.keys(responses).length === 0) { + throw new TypeError( + 'Error policies require explicit endpoint responses' + ); + } + return (async (...args: unknown[]) => { + try { + return await (handler as (...args: unknown[]) => unknown)(...args); + } catch (error) { + return policy.translate(error); + } + }) as Handler; +} diff --git a/libs/server/src/Implementation.consumer.test.ts b/libs/server/src/Implementation.consumer.test.ts new file mode 100644 index 00000000..5b09f6bd --- /dev/null +++ b/libs/server/src/Implementation.consumer.test.ts @@ -0,0 +1,186 @@ +import { spawnSync } from 'node:child_process'; +import { + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { expect, it } from 'vitest'; + +const repository = resolve(dirname(fileURLToPath(import.meta.url)), '../../..'); + +/** Generate and compile equivalent, separately packaged 1,000-operation consumers. */ +function checkLargeConsumers() { + const temporary = mkdtempSync( + join(tmpdir(), 'framework-implementation-consumer-') + ); + const write = (path: string, contents: string) => { + const destination = join(temporary, path); + mkdirSync(dirname(destination), { recursive: true }); + writeFileSync(destination, contents); + }; + const metrics: Record> = {}; + try { + symlinkSync( + join(repository, 'node_modules'), + join(temporary, 'node_modules'), + 'dir' + ); + write('package.json', JSON.stringify({ type: 'module' })); + for (const style of ['existing', 'modular']) { + write( + `${style}/shared.ts`, + `import { object, string } from '@cleverbrush/schema'; +export const Db = object({ name: string() }); +export const Query = object({ search: string() }); +export const Output = object({ title: string() });` + ); + const contractImports: string[] = []; + const rootImports: string[] = []; + for (let group = 0; group < 10; group++) { + const name = `feature${group}`; + const names = Array.from( + { length: 100 }, + (_, i) => `operation${i}` + ); + write( + `${style}/${name}/contract.ts`, + `import { endpoint } from '@cleverbrush/server/contract'; +import { Query, Output } from '../shared.js'; +export const ${name} = { +${names.map(operation => `${operation}: endpoint.get('/${name}/${operation}').query(Query).responses({ 200: Output })`).join(',\n')} +};` + ); + contractImports.push( + `import { ${name} } from './${name}/contract.js';` + ); + const scope = + style === 'modular' + ? `import { implement } from '@cleverbrush/server'; +export const scope = implement(api).group('${name}', { inject: { db: Db }, tags: ['${name}'] });` + : `export const scope = { endpoints: { +${names.map(operation => `${operation}: api.${name}.${operation}.inject({ db: Db }).tags('${name}')`).join(',\n')} +} };`; + write( + `${style}/${name}/scope.ts`, + `import { api } from '../contract.js'; +import { Db } from '../shared.js'; +${scope}` + ); + for (const operation of names) { + write( + `${style}/${name}/handlers/${operation}.ts`, + `import type { Handler } from '@cleverbrush/server'; +import type { scope } from '../scope.js'; +export const ${operation}: Handler = ({ query }, { db }) => ({ title: query.search + db.name });` + ); + } + const handlers = `{ ${names.join(', ')} }`; + write( + `${style}/${name}/index.ts`, + `import { scope } from './scope.js'; +${names.map(operation => `import { ${operation} } from './handlers/${operation}.js';`).join('\n')} +${ + style === 'modular' + ? `export const ${name} = scope.withHandlers(${handlers});` + : `export const ${name} = { endpoints: scope.endpoints, handlers: ${handlers} };` +}` + ); + rootImports.push( + `import { ${name} } from './${name}/index.js';` + ); + } + const groups = Array.from({ length: 10 }, (_, i) => `feature${i}`); + write( + `${style}/contract.ts`, + `import { defineApi } from '@cleverbrush/server/contract'; +${contractImports.join('\n')} +export const api = defineApi({ ${groups.join(', ')} });` + ); + write( + `${style}/index.ts`, + `${rootImports.join('\n')} +import { api } from './contract.js'; +${ + style === 'modular' + ? `import { implement } from '@cleverbrush/server'; +export const registration = implement(api).use(${groups.join(', ')}).complete();` + : `import { mapHandlers } from '@cleverbrush/server'; +export const registration = mapHandlers({ ${groups.map(g => `${g}: ${g}.endpoints`).join(', ')} }, { ${groups.map(g => `${g}: ${g}.handlers`).join(', ')} });` +}` + ); + write( + `${style}/tsconfig.json`, + JSON.stringify({ + compilerOptions: { + target: 'ES2022', + module: 'ESNext', + moduleResolution: 'bundler', + strict: true, + declaration: true, + emitDeclarationOnly: true, + outDir: 'dist', + types: ['node'], + lib: ['ES2022', 'ESNext.Disposable'], + skipLibCheck: false + }, + include: ['**/*.ts'], + exclude: ['dist'] + }) + ); + const result = spawnSync( + process.execPath, + [ + join(repository, 'node_modules/typescript/lib/tsc.js'), + '-p', + join(temporary, style, 'tsconfig.json'), + '--extendedDiagnostics' + ], + { encoding: 'utf8', timeout: 90000, maxBuffer: 4 * 1024 * 1024 } + ); + if (result.status !== 0) + throw new Error( + `${style} consumer failed:\n${result.stdout}\n${result.stderr}\n${result.error ?? ''}` + ); + const declaration = readFileSync( + join(temporary, style, 'dist/index.d.ts'), + 'utf8' + ); + if (!declaration.includes('HandlerMapping')) + throw new Error('Missing public registration declaration'); + metrics[style] = Object.fromEntries( + result.stdout + .split('\n') + .map(line => line.split(/:\s+/, 2)) + .filter(([key]) => + [ + 'Types', + 'Instantiations', + 'Memory used', + 'Check time', + 'Emit time', + 'Total time' + ].includes(key) + ) + ); + } + return metrics; + } finally { + // Only this test's generated temporary directory is removed. + rmSync(temporary, { recursive: true, force: true }); + } +} + +it('typechecks and emits a 1,000-operation multi-file consumer against published declarations', () => { + const metrics = checkLargeConsumers(); + expect(metrics).toHaveProperty('existing'); + expect(metrics).toHaveProperty('modular'); + process.stdout.write( + `Implementation consumer compiler metrics: ${JSON.stringify(metrics)}\n` + ); +}, 200000); diff --git a/libs/server/src/Implementation.test-d.ts b/libs/server/src/Implementation.test-d.ts new file mode 100644 index 00000000..4da5fc97 --- /dev/null +++ b/libs/server/src/Implementation.test-d.ts @@ -0,0 +1,230 @@ +import { array, number, object, string } from '@cleverbrush/schema'; +import { + ActionResult, + createServer, + errorMap, + type Handler, + type HandlerMapping, + implement, + withErrors +} from '@cleverbrush/server'; +import { + defineApi, + endpoint, + pickGroups, + route +} from '@cleverbrush/server/contract'; +import { expectTypeOf, test } from 'vitest'; + +const Item = object({ id: number(), title: string() }); +const Message = object({ message: string() }); +const Principal = object({ userId: string() }); +const resource = endpoint.resource('/items').authorize(Principal, 'member'); +const byId = route({ id: number().coerce() })`/${p => p.id}`; + +const api = defineApi({ + items: { + list: resource + .get() + .query(object({ search: string() })) + .responses({ 200: array(Item), 400: Message }), + create: resource + .post() + .body(object({ title: string() })) + .responses({ 201: Item, 404: Message }), + remove: resource.delete(byId).responses({ 204: null, 404: Message }), + upload: endpoint + .post('/items/upload') + .authorize(Principal) + .upload() + .responses({ 204: null }) + }, + live: { + changes: endpoint.subscription('/changes').outgoing(Item) + } +}); + +const Db = object({ name: string() }); +const Mailer = object({ host: string() }); +const items = implement(api).group('items', { + inject: { db: Db }, + tags: ['items'], + operations: { + list: { summary: 'List items' }, + create: { inject: { mailer: Mailer }, operationId: 'createItem' } + } +}); + +const list: Handler = async ( + { principal, query }, + { db } +) => [{ id: 1, title: `${principal.userId}:${query.search}:${db.name}` }]; + +const create: Handler = ( + { body }, + { db, mailer } +) => + ActionResult.created({ + id: 1, + title: `${body.title}:${db.name}:${mailer.host}` + }); + +const remove: Handler = ({ params }) => { + if (params.id < 0) throw new Error('Not a persisted item'); + return ActionResult.noContent(); +}; + +class MissingItem extends Error {} +const missingItem = errorMap().on(MissingItem, () => + ActionResult.notFound({ message: 'Item not found' }) +); + +const reads = items.pick('list').withHandlers({ list }); +const writes = items.pick('create', 'remove', 'upload').withHandlers({ + create: { handler: create, errors: missingItem }, + remove: { handler: remove, errors: missingItem }, + upload: ({ files }, { db }) => { + if (!files.image || !db.name) throw new Error('Missing image'); + return ActionResult.noContent(); + } +}); +const itemsImplementation = implement(api).use(reads, writes); +const live = implement(api) + .group('live') + .withHandlers({ + changes: async function* ({ signal }) { + if (!signal.aborted) yield { id: 1, title: 'Changed' }; + } + }); + +const mapping = implement(api).use(itemsImplementation, live).complete(); + +test('separately declared handlers preserve exact request, DI and return types', () => { + createServer().handleAll(mapping); + expectTypeOf(mapping).toEqualTypeOf(); + expectTypeOf[0]['query']>().toEqualTypeOf<{ + search: string; + }>(); + expectTypeOf[0]['principal']>().toEqualTypeOf<{ + userId: string; + }>(); + expectTypeOf[1]>().toEqualTypeOf<{ + db: { name: string }; + }>(); + expectTypeOf[1]>().toEqualTypeOf<{ + db: { name: string }; + mailer: { host: string }; + }>(); + expectTypeOf[0]['params']>().toEqualTypeOf<{ + id: number; + }>(); + expectTypeOf[0]>().not.toBeAny(); + items.pick('list').withHandlers({ + list: ({ query }, { db }) => { + expectTypeOf(query.search).toEqualTypeOf(); + expectTypeOf(db.name).toEqualTypeOf(); + // @ts-expect-error undeclared query property + query.missing; + return []; + } + }); + const wrong = () => ActionResult.created([]); + // @ts-expect-error independently declared handlers cannot return an undeclared status + const checked: Handler = wrong; + const noMailer: Handler = (_ctx, services) => { + // @ts-expect-error per-operation mailer does not leak into list + services.mailer; + return []; + }; + void checked; + void noMailer; +}); + +test('coverage, identity-compatible slices, and exact keys survive composition', () => { + implement(api).use(itemsImplementation, live).complete(); + implement(pickGroups(api, 'items')).use(reads, writes).complete(); + // @ts-expect-error live.changes has not been registered + implement(api).use(itemsImplementation).complete(); + // @ts-expect-error an empty root is incomplete + implement(api).complete(); + // @ts-expect-error missing selected operations + items.withHandlers({ list }); + // @ts-expect-error duplicate operation in the same use call + implement(api).use(reads, reads); + // @ts-expect-error duplicate operation across calls + implement(api).use(reads).use(reads); + // @ts-expect-error unknown group + implement(api).group('unknown'); + // @ts-expect-error unknown operation + items.pick('unknown'); + implement(api).group('items', { + // @ts-expect-error unknown operation-specific configuration + operations: { unknown: { summary: 'No' } } + }); + const extra = { list, unknown: list }; + // @ts-expect-error extra keys also rejected from named objects + items.pick('list').withHandlers(extra); + const changed = defineApi({ + items: { list: endpoint.get('/items').responses({ 200: string() }) } + }); + const changedModule = implement(changed) + .group('items') + .withHandlers({ list: () => 'different' }); + // @ts-expect-error incompatible response contract + implement(api).use(changedModule); +}); + +test('service overrides and authorization retain inference', () => { + const contract = defineApi({ + things: { + get: endpoint + .get('/things') + .inject({ original: Db, db: Db }) + .responses({ 200: string() }) + } + }); + const scope = implement(contract).group('things', { + inject: { db: object({ shared: number() }) }, + authorize: object({ subject: string() }), + operations: { get: { inject: { db: object({ specific: string() }) } } } + }); + scope.withHandlers({ + get: ({ principal }, { original, db }) => { + expectTypeOf(principal.subject).toEqualTypeOf(); + expectTypeOf(original.name).toEqualTypeOf(); + expectTypeOf(db).toEqualTypeOf<{ specific: string }>(); + return db.specific; + } + }); +}); + +test('error responses are constrained even when an endpoint declares 204', () => { + withErrors(items.endpoints.remove, missingItem, remove); + // @ts-expect-error list does not declare 404 + withErrors(items.endpoints.list, missingItem, list); + items.pick('list').withHandlers({ + // @ts-expect-error descriptors enforce the same policy check + list: { handler: list, errors: missingItem } + }); + const wrongBody = errorMap().on(Error, () => + ActionResult.notFound({ message: 42 }) + ); + // @ts-expect-error wrong body must not be accepted via the declared 204 branch + withErrors(items.endpoints.remove, wrongBody, remove); + const wrongStatus = errorMap().on(Error, () => + ActionResult.forbidden({ message: 'No' }) + ); + // @ts-expect-error undeclared status must not be accepted via 204 + withErrors(items.endpoints.remove, wrongStatus, remove); + const raw = errorMap().on(Error, () => ActionResult.raw(() => {})); + // @ts-expect-error raw response cannot bypass a policy contract check + withErrors(items.endpoints.remove, raw, remove); + // @ts-expect-error a declared responses map is required, even for an empty policy + withErrors(endpoint.get('/legacy'), errorMap(), () => 'legacy'); + implement(api) + .group('live') + .withHandlers({ + // @ts-expect-error error policies are HTTP-only + changes: { handler: async function* () {}, errors: missingItem } + }); +}); diff --git a/libs/server/src/Implementation.test.ts b/libs/server/src/Implementation.test.ts new file mode 100644 index 00000000..984d6e59 --- /dev/null +++ b/libs/server/src/Implementation.test.ts @@ -0,0 +1,199 @@ +import { number, object, string } from '@cleverbrush/schema'; +import { describe, expect, it } from 'vitest'; +import { + ActionResult, + createServer, + defineApi, + endpoint, + errorMap, + implement, + type Middleware, + pickGroups +} from './index.js'; + +const Db = object({ name: string() }); +const Mailer = object({ host: string() }); +const Principal = object({ userId: string() }); +const Message = object({ message: string() }); +function contract() { + return defineApi({ + items: { + list: endpoint.get('/items').responses({ 200: string() }), + remove: endpoint + .delete('/items') + .responses({ 204: null, 404: Message }) + }, + live: { changes: endpoint.subscription('/changes').outgoing(string()) } + }); +} + +describe('contract-bound implementations', () => { + it('composes split scopes, partial roots and contract slices', () => { + const api = contract(); + const items = implement(pickGroups(api, 'items')).group('items', { + inject: { db: Db } + }); + const reads = items + .pick('list') + .withHandlers({ list: (_ctx, { db }) => db.name }); + const writes = items + .pick('remove') + .withHandlers({ remove: () => ActionResult.noContent() }); + const feature = implement(api).use(reads, writes); + const live = implement(api) + .group('live') + .withHandlers({ + changes: async function* () { + yield 'changed'; + } + }); + const mapping = implement(api).use(feature, live).complete(); + expect(mapping._entries).toHaveLength(2); + expect(mapping._subscriptions).toHaveLength(1); + const server = createServer().handleAll(mapping); + expect(server.getRegistrations()).toHaveLength(2); + expect(server.getSubscriptionRegistrations()).toHaveLength(1); + expect(() => (feature as any).complete()).toThrow('live.changes'); + }); + + it('merges injections and preserves all unrelated metadata without mutating the source', () => { + const source = endpoint + .post('/upload') + .authorize(Principal, 'admin') + .inject({ db: Db, original: Db }) + .body(object({ title: string() })) + .query(object({ page: number() })) + .headers(object({ authorization: string() })) + .responses({ 204: null }) + .upload({ maxFileSize: 1000 }) + .summary('Original') + .description('Keep me') + .tags('original') + .operationId('uploadItem') + .deprecated() + .clearsCacheTag('items'); + const api = defineApi({ items: { upload: source } }); + const Replacement = object({ replacement: string() }); + const scope = implement(api).group('items', { + authorize: Principal, + inject: { db: Mailer }, + tags: ['shared'], + operations: { + upload: { + inject: { db: Replacement, mailer: Mailer }, + summary: 'Configured', + tags: ['specific'] + } + } + }); + const before = source.introspect(); + const after = scope.endpoints.upload.introspect(); + expect(after.serviceSchemas).toEqual({ + original: Db, + db: Replacement, + mailer: Mailer + }); + expect(after).toEqual({ + ...before, + summary: 'Configured', + tags: ['specific'], + serviceSchemas: after.serviceSchemas + }); + expect(source.introspect()).toEqual(before); + expect(Object.isFrozen(scope.endpoints)).toBe(true); + expect(implement(api).group('items').endpoints.upload).toBe(source); + }); + + it('keeps configuration-only scopes independent and includes endpoint middleware', () => { + const api = defineApi({ items: { list: endpoint.get('/items') } }); + const scope = implement(api).group('items'); + const middleware: Middleware = async (_ctx, next) => next(); + const middlewares = [middleware]; + const module = scope.withHandlers({ + list: { handler: () => 'ok', middlewares } + }); + middlewares.length = 0; + const root = implement(api).use(module); + expect(root.complete()._entries[0].middlewares).toEqual([middleware]); + expect(() => (implement(api) as any).complete()).toThrow('items.list'); + }); + + it('applies endpoint-checked error policies when binding handlers', async () => { + class Missing extends Error {} + const api = defineApi({ + items: { + remove: endpoint + .delete('/items') + .responses({ 204: null, 404: Message }) + } + }); + const scope = implement(api).group('items'); + const errors = errorMap().on(Missing, () => + ActionResult.notFound({ message: 'Missing' }) + ); + const module = scope.withHandlers({ + remove: { + handler: () => { + throw new Missing(); + }, + errors + } + }); + const mapping = implement(api).use(module).complete(); + expect(await mapping._entries[0].handler({})).toMatchObject({ + status: 404, + body: { message: 'Missing' } + }); + }); + + it('checks missing, unknown, duplicate and incompatible bindings at runtime', () => { + const api = contract(); + const root = implement(api); + const scope = root.group('items'); + const reads = scope.pick('list').withHandlers({ list: () => 'ok' }); + expect(() => (root as any).group('missing')).toThrow( + 'Unknown contract group' + ); + expect(() => + (root as any).group('items', { operations: { missing: {} } }) + ).toThrow('Unknown operation'); + expect(() => (scope as any).pick('missing')).toThrow( + 'Unknown operation' + ); + expect(() => scope.pick('list', 'list')).toThrow('Duplicate operation'); + expect(() => (scope as any).withHandlers({ list: () => 'ok' })).toThrow( + 'Missing handler items.remove' + ); + expect(() => + (scope as any).withHandlers({ extra: () => null }) + ).toThrow('Unknown handler'); + expect(() => (root as any).use(reads, reads)).toThrow( + 'Duplicate implementation' + ); + const different = contract(); + const foreign = implement(different) + .group('items') + .pick('list') + .withHandlers({ list: () => 'foreign' }); + expect(() => root.use(foreign)).toThrow('Incompatible source endpoint'); + expect(() => (root as any).use({})).toThrow( + 'Expected an implementation module' + ); + expect(() => + (root.group('live') as any).withHandlers({ + changes: { handler: async function* () {}, errors: errorMap() } + }) + ).toThrow('HTTP error policies'); + }); + + it('treats unusual group and operation names as literal keys, not prototype properties', () => { + const api = defineApi({ + ['__proto__']: { ['constructor']: endpoint.get('/special') } + }); + const module = implement(api) + .group('__proto__') + .pick('constructor') + .withHandlers({ constructor: () => 'ok' }); + expect(implement(api).use(module).complete()._entries).toHaveLength(1); + }); +}); diff --git a/libs/server/src/Implementation.ts b/libs/server/src/Implementation.ts new file mode 100644 index 00000000..15e7fbd6 --- /dev/null +++ b/libs/server/src/Implementation.ts @@ -0,0 +1,585 @@ +import type { InferType, SchemaBuilder } from '@cleverbrush/schema'; +import type { ApiContract, ApiGroup } from './contract.js'; +import { + type EndpointBuilder, + type Handler, + type HandlerMapping, + mapHandlers, + type ResponsesOf +} from './Endpoint.js'; +import { + type ErrorMap, + type ErrorResponsesOf, + withErrors +} from './ErrorMap.js'; +import { + isSubscriptionBuilder, + type SubscriptionBuilder, + type SubscriptionHandlerEntry +} from './Subscription.js'; +import type { Middleware } from './types.js'; + +type AnySchema = SchemaBuilder; +type Services = Record; +type AnyEndpoint = EndpointBuilder< + any, + any, + any, + any, + any, + any, + any, + any, + any, + any +>; +type AnySubscription = SubscriptionBuilder< + any, + any, + any, + any, + any, + any, + any, + any +>; +type Definition = AnyEndpoint | AnySubscription; +type Merge = { + [K in keyof A | keyof B]: K extends keyof B + ? B[K] + : K extends keyof A + ? A[K] + : never; +}; + +/** Server-only enrichment shared by every operation in a scope. */ +export interface ImplementationDefaults { + /** Merged with contract services; later same-name bindings win. */ + readonly inject?: Services; + /** Explicitly require authentication, retaining any existing role requirements. */ + readonly authorize?: AnySchema; + /** Replace inherited documentation tags when supplied. */ + readonly tags?: readonly string[]; +} + +/** Per-operation overrides. Wire schemas and cache metadata are not configurable here. */ +export interface ImplementationOperationOptions extends ImplementationDefaults { + /** Short documentation summary. */ + readonly summary?: string; + /** Longer documentation description. */ + readonly description?: string; + /** Stable OpenAPI/AsyncAPI operation identifier. */ + readonly operationId?: string; + /** Mark deprecated; this cannot clear an inherited deprecation. */ + readonly deprecated?: true; +} + +/** Typed defaults and operation-specific server settings for a contract group. */ +export interface ImplementationGroupOptions + extends ImplementationDefaults { + /** Keys must belong to the selected group. */ + readonly operations?: { + readonly [K in keyof G]?: ImplementationOperationOptions; + }; +} + +type Injected = O extends { readonly inject: infer I extends Services } + ? I + : {}; +type Principal = O extends { + readonly authorize: infer S extends AnySchema; +} + ? InferType + : P; +type OperationOptions = O extends { + readonly operations: infer Operations; +} + ? K extends keyof Operations + ? Operations[K] + : {} + : {}; + +type Configured = + E extends EndpointBuilder< + infer P, + infer B, + infer Q, + infer H, + infer S, + infer A, + infer Roles, + infer R, + infer Rs, + infer U + > + ? EndpointBuilder< + P, + B, + Q, + H, + Merge>, Injected>, + Principal, Options>, + Roles, + R, + Rs, + U + > + : E extends SubscriptionBuilder< + infer P, + infer Q, + infer H, + infer S, + infer A, + infer Roles, + infer I, + infer O + > + ? SubscriptionBuilder< + P, + Q, + H, + Merge>, Injected>, + Principal, Options>, + Roles, + I, + O + > + : never; + +type ConfiguredGroup = { + readonly [K in keyof G]: Configured>; +}; + +/** Handler binding accepted by an implementation scope. Policies apply only to HTTP handlers. */ +export type ImplementationHandlerEntry = E extends AnySubscription + ? SubscriptionHandlerEntry & { errors?: never } + : E extends AnyEndpoint + ? + | Handler + | { + handler: Handler; + middlewares?: Middleware[]; + errors?: keyof ResponsesOf extends never + ? never + : ErrorMap>; + } + : never; + +/** Complete, endpoint-specific handler bindings for one configured scope. */ +export type ImplementationHandlers = { + [K in keyof G]: ImplementationHandlerEntry; +}; + +type ExactKeys = Record< + Exclude, + never +>; +type ExactOperations = O extends { readonly operations: infer Ops } + ? { readonly operations: ExactKeys } + : unknown; + +type Entry = { + readonly group: string; + readonly name: string; + readonly source: Definition; + readonly endpoint: Definition; + readonly handler: (...args: any[]) => any; + readonly middlewares?: Middleware[]; +}; +type RuntimeBinding = + | Entry['handler'] + | { + handler: Entry['handler']; + middlewares?: Middleware[]; + errors?: ErrorMap; + }; + +const moduleContract: unique symbol = Symbol('implementationContract'); +const moduleEntries: unique symbol = Symbol('implementationEntries'); + +/** + * Exportable, immutable set of bound operations. Its type retains the original + * contract slice, not just an erased array of registrations. + * Obtain modules with `scope.withHandlers()` or `implement(api).use(...)`. + */ +export class ImplementationModule { + /** @internal Compile-time contract coverage. */ + declare readonly [moduleContract]: C; + readonly #entries: readonly Entry[]; + + /** @internal Construct modules through an implementation scope. */ + constructor(entries: readonly Entry[]) { + this.#entries = Object.freeze( + entries.map(entry => + Object.freeze({ + ...entry, + middlewares: entry.middlewares + ? [...entry.middlewares] + : undefined + }) + ) + ); + } + + /** @internal */ + [moduleEntries](): readonly Entry[] { + return this.#entries; + } +} + +/** + * Configured, unbound operations from a single contract group. + * Export the scope from a configuration-only module, then use + * `Handler` in separate handler files. + * No handler imports or registration side effects are needed here. + */ +export class ImplementationScope< + G extends string, + Source extends ApiGroup, + Endpoints extends ApiGroup +> { + readonly #group: G; + readonly #source: Source; + readonly #endpoints: Endpoints; + + /** @internal Use `implement(api).group(...)`. */ + constructor(group: G, source: Source, endpoints: Endpoints) { + this.#group = group; + this.#source = Object.freeze({ ...source }); + this.#endpoints = Object.freeze({ ...endpoints }); + } + + /** Configured endpoint builders: the source of handler request, DI and response types. */ + get endpoints(): Endpoints { + return this.#endpoints; + } + + /** + * Select operations for a smaller module without losing source identity. + * The composition root still checks coverage of the full contract. + */ + pick( + ...names: K[] + ): ImplementationScope, Pick> { + const source: ApiGroup = {}; + const endpoints: ApiGroup = {}; + const seen = new Set(); + for (const name of names) { + if (!Object.hasOwn(this.#source, name)) { + throw new TypeError(`Unknown operation ${this.#group}.${name}`); + } + if (seen.has(name)) + throw new TypeError( + `Duplicate operation ${this.#group}.${name}` + ); + seen.add(name); + Object.defineProperty(source, name, { + value: this.#source[name], + enumerable: true + }); + Object.defineProperty(endpoints, name, { + value: this.#endpoints[name], + enumerable: true + }); + } + return new ImplementationScope( + this.#group, + source, + endpoints + ) as ImplementationScope, Pick>; + } + + /** + * Bind exactly one handler for every operation in this scope. + * Named handlers and inline functions receive the same endpoint typing. + * Optional descriptors attach middleware and HTTP error policies. + */ + withHandlers>( + handlers: H & ExactKeys + ): ImplementationModule<{ [K in G]: Source }> { + for (const key of Object.keys(handlers)) { + if (!Object.hasOwn(this.#endpoints, key)) { + throw new TypeError(`Unknown handler ${this.#group}.${key}`); + } + } + const entries: Entry[] = []; + for (const name of Object.keys(this.#endpoints)) { + const endpoint = this.#endpoints[name]; + const binding = ( + Object.hasOwn(handlers, name) ? handlers[name] : undefined + ) as RuntimeBinding | undefined; + const handler = + typeof binding === 'function' ? binding : binding?.handler; + if (typeof handler !== 'function') { + throw new TypeError(`Missing handler ${this.#group}.${name}`); + } + const errors = + typeof binding === 'function' ? undefined : binding?.errors; + if (errors && isSubscriptionBuilder(endpoint)) { + throw new TypeError( + 'HTTP error policies cannot wrap subscription handlers' + ); + } + entries.push({ + group: this.#group, + name, + source: this.#source[name], + endpoint, + handler: errors + ? withErrors(endpoint as AnyEndpoint, errors, handler) + : handler, + middlewares: + typeof binding === 'function' + ? undefined + : binding?.middlewares + }); + } + return new ImplementationModule(entries); + } +} + +type ContractOf = M extends ImplementationModule ? C : never; +type MergeContracts = { + [G in keyof A | keyof B]: G extends keyof A + ? G extends keyof B + ? A[G] & B[G] + : A[G] + : G extends keyof B + ? B[G] + : never; +}; +type Joined< + Bound extends ApiContract, + Modules extends readonly ImplementationModule[] +> = Modules extends readonly [ + infer Head, + ...infer Tail extends readonly ImplementationModule[] +] + ? Joined>, Tail> + : Bound; +type Missing = { + [G in keyof C & + string]: `${G}.${Exclude & string}`; +}[keyof C & string]; +type Overlap = { + [G in keyof A & + keyof B & + string]: `${G}.${keyof A[G] & keyof B[G] & string}`; +}[keyof A & keyof B & string]; +type Incompatible = { + [G in keyof S & string]: G extends keyof C + ? { + [K in keyof S[G] & string]: K extends keyof C[G] + ? S[G][K] extends C[G][K] + ? never + : `${G}.${K}` + : `${G}.${K}`; + }[keyof S[G] & string] + : `${G}.${keyof S[G] & string}`; +}[keyof S & string]; +type CheckModules< + C, + B extends ApiContract, + M extends readonly ImplementationModule[] +> = number extends M['length'] + ? { + readonly implementationError: 'Use a tuple of modules with known operation keys'; + } + : M extends readonly [ + infer Head, + ...infer Tail extends readonly ImplementationModule[] + ] + ? [ + Overlap> | Incompatible> + ] extends [never] + ? CheckModules>, Tail> + : { + readonly duplicateOrIncompatibleOperations: + | Overlap> + | Incompatible>; + } + : unknown; + +/** + * Immutable composition root bound to a shared contract. Compose exported + * feature modules with `use`, then pass `complete()` to `server.handleAll`. + * Partial roots are themselves modules and can be composed in larger roots. + */ +export class ApiImplementation< + C extends ApiContract, + Bound extends ApiContract = {} +> extends ImplementationModule { + readonly #contract: C; + + /** @internal Use {@link implement}. */ + constructor(contract: C, entries: readonly Entry[] = []) { + super(entries); + this.#contract = Object.freeze( + Object.fromEntries( + Object.entries(contract).map(([name, group]) => [ + name, + Object.freeze({ ...group }) + ]) + ) + ) as C; + } + + /** + * Create an unbound scope; does not register handlers or change this root. + * Services merge contract → group → operation. Only explicitly supplied + * documentation fields override inherited values. Authorization retains roles. + */ + group< + K extends keyof C & string, + const O extends ImplementationGroupOptions = {} + >( + name: K, + options?: O & ExactOperations + ): ImplementationScope> { + if (!Object.hasOwn(this.#contract, name)) { + throw new TypeError(`Unknown contract group ${name}`); + } + const source = this.#contract[name]; + const config: ImplementationGroupOptions = options ?? {}; + for (const key of Object.keys(config.operations ?? {})) { + if (!Object.hasOwn(source, key)) + throw new TypeError(`Unknown operation ${name}.${key}`); + } + const endpoints = Object.fromEntries( + Object.entries(source).map(([key, endpoint]) => [ + key, + configure(endpoint, config, config.operations?.[key] ?? {}) + ]) + ); + return new ImplementationScope( + name, + source, + endpoints + ) as ImplementationScope>; + } + + /** + * Compose modules without erasing their operation coverage. Rejects duplicate + * and incompatible bindings, including runtime source-identity mismatches. + */ + use[]>( + ...modules: M & CheckModules + ): ApiImplementation> { + const entries = [...this[moduleEntries]()]; + const seen = new Map>(); + for (const entry of entries) { + if (!seen.has(entry.group)) seen.set(entry.group, new Set()); + seen.get(entry.group)!.add(entry.name); + } + for (const module of modules) { + if (!(module instanceof ImplementationModule)) + throw new TypeError('Expected an implementation module'); + for (const entry of module[moduleEntries]()) { + if ( + !Object.hasOwn(this.#contract, entry.group) || + !Object.hasOwn(this.#contract[entry.group], entry.name) || + this.#contract[entry.group][entry.name] !== entry.source + ) { + throw new TypeError( + `Incompatible source endpoint ${entry.group}.${entry.name}` + ); + } + if (seen.get(entry.group)?.has(entry.name)) { + throw new TypeError( + `Duplicate implementation ${entry.group}.${entry.name}` + ); + } + if (!seen.has(entry.group)) seen.set(entry.group, new Set()); + seen.get(entry.group)!.add(entry.name); + entries.push(entry); + } + } + return new ApiImplementation>( + this.#contract, + entries + ); + } + + /** + * Finalize only when every operation in the original contract is covered. + * Rechecks coverage at runtime for JavaScript callers and erased types. + * Returns the existing registration format; the server pipeline is unchanged. + */ + complete( + this: [Missing] extends [never] + ? ApiImplementation + : { readonly missingOperations: Missing } + ): HandlerMapping; + complete(): HandlerMapping { + const endpoints: Record = Object.create(null); + const handlers: Record> = Object.create( + null + ); + for (const entry of this[moduleEntries]()) { + endpoints[entry.group] ??= Object.create(null); + handlers[entry.group] ??= Object.create(null); + endpoints[entry.group][entry.name] = entry.endpoint; + handlers[entry.group][entry.name] = { + handler: entry.handler, + middlewares: entry.middlewares + ? [...entry.middlewares] + : undefined + }; + } + const missing: string[] = []; + for (const [group, operations] of Object.entries(this.#contract)) { + for (const name of Object.keys(operations)) { + if (!Object.hasOwn(endpoints[group] ?? {}, name)) + missing.push(`${group}.${name}`); + } + } + if (missing.length) + throw new TypeError( + `Missing implementations: ${missing.join(', ')}` + ); + return mapHandlers(endpoints, handlers); + } +} + +function configure( + endpoint: Definition, + defaults: ImplementationDefaults, + options: ImplementationOperationOptions +): Definition { + let result: any = endpoint; + if (defaults.inject || options.inject) { + result = result.inject({ + ...endpoint.introspect().serviceSchemas, + ...defaults.inject, + ...options.inject + }); + } + const authorize = options.authorize ?? defaults.authorize; + if (authorize) result = result.authorize(authorize); + const tags = options.tags ?? defaults.tags; + if (tags) result = result.tags(...tags); + if (options.summary !== undefined) result = result.summary(options.summary); + if (options.description !== undefined) + result = result.description(options.description); + if (options.operationId !== undefined) + result = result.operationId(options.operationId); + if (options.deprecated) result = result.deprecated(); + return result; +} + +/** + * Start an immutable implementation bound to a shared API contract. + * + * @param contract - Complete shared contract, or an intentional audience slice. + * @example + * ```ts + * const todos = implement(api).group('todos', { inject: { db: DbToken } }); + * const module = todos.withHandlers({ list: listHandler, create: createHandler }); + * server.handleAll(implement(api).use(module).complete()); + * ``` + */ +export function implement( + contract: C +): ApiImplementation { + return new ApiImplementation(contract); +} diff --git a/libs/server/src/contract.ts b/libs/server/src/contract.ts index 6fc5f109..a51045ca 100644 --- a/libs/server/src/contract.ts +++ b/libs/server/src/contract.ts @@ -72,7 +72,7 @@ import type { SubscriptionBuilder as _SB } from './Subscription.js'; */ export type ApiGroup = Record< string, - | _EB + | _EB | _SB >; diff --git a/libs/server/src/index.ts b/libs/server/src/index.ts index 1869b4bb..e7a1e057 100644 --- a/libs/server/src/index.ts +++ b/libs/server/src/index.ts @@ -50,6 +50,13 @@ export { type ResponsesOf, type ScopedEndpointFactory } from './Endpoint.js'; +export { + ErrorMap, + type ErrorResponse, + type ErrorResponsesOf, + errorMap, + withErrors +} from './ErrorMap.js'; export { BadRequestError, ConflictError, @@ -58,6 +65,17 @@ export { NotFoundError, UnauthorizedError } from './HttpError.js'; +export { + ApiImplementation, + type ImplementationDefaults, + type ImplementationGroupOptions, + type ImplementationHandlerEntry, + type ImplementationHandlers, + ImplementationModule, + type ImplementationOperationOptions, + ImplementationScope, + implement +} from './Implementation.js'; export { idempotency, type ServerIdempotencyOptions diff --git a/libs/server/tsconfig.build.json b/libs/server/tsconfig.build.json index cd010194..c4f9c52a 100644 --- a/libs/server/tsconfig.build.json +++ b/libs/server/tsconfig.build.json @@ -15,5 +15,5 @@ "types": ["node"] }, "include": ["src/**/*.ts"], - "exclude": ["src/**/*.test.ts"] + "exclude": ["src/**/*.test.ts", "src/**/*.test-d.ts"] } diff --git a/libs/server/tsconfig.typecheck.json b/libs/server/tsconfig.typecheck.json new file mode 100644 index 00000000..56426e83 --- /dev/null +++ b/libs/server/tsconfig.typecheck.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.build.json", + "compilerOptions": { + "noEmit": true, + "strict": true, + "rootDir": "." + }, + "include": ["src/**/*.test-d.ts"], + "exclude": [] +} diff --git a/libs/server/vitest.config.mts b/libs/server/vitest.config.mts new file mode 100644 index 00000000..c2329925 --- /dev/null +++ b/libs/server/vitest.config.mts @@ -0,0 +1,12 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['src/**/*.test.ts'], + typecheck: { + enabled: true, + include: ['src/**/*.test-d.ts'], + tsconfig: './tsconfig.typecheck.json' + } + } +}); diff --git a/websites/docs/app/server/page.tsx b/websites/docs/app/server/page.tsx index cacc013f..77771ae4 100644 --- a/websites/docs/app/server/page.tsx +++ b/websites/docs/app/server/page.tsx @@ -490,6 +490,90 @@ const AdminEp = endpoint +
+

Modular implementations

+

+ Build large APIs with separate configuration, handler, + and feature-module files. implement(api) + derives configured scopes from the shared contract; + complete() checks that every operation is + implemented before producing an ordinary handler + mapping. +

+
+                         = async (
+    { params }, { db }
+) => {
+    await removeItem(db, params.id);
+    return ActionResult.noContent();
+};
+
+// module.ts — list/remove are this example's complete contract
+export const itemsModule = items.withHandlers({ list, remove });
+
+// server.ts
+server.handleAll(implement(api).use(itemsModule).complete());`)
+                            }}
+                        />
+                    
+

+ Use pick(...operationNames) to split a + large group. Shared and per-operation services merge + while request, principal, service, and response types + remain available inside each handler file. Subscription + handlers can be composed through the same API. Existing + handle and mapHandlers remain + supported. +

+

Shared, typed error policies

+
+                        
+    ActionResult.notFound({ message: 'Item not found' })
+);
+
+const itemsModule = items.withHandlers({
+    list,
+    remove: { handler: remove, errors: itemErrors }
+});
+
+// Or adopt the policy without changing existing registration:
+server.handle(RemoveItemEndpoint,
+    withErrors(RemoveItemEndpoint, itemErrors, removeHandler));`)
+                            }}
+                        />
+                    
+

+ A policy must fit each endpoint's explicitly + declared response statuses and bodies. Only known + exceptions from the handler are translated. Unknown + exceptions retain the existing safe 500 handling; + middleware, validation, DI, and serialization failures + are not intercepted. Policies are HTTP-only and do not + change subscription errors. +

+

+ See the{' '} + + complete multi-file consumer guide + {' '} + for configuration precedence, module composition, and + migration. +

+
+ {/* ── WebSocket Subscriptions ──────────────────────── */}

WebSocket Subscriptions