From 9179e924d7091081cda2f2d4db0eba24a8ed1817 Mon Sep 17 00:00:00 2001 From: charlie12520 Date: Wed, 27 May 2026 14:06:04 -0400 Subject: [PATCH 1/2] fix: normalize adapter middleware responses --- .../create-winter-spec-from-route-map.ts | 5 +- src/types/winter-spec.ts | 39 +++++- tests/errors/do-not-allow-raw-json.test.ts | 120 ++++++++++++++++++ 3 files changed, 160 insertions(+), 4 deletions(-) diff --git a/src/serve/create-winter-spec-from-route-map.ts b/src/serve/create-winter-spec-from-route-map.ts index fa45720..a591eb4 100644 --- a/src/serve/create-winter-spec-from-route-map.ts +++ b/src/serve/create-winter-spec-from-route-map.ts @@ -2,6 +2,7 @@ import { getRouteMatcher } from "next-route-matcher" import { normalizeRouteMap } from "../lib/normalize-route-map.js" import { WinterSpecRouteFn } from "src/types/web-handler.js" import { + MakeRequestOptions, WinterSpecRouteBundle, WinterSpecOptions, makeRequestAgainstWinterSpec, @@ -24,8 +25,8 @@ export const createWinterSpecFromRouteMap = ( const winterSpec = { routeMatcher, routeMapWithHandlers, - makeRequest: async (req: Request) => - makeRequestAgainstWinterSpec(winterSpec)(req), + makeRequest: async (req: Request, opts?: MakeRequestOptions) => + makeRequestAgainstWinterSpec(winterSpec, opts)(req), ...winterSpecOptions, } diff --git a/src/types/winter-spec.ts b/src/types/winter-spec.ts index ba5adac..7213b88 100644 --- a/src/types/winter-spec.ts +++ b/src/types/winter-spec.ts @@ -1,10 +1,12 @@ import type { Middleware } from "src/middleware/types.js" import { createWinterSpecRequest, + type SerializableToResponse, type WinterSpecRouteFn, type WinterSpecRouteParams, WinterSpecRequest, } from "./web-handler.js" +import { z } from "zod" import type { ReadonlyDeep } from "type-fest" import { wrapMiddlewares } from "src/create-with-winter-spec.js" @@ -123,14 +125,47 @@ export function makeRequestAgainstWinterSpec( }) if (!routeFn) { - return await handle404(winterSpecRequest, getDefaultContext()) + const response = await handle404(winterSpecRequest, getDefaultContext()) + return serializeAdapterMiddlewareResponse(response) } - return wrapMiddlewares( + const response = await wrapMiddlewares( options.middleware ?? [], routeFn, winterSpecRequest, getDefaultContext() ) + + return serializeAdapterMiddlewareResponse(response) + } +} + +function canSerializeToResponse( + response: unknown +): response is SerializableToResponse { + return ( + response !== null && + typeof response === "object" && + "serializeToResponse" in response && + typeof response.serializeToResponse === "function" + ) +} + +function serializeAdapterMiddlewareResponse(response: unknown): Response { + if (response instanceof Response) { + return response } + + // Adapter-level middleware runs outside route-specific serializers. + if (canSerializeToResponse(response)) { + return response.serializeToResponse(z.any()) + } + + if (typeof response === "object") { + throw new Error( + "Use ctx.json({...}) instead of returning an object directly." + ) + } + + return response as Response } diff --git a/tests/errors/do-not-allow-raw-json.test.ts b/tests/errors/do-not-allow-raw-json.test.ts index 8682796..167d491 100644 --- a/tests/errors/do-not-allow-raw-json.test.ts +++ b/tests/errors/do-not-allow-raw-json.test.ts @@ -1,5 +1,7 @@ import test from "ava" import { z } from "zod" +import { createWithWinterSpec } from "src/create-with-winter-spec.js" +import { createWinterSpecFromRouteMap } from "src/serve/create-winter-spec-from-route-map.js" import { getTestRoute } from "tests/fixtures/get-test-route.js" test("should throw an error when responding with raw JSON", async (t) => { @@ -37,3 +39,121 @@ test("should throw an error when responding with raw JSON", async (t) => { ) ) }) + +test("should throw an error when adapter middleware responds with raw JSON", async (t) => { + const withRouteSpec = createWithWinterSpec({ + authMiddleware: {}, + }) + const winterSpec = createWinterSpecFromRouteMap({ + "/": withRouteSpec({ + methods: ["GET"], + auth: "none", + jsonResponse: z.any(), + })((req, ctx) => { + return ctx.json({ ok: true }) + }), + }) + const makeRequest = (request: Request) => + winterSpec.makeRequest(request, { + middleware: [ + async () => { + return { foo: "bar" } as any + }, + ], + }) + + const error = await t.throwsAsync( + makeRequest(new Request("https://example.com/")) + ) + t.true( + error?.message.includes( + "Use ctx.json({...}) instead of returning an object directly." + ) + ) +}) + +test("should serialize ctx.json returned by adapter middleware", async (t) => { + const withRouteSpec = createWithWinterSpec({ + authMiddleware: {}, + }) + const winterSpec = createWinterSpecFromRouteMap({ + "/": withRouteSpec({ + methods: ["GET"], + auth: "none", + jsonResponse: z.any(), + })((req, ctx) => { + return ctx.json({ ok: true }) + }), + }) + const response = await winterSpec.makeRequest( + new Request("https://example.com/"), + { + middleware: [ + async (_req, ctx: any) => { + return ctx.json({ intercepted: true }) + }, + ], + } + ) + t.true(response instanceof Response) + t.is(response.status, 200) + t.deepEqual(await response.json(), { intercepted: true }) +}) + +test("should throw an error when handle404 responds with raw JSON", async (t) => { + const withRouteSpec = createWithWinterSpec({ + authMiddleware: {}, + }) + const winterSpec = createWinterSpecFromRouteMap( + { + "/": withRouteSpec({ + methods: ["GET"], + auth: "none", + })(() => { + return new Response("ok") + }), + }, + { + handle404: () => { + return { error: "not found" } as any + }, + } + ) + + const error = await t.throwsAsync( + winterSpec.makeRequest(new Request("https://example.com/missing")) + ) + t.true( + error?.message.includes( + "Use ctx.json({...}) instead of returning an object directly." + ) + ) +}) + +test("should serialize ctx.json returned by handle404", async (t) => { + const withRouteSpec = createWithWinterSpec({ + authMiddleware: {}, + }) + const winterSpec = createWinterSpecFromRouteMap( + { + "/": withRouteSpec({ + methods: ["GET"], + auth: "none", + })(() => { + return new Response("ok") + }), + }, + { + handle404: ((_req: any, ctx: any) => { + return ctx.json({ error: "not found" }) + }) as any, + } + ) + + const response = await winterSpec.makeRequest( + new Request("https://example.com/missing") + ) + t.true(response instanceof Response) + t.is(response.status, 200) + t.deepEqual(await response.json(), { error: "not found" }) +}) From d2b6ae67e9b332130dc303d8c685348a491b9a39 Mon Sep 17 00:00:00 2001 From: charlie12520 Date: Wed, 27 May 2026 15:11:08 -0400 Subject: [PATCH 2/2] test: reject invalid adapter response returns --- src/types/winter-spec.ts | 4 +- tests/errors/do-not-allow-raw-json.test.ts | 60 ++++++++++++++++++++++ 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/src/types/winter-spec.ts b/src/types/winter-spec.ts index 7213b88..9f33245 100644 --- a/src/types/winter-spec.ts +++ b/src/types/winter-spec.ts @@ -167,5 +167,7 @@ function serializeAdapterMiddlewareResponse(response: unknown): Response { ) } - return response as Response + throw new Error( + "WinterSpec handlers must return a Response or ctx.json(...)." + ) } diff --git a/tests/errors/do-not-allow-raw-json.test.ts b/tests/errors/do-not-allow-raw-json.test.ts index 167d491..94b40ed 100644 --- a/tests/errors/do-not-allow-raw-json.test.ts +++ b/tests/errors/do-not-allow-raw-json.test.ts @@ -100,6 +100,36 @@ test("should serialize ctx.json returned by adapter middleware", async (t) => { t.deepEqual(await response.json(), { intercepted: true }) }) +test("should throw a helpful error when adapter middleware returns undefined", async (t) => { + const withRouteSpec = createWithWinterSpec({ + authMiddleware: {}, + }) + const winterSpec = createWinterSpecFromRouteMap({ + "/": withRouteSpec({ + methods: ["GET"], + auth: "none", + jsonResponse: z.any(), + })((req, ctx) => { + return ctx.json({ ok: true }) + }), + }) + + const error = await t.throwsAsync( + winterSpec.makeRequest(new Request("https://example.com/"), { + middleware: [ + async () => { + return undefined as any + }, + ], + }) + ) + t.true( + error?.message.includes( + "WinterSpec handlers must return a Response or ctx.json(...)." + ) + ) +}) + test("should throw an error when handle404 responds with raw JSON", async (t) => { const withRouteSpec = createWithWinterSpec({ authMiddleware: {}, @@ -157,3 +187,33 @@ test("should serialize ctx.json returned by handle404", async (t) => { t.is(response.status, 200) t.deepEqual(await response.json(), { error: "not found" }) }) + +test("should throw a helpful error when handle404 returns a primitive", async (t) => { + const withRouteSpec = createWithWinterSpec({ + authMiddleware: {}, + }) + const winterSpec = createWinterSpecFromRouteMap( + { + "/": withRouteSpec({ + methods: ["GET"], + auth: "none", + })(() => { + return new Response("ok") + }), + }, + { + handle404: (() => { + return "not found" + }) as any, + } + ) + + const error = await t.throwsAsync( + winterSpec.makeRequest(new Request("https://example.com/missing")) + ) + t.true( + error?.message.includes( + "WinterSpec handlers must return a Response or ctx.json(...)." + ) + ) +})