diff --git a/examples/tool/package.json b/examples/tool/package.json index 81659e6d..1d97a12e 100644 --- a/examples/tool/package.json +++ b/examples/tool/package.json @@ -145,4 +145,4 @@ "@universal-middleware/hono": "^0", "@universal-middleware/webroute": "^0" } -} +} \ No newline at end of file diff --git a/packages/core/package.json b/packages/core/package.json index c2671b1e..26f94908 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -14,6 +14,7 @@ "license": "MIT", "scripts": { "build": "rimraf dist && tsup", + "test": "vitest --typecheck run", "prepack": "pnpm build", "test:typecheck": "tsc -p tsconfig.json --noEmit", "release": "LANG=en_US release-me patch", @@ -27,7 +28,8 @@ "@types/node": "^20.14.10", "rimraf": "^6.0.0", "tsup": "^8.2.4", - "typescript": "^5.5.4" + "typescript": "^5.5.4", + "vitest": "^2.0.5" }, "sideEffects": false } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 60d16679..5d8ed6b7 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -2,3 +2,4 @@ export type * from "./types.js"; export * from "./runtime.js"; export * from "./adapter.js"; export * from "./utils.js"; +export * from "./pipe.js"; diff --git a/packages/core/src/pipe.ts b/packages/core/src/pipe.ts new file mode 100644 index 00000000..0d02ad44 --- /dev/null +++ b/packages/core/src/pipe.ts @@ -0,0 +1,70 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import type { Awaitable, UniversalHandler, UniversalMiddleware } from "./types"; + +type Out = T extends UniversalMiddleware ? C : never; +type In = + T extends UniversalHandler + ? C + : T extends UniversalMiddleware + ? C + : never; +type First = T extends [infer X, ...any[]] ? X : never; +type Last = T extends [...any[], infer X] ? X : never; + +type ComposeReturnType[]> = + Last extends UniversalHandler + ? UniversalHandler>> + : UniversalMiddleware>, In>>; + +type Pipe[]> = F extends [] + ? F + : F extends [UniversalMiddleware] + ? F + : F extends [ + UniversalMiddleware, + UniversalMiddleware, + ] + ? [UniversalMiddleware, UniversalMiddleware] + : F extends [ + ...infer X extends UniversalMiddleware[], + infer Y extends UniversalMiddleware, + UniversalMiddleware, + ] + ? [...Pipe<[...X, Y]>, UniversalMiddleware, D1>] + : never; + +export function pipe[]>( + ...a: Pipe +): ComposeReturnType { + const middlewares = a as UniversalMiddleware[]; + const handler = a.pop() as UniversalHandler; + + return async (request, context, runtime) => { + const pending: ((response: Response) => Awaitable)[] = []; + + for (const m of middlewares) { + const response = await m(request, context, runtime); + + if (typeof response === "function") { + pending.push(response); + } else if (response !== null && typeof response === "object") { + if (response instanceof Response) { + return response; + } + // Update context + context = response as any; + } + } + + let response = await handler(request, context, runtime); + + for (const m of pending) { + const r = await m(response); + if (r) { + response = r; + } + } + + return response; + }; +} diff --git a/packages/core/test/pipe.test-d.ts b/packages/core/test/pipe.test-d.ts new file mode 100644 index 00000000..099261d2 --- /dev/null +++ b/packages/core/test/pipe.test-d.ts @@ -0,0 +1,67 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { expectTypeOf, test } from "vitest"; +import type { + UniversalHandler, + UniversalMiddleware, +} from "@universal-middleware/core"; +import { pipe } from "../src/pipe"; + +type M1 = UniversalMiddleware<{ a: 1 }, { a: 1; b: 2 }>; +type M2 = UniversalMiddleware<{ a: 1; b: 2 }, { a: 1; b: 2; c: 3 }>; +type M3 = UniversalMiddleware<{ a: 1; b: 2; c: 3 }, { a: 1; b: 2; c: 3 }>; +type H1 = UniversalHandler<{ a: 1; b: 2; c: 3 }>; + +test("pipe", () => { + const m1: M1 = {} as any; + const m2: M2 = {} as any; + const m3: M3 = {} as any; + const h1: H1 = {} as any; + + expectTypeOf(pipe(m3, h1)).toEqualTypeOf< + UniversalHandler<{ a: 1; b: 2; c: 3 }> + >(h1); + expectTypeOf( + pipe( + m1, + // @ts-expect-error + h1, + ), + ).toEqualTypeOf>(); + expectTypeOf(pipe(m1, m2, m3, h1)).toEqualTypeOf< + UniversalHandler<{ a: 1 }> + >(); + expectTypeOf( + pipe( + m1, + m1, + // @ts-expect-error + h1, + ), + ).toEqualTypeOf>(); + expectTypeOf( + pipe( + m1, + // @ts-expect-error + m3, + h1, + ), + ).toEqualTypeOf>(); + + expectTypeOf(pipe(m1, m2, m3)).toEqualTypeOf< + UniversalMiddleware<{ a: 1 }, { a: 1; b: 2; c: 3 }> + >(); + + expectTypeOf( + pipe((_: Request) => new Response(null)), + ).toEqualTypeOf(); + expectTypeOf( + pipe( + () => { + return { + a: "1", + }; + }, + (a: Request, c: { a: string }) => new Response(c.a), + ), + ).toEqualTypeOf(); +}); diff --git a/packages/core/test/pipe.test.ts b/packages/core/test/pipe.test.ts new file mode 100644 index 00000000..75013d85 --- /dev/null +++ b/packages/core/test/pipe.test.ts @@ -0,0 +1,96 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { describe, expect, test } from "vitest"; +import { pipe } from "../src/pipe"; +import type { RuntimeAdapter, UniversalMiddleware } from "../src/index"; + +describe("pipe", () => { + const request = new Request("http://localhost"); + const context: Universal.Context = {}; + const runtime: RuntimeAdapter = { + runtime: "other", + adapter: "other", + }; + + test("handler", async () => { + const handler = pipe(() => new Response("OK")); + const response = handler(request, context, runtime); + await expect(response).resolves.toBeInstanceOf(Response); + }); + + test("context middleware |> handler", async () => { + const handler = pipe( + () => ({ a: 1 }), + (_: Request, ctx: { a: number }) => new Response(String(ctx.a)), + ); + const response = handler(request, context, runtime); + await expect(response).resolves.toBeInstanceOf(Response); + + const body = await (await response).text(); + expect(body).toBe("1"); + }); + + test("context middleware |> empty middlware |> handler", async () => { + const handler = pipe( + () => ({ a: 1 }), + (async () => {}) as UniversalMiddleware<{ a: number }, { a: number }>, + (_: Request, ctx: { a: number }) => new Response(String(ctx.a)), + ); + const response = handler(request, context, runtime); + await expect(response).resolves.toBeInstanceOf(Response); + + const body = await (await response).text(); + expect(body).toBe("1"); + }); + + test("context middleware |> context middleware |> handler", async () => { + const handler = pipe( + () => ({ a: 1 }), + async (_: Request, ctx: { a: number }) => { + return { + ...ctx, + b: 2, + }; + }, + (_: Request, ctx: { a: number; b: number }) => + new Response(String(ctx.a + ctx.b)), + ); + const response = handler(request, context, runtime); + await expect(response).resolves.toBeInstanceOf(Response); + + const body = await (await response).text(); + expect(body).toBe("3"); + }); + + test("context middleware |> response |> handler", async () => { + const handler = pipe( + () => ({ a: 1 }), + async (_: Request) => { + return new Response("STOPPED"); + }, + (_: Request) => new Response(null), + ); + const response = handler(request, context, runtime); + await expect(response).resolves.toBeInstanceOf(Response); + + const body = await (await response).text(); + expect(body).toBe("STOPPED"); + }); + + test("context middleware |> response handler |> handler", async () => { + const handler = pipe( + () => ({ a: 1 }), + (_: Request) => { + return async (response: Response) => { + const body = await (await response).text(); + return new Response(body + " World!"); + }; + }, + (_: Request) => new Response("Hello"), + ); + const response = handler(request, context, runtime); + await expect(response).resolves.toBeInstanceOf(Response); + + const body = await (await response).text(); + expect(body).toBe("Hello World!"); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f0fd4c32..2a6932fd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -451,6 +451,9 @@ importers: typescript: specifier: ^5.5.4 version: 5.5.4 + vitest: + specifier: ^2.0.5 + version: 2.0.5(@types/node@20.16.0) packages/tests: dependencies: