diff --git a/package.json b/package.json index 2011d4a..e5d8b5e 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,7 @@ } }, "dependencies": { - "@antelopejs/interface-api": ">=0.0.11 <1.0.0", + "@antelopejs/interface-api": ">=0.0.12 <1.0.0", "@antelopejs/interface-api-util": "^0.1.1", "@antelopejs/interface-core": ">=0.0.6 <1.0.0", "reflect-metadata": "^0.2.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d27be23..78095d4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,11 +9,11 @@ importers: .: dependencies: '@antelopejs/interface-api': - specifier: '>=0.0.11 <1.0.0' - version: 0.0.11(@antelopejs/interface-core@0.0.11) + specifier: '>=0.0.12 <1.0.0' + version: 0.0.12(@antelopejs/interface-core@0.0.11) '@antelopejs/interface-api-util': specifier: ^0.1.1 - version: 0.1.1(@antelopejs/interface-api@0.0.11(@antelopejs/interface-core@0.0.11))(@antelopejs/interface-core@0.0.11) + version: 0.1.1(@antelopejs/interface-api@0.0.12(@antelopejs/interface-core@0.0.11))(@antelopejs/interface-core@0.0.11) '@antelopejs/interface-core': specifier: '>=0.0.6 <1.0.0' version: 0.0.11 @@ -85,8 +85,8 @@ packages: '@antelopejs/interface-api': '>=0.0.3 <1.0.0' '@antelopejs/interface-core': '>=0.0.3 <1.0.0' - '@antelopejs/interface-api@0.0.11': - resolution: {integrity: sha512-egWmk2m+aVSN022xmxBCq5SbPgkN1VVJosXvLnNX2nBB+YAPKmPwsJGg53jota7TE7dK1gdL42HmiwWRHhh1eA==} + '@antelopejs/interface-api@0.0.12': + resolution: {integrity: sha512-28cZoGZ/BxO7jhbrZ/ueOK26+9cRPCA0jKpeJv155G8/RtqFVFcqG7SsVunuIubDT/ukRrb2JUFrnT0uvJV2mw==} peerDependencies: '@antelopejs/interface-core': '>=0.0.3 <1.0.0' @@ -1231,9 +1231,9 @@ packages: snapshots: - '@antelopejs/interface-api-util@0.1.1(@antelopejs/interface-api@0.0.11(@antelopejs/interface-core@0.0.11))(@antelopejs/interface-core@0.0.11)': + '@antelopejs/interface-api-util@0.1.1(@antelopejs/interface-api@0.0.12(@antelopejs/interface-core@0.0.11))(@antelopejs/interface-core@0.0.11)': dependencies: - '@antelopejs/interface-api': 0.0.11(@antelopejs/interface-core@0.0.11) + '@antelopejs/interface-api': 0.0.12(@antelopejs/interface-core@0.0.11) '@antelopejs/interface-core': 0.0.11 '@antelopejs/interface-api-util@0.1.1(@antelopejs/interface-api@0.0.8(@antelopejs/interface-core@0.0.11))(@antelopejs/interface-core@0.0.11)': @@ -1241,7 +1241,7 @@ snapshots: '@antelopejs/interface-api': 0.0.8(@antelopejs/interface-core@0.0.11) '@antelopejs/interface-core': 0.0.11 - '@antelopejs/interface-api@0.0.11(@antelopejs/interface-core@0.0.11)': + '@antelopejs/interface-api@0.0.12(@antelopejs/interface-core@0.0.11)': dependencies: '@antelopejs/interface-core': 0.0.11 diff --git a/src/implementations/api/index.ts b/src/implementations/api/index.ts index d05e4cd..0a0d190 100644 --- a/src/implementations/api/index.ts +++ b/src/implementations/api/index.ts @@ -28,6 +28,15 @@ type ParameterResolver = ( controllerInstance: UnknownRecord, ) => unknown; +type ThenCallback = ( + onfulfilled: (value: unknown) => unknown, + onrejected: (reason: unknown) => unknown, +) => unknown; + +interface PromiseLikeValue { + then?: unknown; +} + interface ComputedPropertyResolver { key: string; resolve: ParameterResolver; @@ -74,13 +83,60 @@ function compileParameter( 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 (context, controller) => + applyModifiers( + provider.call(controller, context), + modifiers, + context, + controller, + ); +} + +function applyModifiers( + initialValue: unknown, + modifiers: ComputedParameter["modifiers"], + context: RequestContextDev, + controller: UnknownRecord, + startIndex = 0, +): unknown { + let value = initialValue; + for (let index = startIndex; index < modifiers.length; index += 1) { + const then = getThen(value); + if (then) { + return resolveThenable(value, then).then((resolved) => + applyModifiers(resolved, modifiers, context, controller, index), + ); } + value = modifiers[index].call(controller, context, value); + } + const then = getThen(value); + return then ? resolveThenable(value, then) : value; +} + +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 compileController( diff --git a/src/server.ts b/src/server.ts index dd2a4b4..daf201c 100644 --- a/src/server.ts +++ b/src/server.ts @@ -29,6 +29,8 @@ export interface RequestContext { interface DynamicRoute { match: RegExp; + requiredPrefix: string; + requiredSuffix: string; parameterName?: string; parameterNames: string[]; sub: RouteLevel; @@ -173,6 +175,12 @@ function findHandlers( } for (const route of level.dynamicRouteList) { + if ( + (route.requiredPrefix && !part.startsWith(route.requiredPrefix)) || + (route.requiredSuffix && !part.endsWith(route.requiredSuffix)) + ) { + continue; + } const match = route.match.exec(part); if (!match) { continue; @@ -436,8 +444,16 @@ function compileDynamicRoute(part: string): DynamicRoute { pattern.push(`(.*)`); } pattern.push("$"); + const firstParameter = part.indexOf(":"); + const lastParameter = part.lastIndexOf(":"); + let suffixStart = lastParameter + 1; + while (suffixStart < part.length && /[a-zA-Z0-9]/.test(part[suffixStart])) { + suffixStart += 1; + } return { match: new RegExp(pattern.join("")), + requiredPrefix: part.slice(0, firstParameter), + requiredSuffix: part.slice(suffixStart), parameterName: mapping.length === 1 ? mapping[0] : undefined, parameterNames: mapping, sub: new RouteLevel(), diff --git a/src/test/controller-resolution.test.ts b/src/test/controller-resolution.test.ts index a5dbb07..bcc38f6 100644 --- a/src/test/controller-resolution.test.ts +++ b/src/test/controller-resolution.test.ts @@ -13,6 +13,7 @@ import { requestListener } from "../server"; const TEST_HOST = "127.0.0.1"; const TEST_ORIGIN = `http://${TEST_HOST}`; +const THEN_PROPERTY = ["th", "en"].join(""); interface TestController { requestId?: string; @@ -24,6 +25,11 @@ interface TestResponse { body: string; } +interface StatefulThenable { + readCount: () => number; + value: PromiseLike; +} + type ControllerConstructor = (new () => TestController) & { location: string; }; @@ -42,6 +48,21 @@ function createController(): ControllerConstructor { }; } +function statefulThenable(value: string): StatefulThenable { + let reads = 0; + const thenable = Object.create(null); + Object.defineProperty(thenable, THEN_PROPERTY, { + get: () => { + reads += 1; + if (reads > 1) { + throw new Error("then getter read more than once"); + } + return (resolve: (resolved: string) => unknown) => resolve(value); + }, + }); + return { readCount: () => reads, value: thenable }; +} + function createHandler( Controller: ControllerConstructor, callback: RouteHandler["callback"], @@ -95,6 +116,7 @@ describe("Controller resolution", () => { let server: Server; let port: number; let nextRouteId = 0; + let listenerResult: unknown; function register(handler: RouteHandler): void { const id = `controller-resolution-${nextRouteId++}`; @@ -104,7 +126,7 @@ describe("Controller resolution", () => { before(async () => { server = createServer((request, response) => { - void requestListener(request, response, "http"); + listenerResult = requestListener(request, response, "http"); }); port = await listen(server); }); @@ -278,6 +300,75 @@ describe("Controller resolution", () => { }); }); + it("keeps synchronous modifier chains on the synchronous request path", async () => { + const Controller = createController(); + const location = "/controller-resolution/synchronous-modifiers"; + register( + createHandler( + Controller, + function (this: TestController, value) { + return `${value}:${this.sequence}`; + }, + location, + [ + computedParameter( + function (this: TestController) { + this.sequence += 1; + return "provider"; + }, + [ + function (this: TestController, _context, value) { + this.sequence += 1; + return `${value}:first`; + }, + function (this: TestController, _context, value) { + this.sequence += 1; + return `${value}:second`; + }, + ], + ), + ], + ), + ); + + assert.deepEqual(await get(port, location), { + status: 200, + body: "provider:first:second:3", + }); + assert.equal(listenerResult, undefined); + }); + + it("continues remaining modifiers after the first asynchronous value", async () => { + const Controller = createController(); + const events: string[] = []; + const thenable = statefulThenable("value:thenable"); + const location = "/controller-resolution/mixed-modifiers"; + register( + createHandler(Controller, (value) => value, location, [ + computedParameter(() => { + events.push("provider"); + return thenable.value; + }, [ + (_context, value) => { + events.push("async"); + return Promise.resolve(`${value}:async`); + }, + (_context, value) => { + events.push("remaining"); + return `${value}:remaining`; + }, + ]), + ]), + ); + + assert.deepEqual(await get(port, location), { + status: 200, + body: "value:thenable:async:remaining", + }); + assert.deepEqual(events, ["provider", "async", "remaining"]); + assert.equal(thenable.readCount(), 1); + }); + it("turns provider and modifier failures into request errors", async () => { const ProviderController = createController(); const ModifierController = createController(); diff --git a/src/test/routing-parameters.test.ts b/src/test/routing-parameters.test.ts index e1f8fcd..3d666dc 100644 --- a/src/test/routing-parameters.test.ts +++ b/src/test/routing-parameters.test.ts @@ -155,6 +155,34 @@ describe("Compiled route parameter extraction", () => { }); }); + it("preserves prefixed and suffixed dynamic segment matching", async () => { + register( + "compiled-filter-prefix", + "handler", + "get", + "/compiled/filter/pre:id.json", + ({ routeParameters }) => `prefix:${routeParameters.id}`, + ); + register( + "compiled-filter-suffix", + "handler", + "get", + "/compiled/filter/:first-:second.tail", + ({ routeParameters }) => + `suffix:${routeParameters.first}:${routeParameters.second}`, + ); + + assert.equal( + (await request("/compiled/filter/prevalue.json")).body, + "prefix:value", + ); + assert.equal( + (await request("/compiled/filter/left-right.tail")).body, + "suffix:left:right", + ); + assert.equal((await request("/compiled/filter/value.json")).status, 404); + }); + it("isolates parameter objects for multiple handlers", async () => { const observed: string[] = []; register(