diff --git a/source/constants.ts b/source/constants.ts index 140fe80..ddfd93c 100644 --- a/source/constants.ts +++ b/source/constants.ts @@ -12,6 +12,3 @@ export const HTTP_STATUS_CODE_CREATED = 201; * No content response status code. */ export const HTTP_STATUS_CODE_NO_CONTENT = 204; - - - diff --git a/source/routes/types.ts b/source/routes/types.ts index 7e39906..2d2746e 100644 --- a/source/routes/types.ts +++ b/source/routes/types.ts @@ -1,6 +1,7 @@ import type { BaseStream } from '../services/stream/base-stream.js'; export type RouteSendOptions = { + /** Defaults to `response`; any other value sends an application event. */ type?: string; @@ -15,6 +16,7 @@ export type RouteSendOptions = { * connection lifetime are shared with other requests on the same connection. */ export interface RouteStream { + /** Connection shared by every request on the same transport session. */ readonly connection: BaseStream; @@ -29,6 +31,7 @@ export type RouteHandler = (stream: RouteStream) => void | Promise; /** An exact application route with no transport-specific metadata. */ export type RouteDefinition = { + /** Exact route name. Parameter and wildcard syntax are not supported. */ url: string; handler: RouteHandler; diff --git a/source/services/router.ts b/source/services/router.ts index f4ce543..71c4d50 100644 --- a/source/services/router.ts +++ b/source/services/router.ts @@ -5,6 +5,7 @@ import type { BaseStream } from './stream/base-stream.ts'; /** Canonical request produced by every transport adapter. */ export type ApplicationRequest = { + /** Exact application route name. */ path: string; diff --git a/source/services/stream/base-stream.ts b/source/services/stream/base-stream.ts index b79f824..c76ac31 100644 --- a/source/services/stream/base-stream.ts +++ b/source/services/stream/base-stream.ts @@ -1,5 +1,6 @@ /** A normal request/response result before transport encoding. */ export type StreamResponse = { + /** Optional correlation ID for multiplexed transports. */ id?: string; @@ -15,6 +16,7 @@ export type StreamResponse = { /** An application event before a transport applies its wire encoding. */ export type StreamEvent = { + /** Optional event or correlation ID. */ id?: string; diff --git a/test/helpers/test-connection.ts b/test/helpers/test-connection.ts index caa318f..538f1b5 100644 --- a/test/helpers/test-connection.ts +++ b/test/helpers/test-connection.ts @@ -1,45 +1,43 @@ -import { - BaseStream, - type StreamMessage, -} from "../../source/services/stream/base-stream.js"; +import { BaseStream, type StreamMessage } from '../../source/services/stream/base-stream.ts'; /** Minimal observable connection used by application and broadcaster tests. */ export class TestConnection extends BaseStream { - readonly messages: StreamMessage[] = []; - readonly closeCallbacks: Array<() => void> = []; - closed = false; + readonly messages: StreamMessage[] = []; + readonly closeCallbacks: Array<() => void> = []; + closed = false; - constructor( - readonly streaming: boolean, - readonly bidirectional: boolean, - ) { - super(); - } - - async send(message: StreamMessage): Promise { - if (this.closed) { - throw new Error("connection is closed"); + constructor( + readonly streaming: boolean, + readonly bidirectional: boolean, + ) { + super(); } - this.messages.push(message); - } + async send(message: StreamMessage): Promise { + if (this.closed) { + throw new Error('connection is closed'); + } - close(): void { - if (this.closed) { - return; + this.messages.push(message); } - this.closed = true; - const callbacks = this.closeCallbacks.splice(0); - callbacks.forEach((callback) => callback()); - } + close(): void { + if (this.closed) { + return; + } - onClose(callback: () => void): void { - if (this.closed) { - callback(); - return; + this.closed = true; + const callbacks = this.closeCallbacks.splice(0); + callbacks.forEach((callback) => callback()); } - this.closeCallbacks.push(callback); - } + onClose(callback: () => void): void { + if (this.closed) { + callback(); + + return; + } + + this.closeCallbacks.push(callback); + } } diff --git a/test/services/router.test.ts b/test/services/router.test.ts index 222d3e9..20c1826 100644 --- a/test/services/router.test.ts +++ b/test/services/router.test.ts @@ -1,134 +1,111 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it } from 'vitest'; -import type { RouteDefinition, RouteModule } from "../../source/routes/types.js"; -import { ApplicationError } from "../../source/errors/index.js"; -import { ApplicationRouter } from "../../source/services/router.js"; -import { TestConnection } from "../helpers/test-connection.js"; +import type { RouteDefinition, RouteModule } from '../../source/routes/types.ts'; +import { ApplicationRouter } from '../../source/services/router.ts'; +import { TestConnection } from '../helpers/test-connection.ts'; -function moduleWith(routes: RouteDefinition[]): RouteModule { - return { - async getRoutes() { - return routes; - }, - }; -} +const moduleWith = (routes: RouteDefinition[]): RouteModule => { + return { + async getRoutes(): Promise { + return routes; + }, + }; +}; -describe("ApplicationRouter initialization", () => { - it("rejects duplicate exact paths during startup", async () => { - const route = { url: "/echo", handler: () => undefined }; +describe('ApplicationRouter initialization', (): void => { + it('rejects duplicate exact paths during startup', async (): Promise => { + const route = { url: '/echo', handler: (): void => undefined }; - await expect( - ApplicationRouter.create([moduleWith([route]), moduleWith([route])]), - ).rejects.toThrow("Duplicate application route: /echo"); - }); + await expect(ApplicationRouter.create([ moduleWith([ route ]), moduleWith([ route ]) ])).rejects.toThrow('Duplicate application route: /echo'); + }); - it.each(["echo", "/", "/items/:id", "/items?active=true", "/items#active"])( - "rejects the invalid route path %s", - async (url) => { - await expect( - ApplicationRouter.create([ - moduleWith([{ url, handler: () => undefined }]), - ]), - ).rejects.toThrow("Invalid application route"); - }, - ); + it.each([ 'echo', '/', '/items/:id', '/items?active=true', '/items#active' ])('rejects the invalid route path %s', async (url): Promise => { + await expect(ApplicationRouter.create([ moduleWith([{ url, handler: (): void => undefined }]) ])).rejects.toThrow('Invalid application route'); + }); }); -describe("ApplicationRouter dispatch", () => { - it("binds the connection, body, and request ID to one route stream", async () => { - const connection = new TestConnection(false, false); - const router = await ApplicationRouter.create([ - moduleWith([ - { - url: "/echo", - handler: async (stream) => { - expect(stream.connection).toBe(connection); - await stream.send(stream.body); - }, - }, - ]), - ]); +describe('ApplicationRouter dispatch', (): void => { + it('binds the connection, body, and request ID to one route stream', async (): Promise => { + const connection = new TestConnection(false, false); + const router = await ApplicationRouter.create([ + moduleWith([ + { + url: '/echo', + handler: async (stream): Promise => { + expect(stream.connection).toBe(connection); + await stream.send(stream.body); + }, + }, + ]), + ]); - await router.dispatch( - { path: "/echo", body: { value: 1 }, requestId: "request-1" }, - connection, - ); + await router.dispatch({ path: '/echo', body: { value: 1 }, requestId: 'request-1' }, connection); - expect(connection.messages).toEqual([ - { - id: "request-1", - type: "response", - statusCode: 200, - body: { value: 1 }, - }, - ]); + expect(connection.messages).toEqual([ + { + id: 'request-1', + type: 'response', + statusCode: 200, + body: { value: 1 }, + }, + ]); - await expect( - router.dispatch({ path: "/echo/other", body: {} }, connection), - ).rejects.toMatchObject({ statusCode: 404 }); - }); + await expect(router.dispatch({ path: '/echo/other', body: {} }, connection)).rejects.toMatchObject({ statusCode: 404 }); + }); - it("preserves correlation when concurrent requests finish out of order", async () => { - const completions = new Map void>(); - const router = await ApplicationRouter.create([ - moduleWith([ - { - url: "/delayed", - handler: async (stream) => { - const key = (stream.body as { key: string }).key; - await new Promise((resolve) => completions.set(key, resolve)); - await stream.send({ key }); - }, - }, - ]), - ]); - const connection = new TestConnection(true, true); + it('preserves correlation when concurrent requests finish out of order', async (): Promise => { + const completions = new Map void>(); + const router = await ApplicationRouter.create([ + moduleWith([ + { + url: '/delayed', + handler: async (stream): Promise => { + const key = (stream.body as { key: string }).key; + await new Promise((resolve) => completions.set(key, resolve)); + await stream.send({ key }); + }, + }, + ]), + ]); + const connection = new TestConnection(true, true); - const first = router.dispatch( - { path: "/delayed", body: { key: "A" }, requestId: "A" }, - connection, - ); - const second = router.dispatch( - { path: "/delayed", body: { key: "B" }, requestId: "B" }, - connection, - ); + const first = router.dispatch({ path: '/delayed', body: { key: 'A' }, requestId: 'A' }, connection); + const second = router.dispatch({ path: '/delayed', body: { key: 'B' }, requestId: 'B' }, connection); - completions.get("B")?.(); - await second; - completions.get("A")?.(); - await first; + completions.get('B')?.(); + await second; + completions.get('A')?.(); + await first; - expect(connection.messages).toEqual([ - { - id: "B", - type: "response", - statusCode: 200, - body: { key: "B" }, - }, - { - id: "A", - type: "response", - statusCode: 200, - body: { key: "A" }, - }, - ]); - }); + expect(connection.messages).toEqual([ + { + id: 'B', + type: 'response', + statusCode: 200, + body: { key: 'B' }, + }, + { + id: 'A', + type: 'response', + statusCode: 200, + body: { key: 'A' }, + }, + ]); + }); - it("propagates route failures without infrastructure-specific cleanup", async () => { - const error = new Error("route failed"); - const router = await ApplicationRouter.create([ - moduleWith([ - { - url: "/failure", - handler: () => { - throw error; - }, - }, - ]), - ]); + it('propagates route failures without infrastructure-specific cleanup', async (): Promise => { + const error = new Error('route failed'); + const router = await ApplicationRouter.create([ + moduleWith([ + { + url: '/failure', + handler: (): void => { + throw error; + }, + }, + ]), + ]); - await expect( - router.dispatch({ path: "/failure" }, new TestConnection(false, false)), - ).rejects.toBe(error); - }); + await expect(router.dispatch({ path: '/failure' }, new TestConnection(false, false))).rejects.toBe(error); + }); });