diff --git a/src/lib/http-server/index.test.ts b/src/lib/http-server/index.test.ts index ace6f5a9..9f3b60ea 100644 --- a/src/lib/http-server/index.test.ts +++ b/src/lib/http-server/index.test.ts @@ -589,3 +589,141 @@ test('maxBodySize: rejects oversized requests', async function() { await server.stop(); }, 30000); + +test('KeetaNetCombinedAnchorHTTPServer: combines routes from multiple servers', async function() { + const serverA = new (class extends HTTPServer.KeetaNetAnchorHTTPServer { + protected async initRoutes(): Promise { + return({ + 'GET /api/server-a': async function() { + return({ output: JSON.stringify({ server: 'A' }), statusCode: 200 }); + } + }); + } + })({ port: 0 }); + + const serverB = new (class extends HTTPServer.KeetaNetAnchorHTTPServer { + protected async initRoutes(): Promise { + return({ + 'GET /api/server-b': async function() { + return({ output: JSON.stringify({ server: 'B' }), statusCode: 200 }); + } + }); + } + })({ port: 0 }); + + await using combined = new HTTPServer.KeetaNetCombinedAnchorHTTPServer({ + port: 0, + servers: [serverA, serverB] + }); + + await combined.start(); + + /* + * Routes from both child servers should be accessible via the combined server. + */ + const responseA = await testHTTPRequest(combined.url, '/api/server-a', 'GET'); + expect(responseA.code).toBe(200); + expect(responseA.body).toMatchObject({ server: 'A' }); + + const responseB = await testHTTPRequest(combined.url, '/api/server-b', 'GET'); + expect(responseB.code).toBe(200); + expect(responseB.body).toMatchObject({ server: 'B' }); + + /* + * A route that doesn't exist on either child should return 404. + */ + const responseMissing = await testHTTPRequest(combined.url, '/api/missing', 'GET'); + expect(responseMissing.code).toBe(404); + + /* + * The combined server's URL should be propagated to child instances after start. + */ + expect(serverA.url).toBe(combined.url); + expect(serverB.url).toBe(combined.url); +}, 30000); + +test('KeetaNetCombinedAnchorHTTPServer: later child routes overwrite earlier ones on conflict', async function() { + const serverA = new (class extends HTTPServer.KeetaNetAnchorHTTPServer { + protected async initRoutes(): Promise { + return({ + 'GET /api/conflict': async function() { + return({ output: JSON.stringify({ from: 'A' }), statusCode: 200 }); + } + }); + } + })({ port: 0 }); + + const serverB = new (class extends HTTPServer.KeetaNetAnchorHTTPServer { + protected async initRoutes(): Promise { + return({ + 'GET /api/conflict': async function() { + return({ output: JSON.stringify({ from: 'B' }), statusCode: 200 }); + } + }); + } + })({ port: 0 }); + + await using combined = new HTTPServer.KeetaNetCombinedAnchorHTTPServer({ + port: 0, + servers: [serverA, serverB] + }); + + await combined.start(); + + const response = await testHTTPRequest(combined.url, '/api/conflict', 'GET'); + expect(response.code).toBe(200); + expect(response.body).toMatchObject({ from: 'B' }); +}, 30000); + +test('KeetaNetCombinedAnchorHTTPServer: url set on combined server propagates to children', async function() { + const child = new (class extends HTTPServer.KeetaNetAnchorHTTPServer { + protected async initRoutes(): Promise { + return({ + 'GET /health': async function() { + return({ output: JSON.stringify({ ok: true }), statusCode: 200 }); + } + }); + } + })({ port: 0 }); + + const customURL = 'https://anchor.example.com/'; + + await using combined = new HTTPServer.KeetaNetCombinedAnchorHTTPServer({ + port: 0, + url: customURL, + servers: [child] + }); + + await combined.start(); + + expect(combined.url).toBe(customURL); + + /* + * The custom URL should also be propagated to the child. + */ + expect(child.url).toBe(customURL); +}, 30000); + +test('KeetaNetCombinedAnchorHTTPServer: throws when a child has a conflicting URL', async function() { + const conflictingURL = 'https://other.example.com/'; + + const child = new (class extends HTTPServer.KeetaNetAnchorHTTPServer { + protected async initRoutes(): Promise { + return({ + 'GET /health': async function() { + return({ output: JSON.stringify({ ok: true }), statusCode: 200 }); + } + }); + } + })({ port: 0, url: conflictingURL }); + + const customURL = 'https://anchor.example.com/'; + + await using combined = new HTTPServer.KeetaNetCombinedAnchorHTTPServer({ + port: 0, + url: customURL, + servers: [child] + }); + + await expect(combined.start()).rejects.toThrow(`Child server url "${conflictingURL}" does not match combined server url "${customURL}"`); +}, 30000); diff --git a/src/lib/http-server/index.ts b/src/lib/http-server/index.ts index 86c6ddeb..2ee72b5f 100644 --- a/src/lib/http-server/index.ts +++ b/src/lib/http-server/index.ts @@ -69,7 +69,7 @@ export abstract class KeetaNetAnchorHTTPServer; #server?: http.Server; #urlParts: undefined | { hostname?: string; port?: number; protocol?: string; }; - #url: undefined | string | URL | ((object: this) => string); + #url: undefined | string | URL | ((object: KeetaNetAnchorHTTPServer) => string); readonly #config: ConfigType; constructor(config: ConfigType) { @@ -83,18 +83,6 @@ export abstract class KeetaNetAnchorHTTPServer string)) { + set url(value: undefined | string | URL | ((object: KeetaNetAnchorHTTPServer) => string)) { this.#urlParts = undefined; this.#url = value; } + /** + * Expose the config so that subclasses can access it without + * needing to maintain their own copy of the same data. + */ + protected get config(): ConfigType { + return(this.#config); + } + + /** + * Build the routes for this server by calling initRoutes with the + * config that was provided at construction time. This is used + * internally by KeetaNetCombinedAnchorHTTPServer to gather routes + * from each child server. + */ + protected buildRoutes(): Promise { + return(this.initRoutes(this.#config)); + } + [Symbol.asyncDispose](): Promise { return(this.stop()); } } + +export interface KeetaAnchorCombinedHTTPServerConfig extends KeetaAnchorHTTPServerConfig { + /** + * The list of servers whose routes should be combined. + */ + servers: KeetaNetAnchorHTTPServer[]; +} + +/** + * A KeetaNetAnchorHTTPServer that combines the routes of multiple + * KeetaNetAnchorHTTPServer instances into a single HTTP server. + * + * When the combined server starts it propagates its URL to all child + * server instances so that callers holding references to those children + * can retrieve the correct URL via child.url. + */ +export class KeetaNetCombinedAnchorHTTPServer extends KeetaNetAnchorHTTPServer { + protected async initRoutes(config: KeetaAnchorCombinedHTTPServerConfig): Promise { + const combined: Routes = {}; + + for (const child of config.servers) { + // buildRoutes() is protected; bracket notation is used here to access + // it on an arbitrary KeetaNetAnchorHTTPServer instance. + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + const childRoutes: Routes = await (child as unknown as { buildRoutes(): Promise }).buildRoutes(); + Object.assign(combined, childRoutes); + } + + return(combined); + } + + override async start(): Promise { + await super.start(); + + const url = this.url; + + /* + * Propagate the combined server's URL to all child server instances. + * If a child already has a URL set it must match the combined server's + * URL; a mismatch indicates a misconfiguration. + */ + for (const child of this.config.servers) { + let childURL: string | undefined; + try { + childURL = child.url; + } catch (err) { + if (!(err instanceof Error && err.message === 'Server not started')) { + throw(err); + } + /* child has no static URL set; the URL will be propagated below */ + } + + if (childURL !== undefined && childURL !== url) { + throw(new Error(`Child server url "${childURL}" does not match combined server url "${url}"`)); + } + + child.url = url; + } + } +}