diff --git a/source/app.ts b/source/index.ts similarity index 98% rename from source/app.ts rename to source/index.ts index bedf6c7..2559873 100644 --- a/source/app.ts +++ b/source/index.ts @@ -39,6 +39,7 @@ export class App { // Both transports share one ApplicationRouter. Routes use the shared // Broadcaster directly, while HTTP and WebSocket remain protocol adapters. const http = new HttpTransportRouter(router, debug); + const ws = new WsTransportRouter(router, debug, config.server.maxRequestBodyBytes); const host = new ServerHost(config, debug, [http, ws]); @@ -53,6 +54,7 @@ export class App { ) {} async start(): Promise { + await this.database.start(); await this.host.start(); } 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/broadcaster.ts b/source/services/broadcaster.ts index a59e8a4..463c26c 100644 --- a/source/services/broadcaster.ts +++ b/source/services/broadcaster.ts @@ -6,6 +6,7 @@ import { ApplicationError } from '../errors/index.ts'; /** Request-scoped view from which the broadcaster obtains a stable connection. */ export interface BroadcastStream { + /** Connection identity shared by every request on the same transport session. */ readonly connection: BaseStream; @@ -15,6 +16,7 @@ export interface BroadcastStream { /** One pending subscribe call and the topics whose removal will resolve it. */ interface SubscriptionWaiter { + /** Only topics newly introduced by this particular subscribe call. */ readonly remainingTopics: Set; 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/server-host.ts b/source/services/server-host.ts index 9c72f37..e4c8442 100644 --- a/source/services/server-host.ts +++ b/source/services/server-host.ts @@ -40,8 +40,8 @@ export class ServerHost { const corsMiddleware = cors({ origin: corsConfig.origin ?? '*', - allowMethods: corsConfig.methods ?? ['POST', 'OPTIONS'], - allowHeaders: corsConfig.allowedHeaders ?? ['Content-Type', 'Accept'], + allowMethods: corsConfig.methods ?? [ 'POST', 'OPTIONS' ], + allowHeaders: corsConfig.allowedHeaders ?? [ 'Content-Type', 'Accept' ], }); this.app.use('*', corsMiddleware); @@ -77,7 +77,7 @@ export class ServerHost { throw new Error('ServerHost supports only one WebSocket upgrade server'); } - const [upgradeTransport] = upgradeTransports; + const [ upgradeTransport ] = upgradeTransports; this.server = serve({ fetch: this.app.fetch, @@ -124,7 +124,7 @@ export class ServerHost { const closeTransports = this.transports.map((transport) => Promise.resolve(transport.stop?.())); // Create a promise that resolves when the server and transports are closed - this.stopPromise = Promise.all([closeServer, ...closeTransports]).then(() => { + this.stopPromise = Promise.all([ closeServer, ...closeTransports ]).then(() => { this.stopPromise = undefined; }); diff --git a/source/services/storage/database.ts b/source/services/storage/database.ts index c3ef093..1cf194e 100644 --- a/source/services/storage/database.ts +++ b/source/services/storage/database.ts @@ -6,6 +6,7 @@ import type { Logger } from '../../utils/logger.ts'; /** Options required to open a SQLite database connection. */ export type DatabaseOptions = { + /** Filesystem path to the SQLite database file. */ path: string; @@ -39,9 +40,6 @@ export class Database { this.kysely = new Kysely({ dialect: this.dialect, }); - - // Configure the SQLite pragmas. - this.configurePragmas(); } /** @@ -53,6 +51,13 @@ export class Database { return this.kysely; } + async start(): Promise { + this.debug('starting database connection'); + + // Configure the SQLite pragmas. + await this.configurePragmas(); + } + /** * Destroys the database connection. */ @@ -66,10 +71,10 @@ export class Database { * * WAL improves write concurrency; foreign keys enforce referential integrity. */ - private configurePragmas(): void { + private async configurePragmas(): Promise { this.debug('configuring SQLite pragmas'); - this.kysely.executeQuery(CompiledQuery.raw('PRAGMA journal_mode = WAL')); - this.kysely.executeQuery(CompiledQuery.raw('PRAGMA foreign_keys = ON')); + await this.kysely.executeQuery(CompiledQuery.raw('PRAGMA journal_mode = WAL')); + await this.kysely.executeQuery(CompiledQuery.raw('PRAGMA foreign_keys = ON')); } } diff --git a/source/services/storage/migrations/001-resources.ts b/source/services/storage/migrations/001-resources.ts index 0e548c9..27dcf73 100644 --- a/source/services/storage/migrations/001-resources.ts +++ b/source/services/storage/migrations/001-resources.ts @@ -23,7 +23,7 @@ export const up = async (db: Kysely): Promise => { .addColumn('public_key', 'text', (col) => col.notNull()) .addColumn('blob', 'blob', (col) => col.notNull()) .addColumn('timestamp', 'integer', (col) => col.notNull().defaultTo(millisecondTime)) - .addPrimaryKeyConstraint('pk_resource_data', ['resource_id', 'public_key']) + .addPrimaryKeyConstraint('pk_resource_data', [ 'resource_id', 'public_key' ]) .execute(); }; @@ -33,5 +33,6 @@ export const up = async (db: Kysely): Promise => { * @param db - Kysely database to apply the rollback against. */ export const down = async (db: Kysely): Promise => { - await db.schema.dropTable('resource_data').ifExists().execute(); + await db.schema.dropTable('resource_data').ifExists() +.execute(); }; diff --git a/source/services/storage/tables.ts b/source/services/storage/tables.ts index cedf815..d937c28 100644 --- a/source/services/storage/tables.ts +++ b/source/services/storage/tables.ts @@ -10,6 +10,7 @@ export type BlobColumn = ColumnType; * One row per (resource_id, public_key). Each publicKey owns a slot within a shared resource. */ export interface ResourceDataTable { + /** Shared resource identifier grouping related instances. */ resource_id: 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/source/services/transport/transport-router.ts b/source/services/transport/transport-router.ts index be506cd..4e05e42 100644 --- a/source/services/transport/transport-router.ts +++ b/source/services/transport/transport-router.ts @@ -4,6 +4,7 @@ import type { Hono } from 'hono'; /** Hono variables populated by transport-boundary middleware. */ export type AppEnv = { Variables: { + /** Decoded Extended JSON request body, when present. */ parsedBody?: unknown; @@ -19,6 +20,7 @@ export type AppEnv = { * not application routing. Implementations remain unaware of route modules. */ export interface TransportRouter { + /** * Attach wire endpoints and middleware to the shared Hono application. * @@ -32,6 +34,7 @@ export interface TransportRouter { /** A transport which also supplies the WebSocket server used during upgrade. */ export interface UpgradeTransportRouter extends TransportRouter { + /** WebSocket server instance passed to the Node HTTP listener. */ readonly websocketServer: WebSocketServerLike; } 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/broadcaster.test.ts b/test/services/broadcaster.test.ts index 0fc6f3e..3acc018 100644 --- a/test/services/broadcaster.test.ts +++ b/test/services/broadcaster.test.ts @@ -1,199 +1,180 @@ -import { describe, expect, it, vi } from "vitest"; +import { describe, expect, it, vi } from 'vitest'; -import { ApplicationError } from "../../source/errors/index.js"; -import { Broadcaster } from "../../source/services/broadcaster.js"; -import { ApplicationRouteStream } from "../../source/services/route-stream.js"; -import { Logger } from "../../source/utils/logger.js"; -import { TestConnection } from "../helpers/test-connection.js"; +import { Broadcaster } from '../../source/services/broadcaster.ts'; +import { ApplicationRouteStream } from '../../source/services/route-stream.ts'; +import { Logger } from '../../source/utils/logger.ts'; +import { TestConnection } from '../helpers/test-connection.ts'; -function createBroadcaster(): Broadcaster { - return new Broadcaster(new Logger("broadcaster-test")); -} +const createBroadcaster = (): Broadcaster => { + return new Broadcaster(new Logger('broadcaster-test')); +}; -function routeStream(connection: TestConnection): ApplicationRouteStream { - return new ApplicationRouteStream(connection, undefined); -} +const routeStream = (connection: TestConnection): ApplicationRouteStream => { + return new ApplicationRouteStream(connection, undefined); +}; -async function expectPending(promise: Promise): Promise { - const settled = vi.fn(); - void promise.then(settled); - await Promise.resolve(); - expect(settled).not.toHaveBeenCalled(); -} +const expectPending = async (promise: Promise): Promise => { + const settled = vi.fn(); + void promise.then(settled); + await Promise.resolve(); + expect(settled).not.toHaveBeenCalled(); +}; -describe("Broadcaster subscriptions", () => { - it("delivers events and resolves after a later request removes the topic", async () => { - const broadcaster = createBroadcaster(); - const connection = new TestConnection(true, true); - const subscribed = broadcaster.subscribe(routeStream(connection), [ - "items", - ]); +describe('Broadcaster subscriptions', () => { + it('delivers events and resolves after a later request removes the topic', async (): Promise => { + const broadcaster = createBroadcaster(); + const connection = new TestConnection(true, true); + const subscribed = broadcaster.subscribe(routeStream(connection), [ 'items' ]); - await expectPending(subscribed); - await broadcaster.publish("items", { - type: "item-changed", - data: { id: "a" }, + await expectPending(subscribed); + await broadcaster.publish('items', { + type: 'item-changed', + data: { id: 'a' }, + }); + + expect(connection.messages).toEqual([ + expect.objectContaining({ + type: 'item-changed', + data: { id: 'a' }, + }), + ]); + + // A different request-scoped facade still resolves the connection's + // original subscription. + await broadcaster.unsubscribe(routeStream(connection), [ 'items' ]); + await expect(subscribed).resolves.toBeUndefined(); }); - expect(connection.messages).toEqual([ - expect.objectContaining({ - type: "item-changed", - data: { id: "a" }, - }), - ]); + it('resolves fully duplicate subscriptions immediately', async (): Promise => { + const broadcaster = createBroadcaster(); + const connection = new TestConnection(true, true); + const first = broadcaster.subscribe(routeStream(connection), [ 'items', 'items' ]); + const duplicate = broadcaster.subscribe(routeStream(connection), [ 'items' ]); - // A different request-scoped facade still resolves the connection's - // original subscription. - await broadcaster.unsubscribe(routeStream(connection), ["items"]); - await expect(subscribed).resolves.toBeUndefined(); - }); + await expect(duplicate).resolves.toBeUndefined(); + await expectPending(first); + expect(connection.closeCallbacks).toHaveLength(1); - it("resolves fully duplicate subscriptions immediately", async () => { - const broadcaster = createBroadcaster(); - const connection = new TestConnection(true, true); - const first = broadcaster.subscribe(routeStream(connection), [ - "items", - "items", - ]); - const duplicate = broadcaster.subscribe(routeStream(connection), ["items"]); - - await expect(duplicate).resolves.toBeUndefined(); - await expectPending(first); - expect(connection.closeCallbacks).toHaveLength(1); - - await broadcaster.unsubscribe(routeStream(connection), ["items"]); - await first; - }); - - it("waits only for topics newly added by a partially overlapping call", async () => { - const broadcaster = createBroadcaster(); - const connection = new TestConnection(true, true); - const first = broadcaster.subscribe(routeStream(connection), ["a"]); - const second = broadcaster.subscribe(routeStream(connection), ["a", "b"]); - - await broadcaster.unsubscribe(routeStream(connection), ["a"]); - await expect(first).resolves.toBeUndefined(); - await expectPending(second); - - await broadcaster.unsubscribe(routeStream(connection), ["b"]); - await expect(second).resolves.toBeUndefined(); - }); - - it("resolves every pending subscription and removes topics on close", async () => { - const broadcaster = createBroadcaster(); - const connection = new TestConnection(true, false); - const first = broadcaster.subscribe(routeStream(connection), ["a"]); - const second = broadcaster.subscribe(routeStream(connection), ["b"]); - - connection.close(); - await Promise.all([first, second]); - await broadcaster.publish("a", { type: "changed", data: null }); - await broadcaster.publish("b", { type: "changed", data: null }); - - expect(connection.messages).toEqual([]); - }); - - it("immediately resolves registration against an already-closed connection", async () => { - const broadcaster = createBroadcaster(); - const connection = new TestConnection(true, false); - connection.close(); - - await expect( - broadcaster.subscribe(routeStream(connection), ["items"]), - ).resolves.toBeUndefined(); - }); - - it("rejects subscriptions on a non-streaming connection", () => { - const broadcaster = createBroadcaster(); - const connection = new TestConnection(false, false); - - expect(() => - broadcaster.subscribe(routeStream(connection), ["items"]), - ).toThrowError( - expect.objectContaining({ statusCode: 406 }), - ); - }); - - it("treats an empty subscription and repeated unsubscribe as no-ops", async () => { - const broadcaster = createBroadcaster(); - const connection = new TestConnection(true, true); - - await expect( - broadcaster.subscribe(routeStream(connection), []), - ).resolves.toBeUndefined(); - await broadcaster.unsubscribe(routeStream(connection), ["missing"]); - await broadcaster.unsubscribe(routeStream(connection)); - - expect(connection.closeCallbacks).toHaveLength(0); - }); - - it("fans out concurrently to independent connections", async () => { - const broadcaster = createBroadcaster(); - const first = new TestConnection(true, false); - const second = new TestConnection(true, false); - const originalFirstSend = first.send.bind(first); - let releaseFirst: () => void = () => undefined; - const firstReleased = new Promise((resolve) => { - releaseFirst = resolve; - }); - let markSecondSent: () => void = () => undefined; - const secondSent = new Promise((resolve) => { - markSecondSent = resolve; + await broadcaster.unsubscribe(routeStream(connection), [ 'items' ]); + await first; }); - first.send = async (message) => { - await firstReleased; - await originalFirstSend(message); - }; - second.send = async (message) => { - await TestConnection.prototype.send.call(second, message); - markSecondSent(); - }; + it('waits only for topics newly added by a partially overlapping call', async (): Promise => { + const broadcaster = createBroadcaster(); + const connection = new TestConnection(true, true); + const first = broadcaster.subscribe(routeStream(connection), [ 'a' ]); + const second = broadcaster.subscribe(routeStream(connection), [ 'a', 'b' ]); - const firstSubscription = broadcaster.subscribe(routeStream(first), [ - "items", - ]); - const secondSubscription = broadcaster.subscribe(routeStream(second), [ - "items", - ]); - const publication = broadcaster.publish("items", { - type: "item-changed", - data: {}, + await broadcaster.unsubscribe(routeStream(connection), [ 'a' ]); + await expect(first).resolves.toBeUndefined(); + await expectPending(second); + + await broadcaster.unsubscribe(routeStream(connection), [ 'b' ]); + await expect(second).resolves.toBeUndefined(); }); - await secondSent; - releaseFirst(); - await publication; + it('resolves every pending subscription and removes topics on close', async (): Promise => { + const broadcaster = createBroadcaster(); + const connection = new TestConnection(true, false); + const first = broadcaster.subscribe(routeStream(connection), [ 'a' ]); + const second = broadcaster.subscribe(routeStream(connection), [ 'b' ]); - expect(first.messages).toHaveLength(1); - expect(second.messages).toHaveLength(1); + connection.close(); + await Promise.all([ first, second ]); + await broadcaster.publish('a', { type: 'changed', data: null }); + await broadcaster.publish('b', { type: 'changed', data: null }); - first.close(); - second.close(); - await Promise.all([firstSubscription, secondSubscription]); - }); - - it("closes and removes a connection whose event delivery fails", async () => { - const broadcaster = createBroadcaster(); - const connection = new TestConnection(true, true); - connection.send = vi.fn().mockRejectedValue(new Error("socket failed")); - const subscribed = broadcaster.subscribe(routeStream(connection), [ - "items", - ]); - - await broadcaster.publish("items", { - type: "item-changed", - data: {}, + expect(connection.messages).toEqual([]); }); - await subscribed; - expect(connection.closed).toBe(true); - expect(connection.send).toHaveBeenCalledOnce(); + it('immediately resolves registration against an already-closed connection', async (): Promise => { + const broadcaster = createBroadcaster(); + const connection = new TestConnection(true, false); + connection.close(); - await broadcaster.publish("items", { - type: "item-changed", - data: {}, + await expect(broadcaster.subscribe(routeStream(connection), [ 'items' ])).resolves.toBeUndefined(); + }); + + it('rejects subscriptions on a non-streaming connection', (): void => { + const broadcaster = createBroadcaster(); + const connection = new TestConnection(false, false); + + expect(() => broadcaster.subscribe(routeStream(connection), [ 'items' ])).toThrowError(expect.objectContaining({ statusCode: 406 })); + }); + + it('treats an empty subscription and repeated unsubscribe as no-ops', async (): Promise => { + const broadcaster = createBroadcaster(); + const connection = new TestConnection(true, true); + + await expect(broadcaster.subscribe(routeStream(connection), [])).resolves.toBeUndefined(); + await broadcaster.unsubscribe(routeStream(connection), [ 'missing' ]); + await broadcaster.unsubscribe(routeStream(connection)); + + expect(connection.closeCallbacks).toHaveLength(0); + }); + + it('fans out concurrently to independent connections', async (): Promise => { + const broadcaster = createBroadcaster(); + const first = new TestConnection(true, false); + const second = new TestConnection(true, false); + const originalFirstSend = first.send.bind(first); + let releaseFirst: () => void = () => undefined; + const firstReleased = new Promise((resolve) => { + releaseFirst = resolve; + }); + let markSecondSent: () => void = () => undefined; + const secondSent = new Promise((resolve) => { + markSecondSent = resolve; + }); + + first.send = async (message): Promise => { + await firstReleased; + await originalFirstSend(message); + }; + + second.send = async (message): Promise => { + await TestConnection.prototype.send.call(second, message); + markSecondSent(); + }; + + const firstSubscription = broadcaster.subscribe(routeStream(first), [ 'items' ]); + const secondSubscription = broadcaster.subscribe(routeStream(second), [ 'items' ]); + const publication = broadcaster.publish('items', { + type: 'item-changed', + data: {}, + }); + + await secondSent; + releaseFirst(); + await publication; + + expect(first.messages).toHaveLength(1); + expect(second.messages).toHaveLength(1); + + first.close(); + second.close(); + await Promise.all([ firstSubscription, secondSubscription ]); + }); + + it('closes and removes a connection whose event delivery fails', async (): Promise => { + const broadcaster = createBroadcaster(); + const connection = new TestConnection(true, true); + connection.send = vi.fn().mockRejectedValue(new Error('socket failed')); + const subscribed = broadcaster.subscribe(routeStream(connection), [ 'items' ]); + + await broadcaster.publish('items', { + type: 'item-changed', + data: {}, + }); + await subscribed; + + expect(connection.closed).toBe(true); + expect(connection.send).toHaveBeenCalledOnce(); + + await broadcaster.publish('items', { + type: 'item-changed', + data: {}, + }); + expect(connection.send).toHaveBeenCalledOnce(); }); - expect(connection.send).toHaveBeenCalledOnce(); - }); }); 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); + }); }); diff --git a/test/services/transports/http-transport.test.ts b/test/services/transports/http-transport.test.ts index fbf213f..095d266 100644 --- a/test/services/transports/http-transport.test.ts +++ b/test/services/transports/http-transport.test.ts @@ -1,242 +1,237 @@ -import { Hono } from "hono"; -import { describe, expect, it, vi } from "vitest"; +import { Hono } from 'hono'; +import { describe, expect, it, vi } from 'vitest'; -import type { RouteDefinition } from "../../../source/routes/types.js"; -import { ApplicationError } from "../../../source/errors/index.js"; -import { ApplicationRouter } from "../../../source/services/router.js"; -import { Broadcaster } from "../../../source/services/broadcaster.js"; -import { HttpTransportRouter } from "../../../source/services/transport/http-transport.js"; -import type { AppEnv } from "../../../source/services/transport/transport-router.js"; -import { fromExtendedJson, toExtendedJson } from "@xo-cash/utils"; -import { Logger } from "../../../source/utils/logger.js"; -import { ServerHost } from "../../../source/services/server-host.js"; +import type { RouteDefinition } from '../../../source/routes/types.ts'; +import { ApplicationError } from '../../../source/errors/index.ts'; +import { ApplicationRouter } from '../../../source/services/router.ts'; +import { Broadcaster } from '../../../source/services/broadcaster.ts'; +import { HttpTransportRouter } from '../../../source/services/transport/http-transport.ts'; +import type { AppEnv } from '../../../source/services/transport/transport-router.ts'; +import { fromExtendedJson, toExtendedJson } from '@xo-cash/utils'; +import { Logger } from '../../../source/utils/logger.ts'; +import { ServerHost } from '../../../source/services/server-host.ts'; -async function createApp( - routes: RouteDefinition[] | ((broadcaster: Broadcaster) => RouteDefinition[]), - maxRequestBodyBytes = 1024 * 1024, -): Promise> { - const debug = new Logger("http-transport-test"); - const broadcaster = new Broadcaster(debug); - const resolvedRoutes = - typeof routes === "function" ? routes(broadcaster) : routes; - const router = await ApplicationRouter.create([ - { - async getRoutes() { - return resolvedRoutes; - }, - }, - ]); - const transport = new HttpTransportRouter(router, debug); - const app = new Hono(); - - app.onError(HttpTransportRouter.createErrorHandler(debug)); - app.use("*", ServerHost.limitBodySizeMiddleware(maxRequestBodyBytes, debug)); - app.use("*", HttpTransportRouter.createExtJsonMiddleware(debug)); - transport.register(app); - return app; -} - -describe("HttpTransportRouter", () => { - it("runs normal HTTP through a non-streaming route stream", async () => { - const app = await createApp([ - { - url: "/echo", - handler: async (stream) => stream.send(stream.body), - }, - ]); - const value = new Uint8Array([1, 2, 3]); - - const response = await app.request("/echo", { - method: "POST", - headers: { "content-type": "application/json" }, - body: toExtendedJson({ value }), - }); - - expect(response.status).toBe(200); - expect(fromExtendedJson(await response.text())).toEqual({ value }); - }); - - it("returns 204 when a normal HTTP route sends nothing", async () => { - const app = await createApp([ - { - url: "/nothing", - handler: () => undefined, - }, - ]); - - const response = await app.request("/nothing", { method: "POST" }); - - expect(response.status).toBe(204); - expect(await response.text()).toBe(""); - }); - - it("returns normalized errors for non-streaming requests", async () => { - const app = await createApp([]); - - const missing = await app.request("/missing", { method: "POST" }); - expect(missing.status).toBe(404); - expect(await missing.json()).toEqual({ - statusCode: 404, - error: "No route found for /missing", - }); - - const invalid = await app.request("/missing", { - method: "POST", - headers: { "content-type": "application/json" }, - body: "{", - }); - expect(invalid.status).toBe(400); - expect(await invalid.json()).toEqual({ - statusCode: 400, - error: "Invalid JSON in request body", - }); - }); - - it("rejects subscribe when normal HTTP has no streaming capability", async () => { - const app = await createApp((broadcaster) => [ - { - url: "/items/subscribe", - handler: async (stream) => { - await broadcaster.subscribe(stream, ["items"]); - }, - }, - ]); - - const response = await app.request("/items/subscribe", { method: "POST" }); - - expect(response.status).toBe(406); - expect(await response.json()).toMatchObject({ statusCode: 406 }); - }); - - it("sends SSE route errors as events and closes only that stream", async () => { - const app = await createApp([ - { - url: "/items/subscribe", - handler: () => { - throw new Error("private storage failure"); - }, - }, - ]); - - const response = await app.request("/items/subscribe", { - method: "POST", - headers: { accept: "text/event-stream" }, - }); - const events = await response.text(); - - expect(response.status).toBe(200); - expect(events).toContain("event: error"); - expect(events).toContain( - 'data: {"statusCode":500,"error":"Internal Server Error"}', - ); - expect(events).not.toContain("private storage failure"); - }); - - it("sends a normal route as one SSE response event and then closes", async () => { - const app = await createApp([ - { - url: "/echo", - handler: (stream) => stream.send({ ok: true }), - }, - ]); - - const response = await app.request("/echo", { - method: "POST", - headers: { accept: "text/event-stream" }, - }); - const events = await response.text(); - - expect(response.status).toBe(200); - expect(events).toContain("event: response"); - expect(events).toContain('data: {"ok":true}'); - }); - - it("keeps SSE open until the route's subscription promise resolves", async () => { - let removeSubscription: () => Promise = async () => undefined; - let markSubscribed: () => void = () => undefined; - const subscribed = new Promise((resolve) => { - markSubscribed = resolve; - }); - const app = await createApp((broadcaster) => [ - { - url: "/items/subscribe", - handler: async (stream) => { - const topics = ["items"]; - removeSubscription = () => broadcaster.unsubscribe(stream, topics); - - const removed = broadcaster.subscribe(stream, topics); - markSubscribed(); - await removed; - }, - }, - ]); - - const response = await app.request("/items/subscribe", { - method: "POST", - headers: { accept: "text/event-stream" }, - }); - const body = response.text(); - const completed = vi.fn(); - void body.then(completed); - - await subscribed; - await Promise.resolve(); - expect(completed).not.toHaveBeenCalled(); - - await removeSubscription(); - - expect(await body).toBe(""); - expect(completed).toHaveBeenCalledOnce(); - }); - - it("rejects unsubscribe over non-bidirectional SSE", async () => { - const app = await createApp((broadcaster) => [ - { - url: "/items/unsubscribe", - handler: async (stream) => { - if (!stream.bidirectional) { - throw new ApplicationError( - 400, - "This route requires an existing bidirectional stream", - ); - } - - await broadcaster.unsubscribe(stream, ["items"]); - }, - }, - ]); - - const response = await app.request("/items/unsubscribe", { - method: "POST", - headers: { accept: "text/event-stream" }, - }); - const events = await response.text(); - - expect(response.status).toBe(200); - expect(events).toContain("event: error"); - expect(events).toContain('"statusCode":400'); - }); - it("rejects HTTP bodies larger than the configured byte limit", async () => { - const app = await createApp( - [ +const createApp = async ( + routes: RouteDefinition[] | ((broadcaster: Broadcaster) => RouteDefinition[]), + maxRequestBodyBytes = 1024 * 1024, +): Promise> => { + const debug = new Logger('http-transport-test'); + const broadcaster = new Broadcaster(debug); + const resolvedRoutes = typeof routes === 'function' ? routes(broadcaster) : routes; + const router = await ApplicationRouter.create([ { - url: "/echo", - handler: async (stream) => stream.send(stream.body), + async getRoutes(): Promise { + return resolvedRoutes; + }, }, - ], - 32, - ); + ]); + const transport = new HttpTransportRouter(router, debug); + const app = new Hono(); - const response = await app.request("/echo", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ value: "x".repeat(64) }), + app.onError(HttpTransportRouter.createErrorHandler(debug)); + app.use('*', ServerHost.limitBodySizeMiddleware(maxRequestBodyBytes, debug)); + app.use('*', HttpTransportRouter.createExtJsonMiddleware(debug)); + transport.register(app); + + return app; +}; + +describe('HttpTransportRouter', (): void => { + it('runs normal HTTP through a non-streaming route stream', async (): Promise => { + const app = await createApp([ + { + url: '/echo', + handler: async (stream): Promise => stream.send(stream.body), + }, + ]); + const value = new Uint8Array([ 1, 2, 3 ]); + + const response = await app.request('/echo', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: toExtendedJson({ value }), + }); + + expect(response.status).toBe(200); + expect(fromExtendedJson(await response.text())).toEqual({ value }); }); - expect(response.status).toBe(413); - expect(await response.json()).toEqual({ - statusCode: 413, - error: "Request body exceeds the 32 byte limit", + it('returns 204 when a normal HTTP route sends nothing', async (): Promise => { + const app = await createApp([ + { + url: '/nothing', + handler: (): void => undefined, + }, + ]); + + const response = await app.request('/nothing', { method: 'POST' }); + + expect(response.status).toBe(204); + expect(await response.text()).toBe(''); + }); + + it('returns normalized errors for non-streaming requests', async (): Promise => { + const app = await createApp([]); + + const missing = await app.request('/missing', { method: 'POST' }); + expect(missing.status).toBe(404); + expect(await missing.json()).toEqual({ + statusCode: 404, + error: 'No route found for /missing', + }); + + const invalid = await app.request('/missing', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: '{', + }); + expect(invalid.status).toBe(400); + expect(await invalid.json()).toEqual({ + statusCode: 400, + error: 'Invalid JSON in request body', + }); + }); + + it('rejects subscribe when normal HTTP has no streaming capability', async (): Promise => { + const app = await createApp((broadcaster) => [ + { + url: '/items/subscribe', + handler: async (stream): Promise => { + await broadcaster.subscribe(stream, [ 'items' ]); + }, + }, + ]); + + const response = await app.request('/items/subscribe', { method: 'POST' }); + + expect(response.status).toBe(406); + expect(await response.json()).toMatchObject({ statusCode: 406 }); + }); + + it('sends SSE route errors as events and closes only that stream', async (): Promise => { + const app = await createApp([ + { + url: '/items/subscribe', + handler: (): void => { + throw new Error('private storage failure'); + }, + }, + ]); + + const response = await app.request('/items/subscribe', { + method: 'POST', + headers: { accept: 'text/event-stream' }, + }); + const events = await response.text(); + + expect(response.status).toBe(200); + expect(events).toContain('event: error'); + expect(events).toContain('data: {"statusCode":500,"error":"Internal Server Error"}'); + expect(events).not.toContain('private storage failure'); + }); + + it('sends a normal route as one SSE response event and then closes', async (): Promise => { + const app = await createApp([ + { + url: '/echo', + handler: (stream): Promise => stream.send({ ok: true }), + }, + ]); + + const response = await app.request('/echo', { + method: 'POST', + headers: { accept: 'text/event-stream' }, + }); + const events = await response.text(); + + expect(response.status).toBe(200); + expect(events).toContain('event: response'); + expect(events).toContain('data: {"ok":true}'); + }); + + it("keeps SSE open until the route's subscription promise resolves", async (): Promise => { + let removeSubscription: () => Promise = async () => undefined; + let markSubscribed: () => void = () => undefined; + const subscribed = new Promise((resolve) => { + markSubscribed = resolve; + }); + const app = await createApp((broadcaster) => [ + { + url: '/items/subscribe', + handler: async (stream): Promise => { + const topics = [ 'items' ]; + removeSubscription = (): Promise => broadcaster.unsubscribe(stream, topics); + + const removed = broadcaster.subscribe(stream, topics); + markSubscribed(); + await removed; + }, + }, + ]); + + const response = await app.request('/items/subscribe', { + method: 'POST', + headers: { accept: 'text/event-stream' }, + }); + const body = response.text(); + const completed = vi.fn(); + void body.then(completed); + + await subscribed; + await Promise.resolve(); + expect(completed).not.toHaveBeenCalled(); + + await removeSubscription(); + + expect(await body).toBe(''); + expect(completed).toHaveBeenCalledOnce(); + }); + + it('rejects unsubscribe over non-bidirectional SSE', async (): Promise => { + const app = await createApp((broadcaster) => [ + { + url: '/items/unsubscribe', + handler: async (stream): Promise => { + if (!stream.bidirectional) { + throw new ApplicationError(400, 'This route requires an existing bidirectional stream'); + } + + await broadcaster.unsubscribe(stream, [ 'items' ]); + }, + }, + ]); + + const response = await app.request('/items/unsubscribe', { + method: 'POST', + headers: { accept: 'text/event-stream' }, + }); + const events = await response.text(); + + expect(response.status).toBe(200); + expect(events).toContain('event: error'); + expect(events).toContain('"statusCode":400'); + }); + it('rejects HTTP bodies larger than the configured byte limit', async (): Promise => { + const app = await createApp( + [ + { + url: '/echo', + handler: async (stream): Promise => stream.send(stream.body), + }, + ], + 32, + ); + + const response = await app.request('/echo', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ value: 'x'.repeat(64) }), + }); + + expect(response.status).toBe(413); + expect(await response.json()).toEqual({ + statusCode: 413, + error: 'Request body exceeds the 32 byte limit', + }); }); - }); }); diff --git a/test/subscription-flow.test.ts b/test/subscription-flow.test.ts index 79c8400..d585d11 100644 --- a/test/subscription-flow.test.ts +++ b/test/subscription-flow.test.ts @@ -1,76 +1,67 @@ -import { describe, expect, it, vi } from "vitest"; +import { describe, expect, it, vi } from 'vitest'; -import type { RouteDefinition, RouteModule } from "../source/routes/types.js"; -import { ApplicationRouter } from "../source/services/router.js"; -import { Broadcaster } from "../source/services/broadcaster.js"; -import { Logger } from "../source/utils/logger.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 { Broadcaster } from '../source/services/broadcaster.ts'; +import { Logger } from '../source/utils/logger.ts'; +import { TestConnection } from './helpers/test-connection.ts'; -function moduleWith(routes: RouteDefinition[]): RouteModule { - return { - async getRoutes() { - return routes; - }, - }; -} - -async function expectPending(promise: Promise): Promise { - const settled = vi.fn(); - void promise.then(settled); - await Promise.resolve(); - expect(settled).not.toHaveBeenCalled(); -} - -describe("long-lived subscription dispatch", () => { - it("allows duplicate subscribe and unsubscribe requests while the original request waits", async () => { - const broadcaster = new Broadcaster(new Logger("subscription-flow-test")); - const router = await ApplicationRouter.create([ - moduleWith([ - { - url: "/items/subscribe", - handler: async (stream) => { - await broadcaster.subscribe(stream, ["items"]); - }, +const moduleWith = (routes: RouteDefinition[]): RouteModule => { + return { + async getRoutes(): Promise { + return routes; }, - { - url: "/items/unsubscribe", - handler: async (stream) => { - await broadcaster.unsubscribe(stream, ["items"]); - await stream.send({}); - }, - }, - ]), - ]); - const connection = new TestConnection(true, true); + }; +}; - const original = router.dispatch( - { path: "/items/subscribe", requestId: "subscribe-1" }, - connection, - ); - await vi.waitFor(() => expect(connection.closeCallbacks).toHaveLength(1)); - await expectPending(original); +const expectPending = async (promise: Promise): Promise => { + const settled = vi.fn(); + void promise.then(settled); + await Promise.resolve(); + expect(settled).not.toHaveBeenCalled(); +}; - // This request uses a different ApplicationRouteStream over the same - // connection. Since the topic already exists, its dispatch completes. - await router.dispatch( - { path: "/items/subscribe", requestId: "subscribe-2" }, - connection, - ); - await expectPending(original); +describe('long-lived subscription dispatch', (): void => { + it('allows duplicate subscribe and unsubscribe requests while the original request waits', async (): Promise => { + const broadcaster = new Broadcaster(new Logger('subscription-flow-test')); + const router = await ApplicationRouter.create([ + moduleWith([ + { + url: '/items/subscribe', + handler: async (stream): Promise => { + await broadcaster.subscribe(stream, [ 'items' ]); + }, + }, + { + url: '/items/unsubscribe', + handler: async (stream): Promise => { + await broadcaster.unsubscribe(stream, [ 'items' ]); + await stream.send({}); + }, + }, + ]), + ]); + const connection = new TestConnection(true, true); - await router.dispatch( - { path: "/items/unsubscribe", requestId: "unsubscribe-1" }, - connection, - ); - await original; + const original = router.dispatch({ path: '/items/subscribe', requestId: 'subscribe-1' }, connection); + await vi.waitFor(() => expect(connection.closeCallbacks).toHaveLength(1)); + await expectPending(original); - expect(connection.messages).toEqual([ - { - id: "unsubscribe-1", - type: "response", - statusCode: 200, - body: {}, - }, - ]); - }); + // This request uses a different ApplicationRouteStream over the same + // connection. Since the topic already exists, its dispatch completes. + await router.dispatch({ path: '/items/subscribe', requestId: 'subscribe-2' }, connection); + await expectPending(original); + + await router.dispatch({ path: '/items/unsubscribe', requestId: 'unsubscribe-1' }, connection); + await original; + + expect(connection.messages).toEqual([ + { + id: 'unsubscribe-1', + type: 'response', + statusCode: 200, + body: {}, + }, + ]); + }); }); diff --git a/tsconfig.json b/tsconfig.json index bae3d55..6e15aa7 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -15,5 +15,5 @@ "declarationMap": true, "types": ["node"] }, - "exclude": ["node_modules/**/*", "dist/**/*", "test"] + "exclude": ["node_modules/**/*", "dist/**/*"] }