diff --git a/src/server.ts b/src/server.ts index b384059..3ae70ea 100644 --- a/src/server.ts +++ b/src/server.ts @@ -75,8 +75,17 @@ type HandlerLookupResult = | RouteCallback | HandlerResult[] | undefined; +type Awaitable = T | PromiseLike; +type ThenCallback = ( + onfulfilled: (value: unknown) => unknown, + onrejected: (reason: unknown) => unknown, +) => unknown; type ExactHandlerIndex = Record>; +interface PromiseLikeValue { + then?: unknown; +} + const exactHandlerIndexes = new WeakMap< Record, ExactHandlerIndex @@ -589,16 +598,63 @@ function executeHandler( return handler.handler(requestContext); } -async function executePriorityHandlers( +function getThen(value: unknown): ThenCallback | undefined { + if ( + value === null || + (typeof value !== "object" && typeof value !== "function") + ) { + return; + } + const then = (value as PromiseLikeValue).then; + return typeof then === "function" ? (then as ThenCallback) : undefined; +} + +function resolveThenable(value: unknown, then: ThenCallback): Promise { + if (value instanceof Promise) { + return value; + } + return new Promise((resolve, reject) => { + queueMicrotask(() => { + try { + then.call(value, resolve, reject); + } catch (error) { + reject(error); + } + }); + }); +} + +function continueExecution( + value: Awaitable, + next: (result: T) => Awaitable, +): Awaitable { + const then = getThen(value); + if (then) { + return resolveThenable(value, then).then((result) => next(result as T)); + } + return next(value as T); +} + +function executePriorityHandlers( handlers: HandlerResult[], requestContext: RequestContext, -) { - if (handlers.length > 1) { + startIndex = 0, +): Awaitable { + if (startIndex === 0 && handlers.length > 1) { handlers.sort((a, b) => a.priority - b.priority); } - for (const { handler, parameters } of handlers) { + for (let index = startIndex; index < handlers.length; index += 1) { + const { handler, parameters } = handlers[index]; requestContext.routeParameters = parameters; - const result = await handler(requestContext); + const result = handler(requestContext); + const then = getThen(result); + if (then) { + return resolveThenable(result, then).then((resolved) => + resolved + ? resolved + : executePriorityHandlers(handlers, requestContext, index + 1), + ); + } if (result) { return result; } @@ -611,7 +667,7 @@ function executeMiddleware( method: string, path: string[], requestContext: RequestContext, -) { +): Awaitable { if (handlerCounts[mode] === 0) { return undefined; } @@ -622,17 +678,29 @@ function executeMiddleware( return executePriorityHandlers(handlers, requestContext); } -async function runMonitors( +function runMonitors( monitors: HandlerResult[], monitorContext: RequestContext, -) { - if (monitors.length > 1) { + startIndex = 0, +): Awaitable { + if (startIndex === 0 && monitors.length > 1) { monitors.sort((a, b) => a.priority - b.priority); } - for (const { handler, parameters } of monitors) { + for (let index = startIndex; index < monitors.length; index += 1) { + const { handler, parameters } = monitors[index]; monitorContext.routeParameters = parameters; try { - await handler(monitorContext); + const result = handler(monitorContext); + const then = getThen(result); + if (then) { + return resolveThenable(result, then).then( + () => runMonitors(monitors, monitorContext, index + 1), + (error) => { + console.error(error); + return runMonitors(monitors, monitorContext, index + 1); + }, + ); + } } catch (error) { console.error(error); } @@ -643,7 +711,7 @@ function executeMonitors( method: string, path: string[], requestContext: RequestContext, -) { +): Awaitable { if (handlerCounts.monitor === 0) { return; } @@ -659,11 +727,107 @@ function executeMonitors( return runMonitors(monitors, monitorContext); } -export async function requestListener( +function setMiddlewareResponse( + requestContext: RequestContext, + result: unknown, +): void { + if (result) { + requestContext.response = HTTPResult.withHeaders( + result, + requestContext.response.getHeaders(), + 200, + ); + } +} + +function executePostfix( + method: string, + path: string[], + requestContext: RequestContext, +): Awaitable { + const execution = executeMiddleware("postfix", method, path, requestContext); + return continueExecution(execution, (result) => { + setMiddlewareResponse(requestContext, result); + }); +} + +function executeHandlerAndPostfix( + handler: HandlerResult | RouteCallback, + method: string, + path: string[], + requestContext: RequestContext, +): Awaitable { + const execution = executeHandler(handler, requestContext); + return continueExecution(execution, (result) => { + setHandlerResponse(requestContext, result); + return executePostfix(method, path, requestContext); + }); +} + +function executeRequest( + method: string, + path: string[], + exactPath: string, + requestContext: RequestContext, +): Awaitable { + let handler = getHandler(method, path, roots.handler, false, exactPath); + if (!handler && method === "head") { + handler = getHandler("get", path, roots.handler, false, exactPath); + } + const selectedHandler = Array.isArray(handler) ? undefined : handler; + if (!selectedHandler && method !== "options") { + return; + } + const prefixExecution = executeMiddleware( + "prefix", + method, + path, + requestContext, + ); + return continueExecution(prefixExecution, (prefixResult) => { + setMiddlewareResponse(requestContext, prefixResult); + if (prefixResult || !selectedHandler) { + return; + } + return executeHandlerAndPostfix( + selectedHandler, + method, + path, + requestContext, + ); + }); +} + +function completeRequest( + didFail: boolean, + error: unknown, + method: string, + path: string[], + requestContext: RequestContext, +): Awaitable { + if (didFail) { + requestContext.response = HTTPResult.withHeaders( + extractError(error), + requestContext.response.getHeaders(), + 500, + ); + } + requestContext.error = error; + const monitorExecution = executeMonitors(method, path, requestContext); + return continueExecution(monitorExecution, () => + handleResult( + method === "head", + requestContext.response, + requestContext.rawResponse, + ), + ); +} + +function processRequest( req: IncomingMessage, res: ServerResponse, protocol: "http" | "https", -) { +): Awaitable { const url = new URL( req.url || "", `${protocol}://${req.headers.host || "localhost"}`, @@ -675,73 +839,38 @@ export async function requestListener( routeParameters: {}, response: new HTTPResult(404, "Not Found"), }; - const path = url.pathname.split("/").filter((part) => part); const method = req.method?.toLowerCase() || "get"; - const isHeadRequest = method === "head"; - let requestError: unknown; try { - let handler = getHandler(method, path, roots.handler, false, url.pathname); - if (!handler && method === "head") { - handler = getHandler("get", path, roots.handler, false, url.pathname); - } - if (!handler && method !== "options") { - // Fall through to finally block to execute monitors. - } else { - const prefixExecution = executeMiddleware( - "prefix", - method, - path, - requestContext, - ); - const prefixResult = prefixExecution ? await prefixExecution : undefined; - if (prefixResult) { - requestContext.response = HTTPResult.withHeaders( - prefixResult, - requestContext.response.getHeaders(), - 200, - ); - } else if (!handler && method === "options") { - // Fall through to finally block to execute monitors. - } else { - if (handler && !Array.isArray(handler)) { - const result = await executeHandler(handler, requestContext); - setHandlerResponse(requestContext, result); - } - - const postfixExecution = executeMiddleware( - "postfix", - method, - path, - requestContext, - ); - const postfixResult = postfixExecution - ? await postfixExecution - : undefined; - if (postfixResult) { - requestContext.response = HTTPResult.withHeaders( - postfixResult, - requestContext.response.getHeaders(), - 200, - ); - } - } - } - } catch (error: unknown) { - requestError = error; - requestContext.response = HTTPResult.withHeaders( - extractError(error), - requestContext.response.getHeaders(), - 500, + const execution = executeRequest( + method, + path, + url.pathname, + requestContext, ); - } finally { - requestContext.error = requestError; - const monitorExecution = executeMonitors(method, path, requestContext); - if (monitorExecution) { - await monitorExecution; + const then = getThen(execution); + if (then) { + return resolveThenable(execution, then).then( + () => completeRequest(false, undefined, method, path, requestContext), + (error) => completeRequest(true, error, method, path, requestContext), + ); } - handleResult(isHeadRequest, requestContext.response, res); + } catch (error) { + return completeRequest(true, error, method, path, requestContext); + } + return completeRequest(false, undefined, method, path, requestContext); +} + +export function requestListener( + req: IncomingMessage, + res: ServerResponse, + protocol: "http" | "https", +): Awaitable { + try { + return processRequest(req, res, protocol); + } catch (error) { + return Promise.reject(error); } } diff --git a/src/test/server-synchronous-path.test.ts b/src/test/server-synchronous-path.test.ts new file mode 100644 index 0000000..7394ff6 --- /dev/null +++ b/src/test/server-synchronous-path.test.ts @@ -0,0 +1,413 @@ +import assert from "node:assert"; +import { execFile } from "node:child_process"; +import * as http from "node:http"; +import path from "node:path"; +import { promisify } from "node:util"; +import { HandlerPriority, HTTPResult } from "@antelopejs/interface-api"; +import { WebSocket } from "ws"; +import { + type RequestContext, + registerHandler, + requestListener, + unregisterHandler, + upgradeListener, +} from "../server"; + +const TEST_HOST = "127.0.0.1"; +const SUCCESS_STATUS = 200; +const SERVER_ERROR_STATUS = 500; +const THEN_PROPERTY = ["th", "en"].join(""); +const execFileAsync = promisify(execFile); + +interface TestResponse { + body: string; + headers: http.IncomingHttpHeaders; + status: number; +} + +interface RequestOptions { + method?: string; + path?: string; +} + +interface StatefulThenable { + getReadCount: () => number; + value: PromiseLike; +} + +function immediateThenable(value: T): PromiseLike { + const thenable = Object.create(null); + Object.defineProperty(thenable, THEN_PROPERTY, { + value: (resolve: (resolved: T) => unknown) => resolve(value), + }); + return thenable as PromiseLike; +} + +function rejectedThenable(error: unknown): PromiseLike { + const thenable = Object.create(null); + Object.defineProperty(thenable, THEN_PROPERTY, { + value: (_resolve: unknown, reject: (reason: unknown) => unknown) => + reject(error), + }); + return thenable as PromiseLike; +} + +function statefulThenable(value: string): StatefulThenable { + let readCount = 0; + const thenable = Object.create(null); + Object.defineProperty(thenable, THEN_PROPERTY, { + get: () => { + readCount += 1; + if (readCount > 1) { + throw new Error("then getter read more than once"); + } + return (resolve: (resolved: string) => unknown) => resolve(value); + }, + }); + return { + getReadCount: () => readCount, + value: thenable as PromiseLike, + }; +} + +function listen(server: http.Server): Promise { + return new Promise((resolve) => { + server.listen(0, TEST_HOST, () => { + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("Expected an IP server address"); + } + resolve(address.port); + }); + }); +} + +function close(server: http.Server): Promise { + return new Promise((resolve) => server.close(() => resolve())); +} + +function sendRequest( + port: number, + options: RequestOptions = {}, +): Promise { + return new Promise((resolve, reject) => { + const request = http.request( + { + host: TEST_HOST, + port, + method: options.method ?? "GET", + path: options.path ?? "/sync", + }, + (response) => { + const chunks: Buffer[] = []; + response.on("data", (chunk: Buffer) => chunks.push(chunk)); + response.on("end", () => + resolve({ + body: Buffer.concat(chunks).toString(), + headers: response.headers, + status: response.statusCode ?? 0, + }), + ); + }, + ); + request.on("error", reject); + request.end(); + }); +} + +async function getIsolatedListenerResult(): Promise { + const serverPath = path.resolve(__dirname, "../server.js"); + const script = [ + 'const http = require("node:http");', + "const api = require(process.argv[1]);", + 'api.registerHandler("p", "prefix", "GET", "/", () => undefined);', + 'api.registerHandler("h", "handler", "GET", "/", () => "ok");', + 'api.registerHandler("x", "postfix", "GET", "/", () => undefined);', + 'api.registerHandler("m", "monitor", "GET", "/", () => undefined);', + "let result;", + 'const server = http.createServer((req, res) => { result = api.requestListener(req, res, "http"); });', + 'server.listen(0, "127.0.0.1", () => {', + " const port = server.address().port;", + ' http.get({ host: "127.0.0.1", port }, (res) => {', + ' res.resume().on("end", () => server.close(() => process.stdout.write(String(result))));', + " });", + "});", + ].join("\n"); + const { stdout } = await execFileAsync(process.execPath, [ + "-e", + script, + serverPath, + ]); + return stdout; +} + +describe("Synchronous HTTP request path", () => { + const routeIds: string[] = []; + let server: http.Server; + let port: number; + let listenerResult: unknown; + + function register( + id: string, + mode: "prefix" | "postfix" | "handler" | "monitor" | "websocket", + method: string | undefined, + location: string, + callback: (context: RequestContext) => unknown, + priority = HandlerPriority.NORMAL, + ): void { + routeIds.push(id); + registerHandler(id, mode, method, location, callback, priority); + } + + beforeEach(async () => { + listenerResult = undefined; + server = http.createServer((request, response) => { + listenerResult = requestListener(request, response, "http"); + }); + server.on("upgrade", (request, socket, head) => { + void upgradeListener(request, socket, head, "ws"); + }); + port = await listen(server); + }); + + afterEach(async () => { + for (const id of routeIds.splice(0)) { + unregisterHandler(id); + } + await close(server); + }); + + it("completes synchronous callbacks in request order", async () => { + const order: string[] = []; + register("sync-prefix", "prefix", "GET", "/sync", () => { + order.push("prefix"); + }); + register("sync-handler", "handler", "GET", "/sync", () => { + order.push("handler"); + return "ok"; + }); + register("sync-postfix", "postfix", "GET", "/sync", () => { + order.push("postfix"); + }); + register("sync-monitor", "monitor", "GET", "/sync", () => { + order.push("monitor"); + }); + + const response = await sendRequest(port); + + assert.equal(response.status, SUCCESS_STATUS); + assert.equal(response.body, "ok"); + assert.deepEqual(order, ["prefix", "handler", "postfix", "monitor"]); + }); + + it("returns no awaitable for an isolated synchronous stack", async () => { + assert.equal(await getIsolatedListenerResult(), "undefined"); + }); + + it("continues in order when each phase returns a promise or thenable", async () => { + const order: string[] = []; + const handlerResult = statefulThenable("async-ok"); + register("async-prefix", "prefix", "GET", "/async", () => + Promise.resolve().then(() => { + order.push("prefix"); + }), + ); + register("thenable-handler", "handler", "GET", "/async", () => { + order.push("handler"); + return handlerResult.value; + }); + register("thenable-postfix", "postfix", "GET", "/async", () => { + order.push("postfix"); + return immediateThenable(undefined); + }); + register("async-monitor", "monitor", "GET", "/async", () => + Promise.resolve().then(() => order.push("monitor")), + ); + + const response = await sendRequest(port, { path: "/async" }); + + assert.equal(response.body, "async-ok"); + assert.ok(listenerResult instanceof Promise); + assert.equal(handlerResult.getReadCount(), 1); + assert.deepEqual(order, ["prefix", "handler", "postfix", "monitor"]); + }); + + it("preserves priorities, dynamic parameters, and early responses", async () => { + const order: string[] = []; + register( + "priority-low", + "prefix", + "GET", + "/priority/:id", + () => order.push("low"), + HandlerPriority.LOW, + ); + register( + "priority-high", + "prefix", + "GET", + "/priority/:id", + (context) => { + order.push(`high:${context.routeParameters.id}`); + return new HTTPResult(202, "early"); + }, + HandlerPriority.HIGH, + ); + register("skipped-handler", "handler", "GET", "/priority/:id", () => { + order.push("handler"); + }); + register("early-monitor", "monitor", "GET", "/priority/:id", () => { + order.push("monitor"); + }); + + const response = await sendRequest(port, { path: "/priority/42" }); + + assert.equal(response.status, 202); + assert.equal(response.body, "early"); + assert.deepEqual(order, ["high:42", "monitor"]); + }); + + it("turns synchronous throws and asynchronous rejections into errors", async () => { + const errors: unknown[] = []; + register("throw-handler", "handler", "GET", "/throw", () => { + throw new Error("sync failure"); + }); + register("throw-monitor", "monitor", "GET", "/throw", (context) => { + errors.push(context.error); + }); + register("reject-handler", "handler", "GET", "/reject", () => + Promise.reject(new Error("async failure")), + ); + register("reject-monitor", "monitor", "GET", "/reject", (context) => { + errors.push(context.error); + }); + register("thenable-reject", "handler", "GET", "/thenable-reject", () => + rejectedThenable(new Error("thenable failure")), + ); + + const thrown = await sendRequest(port, { path: "/throw" }); + const rejected = await sendRequest(port, { path: "/reject" }); + const thenable = await sendRequest(port, { path: "/thenable-reject" }); + + assert.equal(thrown.status, SERVER_ERROR_STATUS); + assert.equal(thrown.body, "sync failure"); + assert.equal(rejected.status, SERVER_ERROR_STATUS); + assert.equal(rejected.body, "async failure"); + assert.equal(thenable.status, SERVER_ERROR_STATUS); + assert.equal(thenable.body, "thenable failure"); + assert.equal((errors[0] as Error).message, "sync failure"); + assert.equal((errors[1] as Error).message, "async failure"); + }); + + it("isolates monitor failures and continues remaining monitors", async () => { + const order: string[] = []; + const originalConsoleError = console.error; + console.error = () => undefined; + register("monitor-handler", "handler", "GET", "/monitors", () => "ok"); + register( + "monitor-throw", + "monitor", + "GET", + "/monitors", + () => { + order.push("throw"); + throw new Error("ignored"); + }, + HandlerPriority.HIGH, + ); + register( + "monitor-reject", + "monitor", + "GET", + "/monitors", + () => { + order.push("reject"); + return Promise.reject(new Error("ignored")); + }, + HandlerPriority.NORMAL, + ); + register( + "monitor-final", + "monitor", + "GET", + "/monitors", + () => order.push("final"), + HandlerPriority.LOW, + ); + + try { + const response = await sendRequest(port, { path: "/monitors" }); + assert.equal(response.body, "ok"); + assert.deepEqual(order, ["throw", "reject", "final"]); + } finally { + console.error = originalConsoleError; + } + }); + + it("preserves HEAD fallback, OPTIONS handling, and hot reload", async () => { + register("head-get", "handler", "GET", "/head", () => "head-body"); + register( + "options-prefix", + "prefix", + "OPTIONS", + "/options", + () => new HTTPResult(204, null), + ); + register("reload-first", "handler", "GET", "/reload", () => "first"); + + const head = await sendRequest(port, { method: "HEAD", path: "/head" }); + const options = await sendRequest(port, { + method: "OPTIONS", + path: "/options", + }); + const first = await sendRequest(port, { path: "/reload" }); + unregisterHandler("reload-first"); + register("reload-second", "handler", "GET", "/reload", () => "second"); + const second = await sendRequest(port, { path: "/reload" }); + + assert.equal(head.status, SUCCESS_STATUS); + assert.equal(head.body, ""); + assert.equal(options.status, 204); + assert.equal(first.body, "first"); + assert.equal(second.body, "second"); + }); + + it("keeps stream responses intact", async () => { + register("stream-handler", "handler", "GET", "/stream", (context) => { + const output = context.response.getWriteStream("text/plain"); + output.end("streamed"); + return "replacement"; + }); + + const response = await sendRequest(port, { path: "/stream" }); + + assert.equal(response.status, SUCCESS_STATUS); + assert.equal(response.body, "streamed"); + }); + + it("does not alter WebSocket prefix, handler, and monitor ordering", async () => { + const order: string[] = []; + register("ws-prefix", "prefix", "GET", "/socket", () => { + order.push("prefix"); + }); + register("ws-handler", "websocket", "GET", "/socket", (context) => { + order.push("handler"); + (context.connection as WebSocket).send("connected"); + }); + register("ws-monitor", "monitor", "GET", "/socket", () => { + order.push("monitor"); + }); + + const message = await new Promise((resolve, reject) => { + const socket = new WebSocket(`ws://${TEST_HOST}:${port}/socket`); + socket.on("message", (data) => { + resolve(data.toString()); + socket.close(); + }); + socket.on("error", reject); + }); + + assert.equal(message, "connected"); + assert.deepEqual(order, ["prefix", "handler", "monitor"]); + }); +});