From 7710b802aa10236a6ef55f719fd7446858d38662 Mon Sep 17 00:00:00 2001 From: Amp Date: Wed, 19 Aug 2026 00:13:14 +0000 Subject: [PATCH] perf(api): compile controller resolution plans Amp-Thread-ID: https://ampcode.com/threads/T-01a01745-dd95-747a-957f-c70955e98bc1 Co-authored-by: Upd4ting --- src/implementations/api/index.ts | 232 +++++++++++++++---- src/test/controller-resolution.test.ts | 300 +++++++++++++++++++++++++ 2 files changed, 485 insertions(+), 47 deletions(-) create mode 100644 src/test/controller-resolution.test.ts diff --git a/src/implementations/api/index.ts b/src/implementations/api/index.ts index 71ed771..404444b 100644 --- a/src/implementations/api/index.ts +++ b/src/implementations/api/index.ts @@ -2,7 +2,6 @@ import { type ComputedParameter, ControllerMeta, type CorsConfig, - computeParameter, type RouteHandler, } from "@antelopejs/interface-api"; import { GetMetadata } from "@antelopejs/interface-core"; @@ -24,8 +23,31 @@ interface ControllerMetadata { computed_props: Record; } +type ParameterResolver = ( + context: RequestContextDev, + controllerInstance: UnknownRecord, +) => unknown; + +interface ComputedPropertyResolver { + key: string; + resolve: ParameterResolver; +} + +interface ControllerPlan { + controllerClass: ControllerClass; + cacheKey: object; + computedProperties: ComputedPropertyResolver[]; +} + +interface HandlerPlan { + callback: RouteHandler["callback"]; + controller: ControllerPlan; + parameters: ParameterResolver[]; +} + const classCacheSymbol = Symbol(); const registeredRoutes = new Map(); +const controllerPlans = new WeakMap(); interface RequestContextDev extends RequestContext { [classCacheSymbol]?: Map; @@ -39,32 +61,105 @@ function getControllerCache(context: RequestContextDev): Map { return context[classCacheSymbol]; } -async function applyComputedProperties( +function compileParameter( + parameter: ComputedParameter | null, +): ParameterResolver { + if (!parameter?.provider) { + return () => undefined; + } + + const { provider } = parameter; + const modifiers = [...parameter.modifiers]; + if (modifiers.length === 0) { + return (context, controller) => provider.call(controller, context); + } + + return async (context, controller) => { + let value = await provider.call(controller, context); + for (const modifier of modifiers) { + value = await modifier.call(controller, context, value); + } + return value; + }; +} + +function compileController( + controllerClass: ControllerClass, + properties: Record, +): ControllerPlan { + const computedProperties = Object.entries(properties).map( + ([key, parameter]) => ({ + key, + resolve: compileParameter(parameter), + }), + ); + const existingPlan = controllerPlans.get(controllerClass); + const plan = existingPlan ?? { + controllerClass, + cacheKey: controllerClass.prototype, + computedProperties, + }; + plan.computedProperties = computedProperties; + controllerPlans.set(controllerClass, plan); + return plan; +} + +function getControllerPlan(controllerClass: ControllerClass): ControllerPlan { + const cachedPlan = controllerPlans.get(controllerClass); + if (cachedPlan) { + return cachedPlan; + } + + const metadata = GetMetadata( + controllerClass, + ControllerMeta, + ) as ControllerMetadata; + return compileController(controllerClass, metadata.computed_props); +} + +function isPromiseLike(value: unknown): value is PromiseLike { + return ( + value !== null && + (typeof value === "object" || typeof value === "function") && + "then" in value && + typeof value.then === "function" + ); +} + +function applyComputedProperties( controllerInstance: UnknownRecord, - controllerMetadata: ControllerMetadata, + computedProperties: ComputedPropertyResolver[], context: RequestContextDev, -): Promise { - await Promise.all( - Object.entries(controllerMetadata.computed_props).map( - async ([propertyKey, parameter]) => { - const computedValue = await computeParameter( - context, - parameter, - controllerInstance, - ); - controllerInstance[propertyKey] = computedValue; - }, - ), - ); +): void | Promise { + if (computedProperties.length === 0) { + return; + } + + const pending: Promise[] = []; + for (const property of computedProperties) { + const value = property.resolve(context, controllerInstance); + if (isPromiseLike(value)) { + pending.push( + Promise.resolve(value).then((resolved) => { + controllerInstance[property.key] = resolved; + }), + ); + } else { + controllerInstance[property.key] = value; + } + } + + if (pending.length > 0) { + return Promise.all(pending).then(() => undefined); + } } -export async function GetControllerInstance( - controllerClass: Class, - context: RequestContext, -): Promise { - const controllerCache = getControllerCache(context as RequestContextDev); - const cacheKey = controllerClass.prototype; - const cachedController = controllerCache.get(cacheKey) as +function resolveControllerInstance( + plan: ControllerPlan, + context: RequestContextDev, +): UnknownRecord | Promise { + const controllerCache = getControllerCache(context); + const cachedController = controllerCache.get(plan.cacheKey) as | UnknownRecord | undefined; @@ -72,23 +167,31 @@ export async function GetControllerInstance( return cachedController; } - const typedControllerClass = controllerClass as ControllerClass; - const controllerInstance = new typedControllerClass() as UnknownRecord; - const controllerMetadata = GetMetadata( - typedControllerClass, - ControllerMeta, - ) as ControllerMetadata; - - await applyComputedProperties( + const controllerInstance = new plan.controllerClass() as UnknownRecord; + const computed = applyComputedProperties( controllerInstance, - controllerMetadata, - context as RequestContextDev, + plan.computedProperties, + context, ); + if (computed) { + return computed.then(() => { + controllerCache.set(plan.cacheKey, controllerInstance); + return controllerInstance; + }); + } - controllerCache.set(cacheKey, controllerInstance); + controllerCache.set(plan.cacheKey, controllerInstance); return controllerInstance; } +export async function GetControllerInstance( + controllerClass: Class, + context: RequestContext, +): Promise { + const plan = getControllerPlan(controllerClass as ControllerClass); + return resolveControllerInstance(plan, context as RequestContextDev); +} + export async function Listen(): Promise { await listenServers(); } @@ -110,32 +213,67 @@ interface RouteInfo { callbackName: string; } -async function invokeHandler( - handler: RouteHandler, +function invokeCallback( + plan: HandlerPlan, + controllerInstance: UnknownRecord, context: RequestContextDev, -): Promise { - const controllerClass = handler.proto.constructor as ControllerClass; - const controllerInstance = await GetControllerInstance( - controllerClass, - context, +): unknown { + if (plan.parameters.length === 0) { + return plan.callback.call(controllerInstance); + } + if (plan.parameters.length === 1) { + const parameter = plan.parameters[0](context, controllerInstance); + if (isPromiseLike(parameter)) { + return Promise.resolve(parameter).then((resolved) => + plan.callback.call(controllerInstance, resolved), + ); + } + return plan.callback.call(controllerInstance, parameter); + } + + const parameters = plan.parameters.map((resolve) => + resolve(context, controllerInstance), ); - const resolvedParameters = await Promise.all( - handler.parameters.map((parameter) => - computeParameter(context, parameter, controllerInstance), - ), + if (parameters.some(isPromiseLike)) { + return Promise.all(parameters).then((resolved) => + plan.callback.apply(controllerInstance, resolved), + ); + } + return plan.callback.apply(controllerInstance, parameters); +} + +function invokeHandler(plan: HandlerPlan, context: RequestContextDev): unknown { + const controllerInstance = resolveControllerInstance( + plan.controller, + context, ); - return handler.callback.apply(controllerInstance, resolvedParameters); + if (isPromiseLike(controllerInstance)) { + return Promise.resolve(controllerInstance).then((resolved) => + invokeCallback(plan, resolved, context), + ); + } + return invokeCallback(plan, controllerInstance, context); +} + +function compileHandler(handler: RouteHandler): HandlerPlan { + const controllerClass = handler.proto.constructor as ControllerClass; + return { + callback: handler.callback, + controller: compileController(controllerClass, handler.properties), + parameters: handler.parameters.map(compileParameter), + }; } export const routesProxy = { register: (id: string, handler: RouteHandler): void => { registeredRoutes.set(id, handler); + const plan = compileHandler(handler); registerHandler( `dev/${id}`, handler.mode, handler.method, handler.location, - async (context: RequestContextDev) => invokeHandler(handler, context), + (context: RequestContextDev) => invokeHandler(plan, context), handler.priority, ); }, diff --git a/src/test/controller-resolution.test.ts b/src/test/controller-resolution.test.ts new file mode 100644 index 0000000..ad7b304 --- /dev/null +++ b/src/test/controller-resolution.test.ts @@ -0,0 +1,300 @@ +import assert from "node:assert"; +import { createServer, type Server } from "node:http"; +import { + type ComputedParameter, + ControllerMeta, + type RouteHandler, +} from "@antelopejs/interface-api"; +import { GetMetadata } from "@antelopejs/interface-core"; +import { routesProxy } from "../implementations/api"; +import { requestListener } from "../server"; + +const TEST_HOST = "127.0.0.1"; +const TEST_ORIGIN = `http://${TEST_HOST}`; + +interface TestController { + requestId?: string; + sequence: number; +} + +interface TestResponse { + status: number; + body: string; +} + +type ControllerConstructor = (new () => TestController) & { + location: string; +}; + +function computedParameter( + provider: ComputedParameter["provider"], + modifiers: ComputedParameter["modifiers"] = [], +): ComputedParameter { + return { provider, modifiers }; +} + +function createController(): ControllerConstructor { + return class { + static location = ""; + sequence = 0; + }; +} + +function createHandler( + Controller: ControllerConstructor, + callback: RouteHandler["callback"], + location: string, + parameters: RouteHandler["parameters"] = [], + properties: RouteHandler["properties"] = {}, + mode: RouteHandler["mode"] = "handler", +): RouteHandler { + return { + mode, + method: "get", + location, + callback, + parameters, + properties, + proto: Controller.prototype, + }; +} + +function listen(server: Server): Promise { + return new Promise((resolve) => { + server.listen(0, TEST_HOST, () => { + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("Unable to resolve test server port"); + } + resolve(address.port); + }); + }); +} + +function close(server: Server): Promise { + return new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); +} + +async function get( + port: number, + path: string, + requestId?: string, +): Promise { + const response = await fetch(`${TEST_ORIGIN}:${port}${path}`, { + headers: requestId ? { "x-request-id": requestId } : undefined, + }); + return { status: response.status, body: await response.text() }; +} + +describe("Controller resolution", () => { + const routeIds: string[] = []; + let server: Server; + let port: number; + let nextRouteId = 0; + + function register(handler: RouteHandler): void { + const id = `controller-resolution-${nextRouteId++}`; + routeIds.push(id); + routesProxy.register(id, handler); + } + + before(async () => { + server = createServer((request, response) => { + void requestListener(request, response, "http"); + }); + port = await listen(server); + }); + + after(async () => { + routeIds.forEach((id) => { + routesProxy.unregister(id); + }); + await close(server); + }); + + it("isolates computed properties across concurrent requests", async () => { + const Controller = createController(); + const properties = { + requestId: computedParameter( + (context) => + new Promise((resolve) => { + setTimeout( + () => resolve(context.rawRequest.headers["x-request-id"]), + 1, + ); + }), + ), + }; + register( + createHandler( + Controller, + function (this: TestController) { + this.sequence += 1; + return `${this.requestId}:${this.sequence}`; + }, + "/controller-resolution/isolation", + [], + properties, + ), + ); + + const identifiers = Array.from( + { length: 40 }, + (_, index) => `request-${index}`, + ); + const responses = await Promise.all( + identifiers.map((id) => + get(port, "/controller-resolution/isolation", id), + ), + ); + + assert.deepEqual( + responses.map((response) => response.body), + identifiers.map((id) => `${id}:1`), + ); + }); + + it("reuses one controller within a request", async () => { + const Controller = createController(); + const location = "/controller-resolution/reuse"; + register( + createHandler( + Controller, + function (this: TestController) { + this.sequence += 1; + }, + location, + [], + {}, + "prefix", + ), + ); + register( + createHandler( + Controller, + function (this: TestController) { + this.sequence += 1; + return this.sequence.toString(); + }, + location, + ), + ); + + assert.deepEqual(await get(port, location), { status: 200, body: "2" }); + assert.deepEqual(await get(port, location), { status: 200, body: "2" }); + }); + + it("applies inherited computed metadata", async () => { + const Parent = createController(); + class Child extends Parent {} + const parentMetadata = GetMetadata(Parent, ControllerMeta); + parentMetadata.computed_props.requestId = computedParameter( + (context) => context.rawRequest.headers["x-request-id"], + ); + const childMetadata = GetMetadata(Child, ControllerMeta); + const location = "/controller-resolution/inheritance"; + register( + createHandler( + Child, + function (this: TestController) { + return this.requestId; + }, + location, + [], + childMetadata.computed_props, + ), + ); + + assert.deepEqual(await get(port, location, "inherited"), { + status: 200, + body: "inherited", + }); + }); + + it("resolves computed values and handler parameters with controller this", async () => { + const Controller = createController(); + const properties = { + requestId: computedParameter( + function (this: TestController, context) { + this.sequence += 1; + return context.rawRequest.headers["x-request-id"]; + }, + [ + async function (this: TestController, _context, value) { + this.sequence += 1; + return `${value}:computed`; + }, + ], + ), + }; + const parameters = [ + computedParameter(function (this: TestController) { + this.sequence += 1; + return this.requestId; + }), + computedParameter(async function (this: TestController) { + this.sequence += 1; + return this.sequence; + }), + null, + ]; + const location = "/controller-resolution/computed"; + register( + createHandler( + Controller, + function (this: TestController, value, sequence, missing) { + return `${value}:${sequence}:${missing}:${this.sequence}`; + }, + location, + parameters, + properties, + ), + ); + + assert.deepEqual(await get(port, location, "value"), { + status: 200, + body: "value:computed:4:undefined:4", + }); + }); + + it("turns provider and modifier failures into request errors", async () => { + const ProviderController = createController(); + const ModifierController = createController(); + register( + createHandler( + ProviderController, + () => "unreachable", + "/controller-resolution/provider-error", + [computedParameter(() => Promise.reject(new Error("provider failed")))], + ), + ); + register( + createHandler( + ModifierController, + () => "unreachable", + "/controller-resolution/modifier-error", + [ + computedParameter( + () => "value", + [ + () => { + throw new Error("modifier failed"); + }, + ], + ), + ], + ), + ); + + assert.deepEqual(await get(port, "/controller-resolution/provider-error"), { + status: 500, + body: "provider failed", + }); + assert.deepEqual(await get(port, "/controller-resolution/modifier-error"), { + status: 500, + body: "modifier failed", + }); + }); +});