diff --git a/src/context.ts b/src/context.ts index d9d32f9..3967ae6 100644 --- a/src/context.ts +++ b/src/context.ts @@ -166,6 +166,7 @@ export type InputContext< InferHeadersInput & { asResponse?: boolean; returnHeaders?: boolean; + returnCookies?: boolean; use?: Middleware[]; path?: string; }; diff --git a/src/cookies.test.ts b/src/cookies.test.ts index aec12fc..9db9689 100644 --- a/src/cookies.test.ts +++ b/src/cookies.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest"; import { createEndpoint } from "./endpoint"; import { z } from "zod"; import { signCookieValue } from "./crypto"; -import { parseCookies } from "./cookies"; +import { extractSetCookes, parseCookies, parseSetCookie } from "./cookies"; describe("parseCookies", () => { it("should parse cookies", () => { @@ -18,6 +18,58 @@ describe("parseCookies", () => { }); }); +describe("parseSetCookie", () => { + it("should parse a simple Set-Cookie header", () => { + const header = "test=test; Path=/; HttpOnly; Secure"; + const cookie = parseSetCookie(header); + expect(cookie.name).toBe("test"); + expect(cookie.value).toBe("test"); + expect(cookie.path).toBe("/"); + expect(cookie.httpOnly).toBe(true); + expect(cookie.secure).toBe(true); + }); + + it("should parse multiple attributes", () => { + const header = + "sessionId=abc123; Path=/; Domain=example.com; HttpOnly; Secure; Max-Age=3600; Expires=Wed, 21 Oct 2025 07:28:00 GMT; SameSite=Lax"; + const cookie = parseSetCookie(header); + + expect(cookie.name).toBe("sessionId"); + expect(cookie.value).toBe("abc123"); + expect(cookie.path).toBe("/"); + expect(cookie.domain).toBe("example.com"); + expect(cookie.httpOnly).toBe(true); + expect(cookie.secure).toBe(true); + expect(cookie.maxAge).toBe(3600); + expect(cookie.expires?.toISOString()).toBe("2025-10-21T07:28:00.000Z"); + expect(cookie.sameSite).toBe("Lax"); + }); + + it("should parse Set-Cookie with prefix and partitioned flag", () => { + const header = "__Host-test=value; Path=/; Secure; Partitioned; Prefix=__Host"; + const cookie = parseSetCookie(header); + expect(cookie.prefix).toBe("__Host"); + expect(cookie.partitioned).toBe(true); + expect(cookie.secure).toBe(true); + }); +}); + +describe("extractSetCookies", () => { + it("should extract multiple Set-Cookie headers", () => { + const headers = new Headers(); + headers.append("Set-Cookie", "a=1; Path=/"); + headers.append("Set-Cookie", "b=2; HttpOnly"); + const cookies = extractSetCookes(headers); + expect(cookies).toHaveLength(2); + expect(cookies[0].name).toBe("a"); + expect(cookies[0].value).toBe("1"); + expect(cookies[0].path).toBe("/"); + expect(cookies[1].name).toBe("b"); + expect(cookies[1].value).toBe("2"); + expect(cookies[1].httpOnly).toBe(true); + }); +}); + describe("get-cookies", () => { it("should get cookies", async () => { const endpoint = createEndpoint( @@ -257,3 +309,81 @@ describe("set-cookies", () => { expect(response2).toBe("test"); }); }); + +describe("return-cookies", () => { + it("should return cookies when returnCookies is true", async () => { + const endpoint = createEndpoint("/", { method: "POST" }, async (c) => { + c.setCookie("test", "test"); + }); + + const response = await endpoint({ returnCookies: true }); + expect(response.cookies).toHaveLength(1); + expect(response.cookies?.[0].name).toBe("test"); + expect(response.cookies?.[0].value).toBe("test"); + }); + + it("should return multiple cookies when returnCookies is true", async () => { + const endpoint = createEndpoint("/", { method: "POST" }, async (c) => { + c.setCookie("test", "test"); + c.setCookie("test2", "test2"); + c.setCookie("test3", "test3"); + }); + + const response = await endpoint({ returnCookies: true }); + expect(response.cookies).toHaveLength(3); + const names = response.cookies?.map((c) => c.name); + expect(names).toContain("test"); + expect(names).toContain("test2"); + expect(names).toContain("test3"); + }); + + it("should return cookies with options applied", async () => { + const endpoint = createEndpoint("/", { method: "POST" }, async (c) => { + c.setCookie("test", "test", { + secure: true, + httpOnly: true, + path: "/", + }); + }); + + const response = await endpoint({ returnCookies: true }); + const cookie = response.cookies?.[0]; + expect(cookie?.name).toBe("test"); + expect(cookie?.value).toBe("test"); + expect(cookie?.path).toBe("/"); + expect(cookie?.secure).toBe(true); + expect(cookie?.httpOnly).toBe(true); + }); + + it("should return headers and cookies when both returnHeaders and returnCookies are true", async () => { + const endpoint = createEndpoint("/", { method: "POST" }, async (c) => { + c.setCookie("test", "test"); + c.setCookie("test2", "test2"); + }); + + const response = await endpoint({ returnHeaders: true, returnCookies: true }); + expect(response.headers.get("set-cookie")).toBe("test=test, test2=test2"); + expect(response.cookies).toHaveLength(2); + const names = response.cookies?.map((c) => c.name); + expect(names).toContain("test"); + expect(names).toContain("test2"); + }); + + it("should set a signed cookie and return it via returnCookies", async () => { + const secret = "test-secret"; + + const endpoint = createEndpoint("/", { method: "POST" }, async (c) => { + await c.setSignedCookie("session", "abc123", secret); + }); + + const response = await endpoint({ returnCookies: true }); + + expect(response.cookies).toHaveLength(1); + const cookie = response.cookies?.[0]; + expect(cookie?.name).toBe("session"); + expect(cookie?.value).toContain("abc123."); + + const signature = cookie?.value.split(".")[1]; + expect(signature?.length).toBeGreaterThan(10); + }); +}); diff --git a/src/cookies.ts b/src/cookies.ts index 3ecfa34..9c9c202 100644 --- a/src/cookies.ts +++ b/src/cookies.ts @@ -242,3 +242,57 @@ export const serializeSignedCookie = async ( value = await signCookieValue(value, secret); return _serialize(key, value, opt); }; + +export type SetCookie = { + name: string; + value: string; +} & CookieOptions; + +export function parseSetCookie(header: string): SetCookie { + const parts = header.split(";").map((p) => p.trim()); + const [nameValue, ...attributes] = parts; + const [name, rawValue] = nameValue.split("="); + const value = decodeURIComponent(rawValue); + + const cookie: SetCookie = { name, value }; + + for (const attr of attributes) { + if (attr.includes("=")) { + const [k, v] = attr.split("="); + const key = k.toLowerCase(); + switch (key) { + case "domain": + cookie.domain = v; + break; + case "path": + cookie.path = v; + break; + case "max-age": + cookie.maxAge = Number(v); + break; + case "expires": + cookie.expires = new Date(v); + break; + case "samesite": + cookie.sameSite = v as CookieOptions["sameSite"]; + break; + case "prefix": + cookie.prefix = v as CookiePrefixOptions; + break; + } + } else { + const flag = attr.toLowerCase(); + if (flag === "httponly") cookie.httpOnly = true; + else if (flag === "secure") cookie.secure = true; + else if (flag === "partitioned") cookie.partitioned = true; + } + } + + return cookie; +} + +export type SetCookies = SetCookie[]; + +export function extractSetCookes(headers: Headers): SetCookies { + return headers.getSetCookie().map((c) => parseSetCookie(c)); +} diff --git a/src/endpoint.test.ts b/src/endpoint.test.ts index 9ac40b0..207d454 100644 --- a/src/endpoint.test.ts +++ b/src/endpoint.test.ts @@ -499,6 +499,28 @@ describe("response", () => { }); }); + describe("set-cookies", () => { + it("should set cookies", async () => { + const endpoint = createEndpoint( + "/endpoint", + { + method: "POST", + }, + async (c) => { + c.setCookie("hello", "world"); + }, + ); + + const response = await endpoint({ + returnCookies:true + }); + + expect(response.cookies).toHaveLength(1); + expect(response.cookies?.at(0)?.name).toBe("hello"); + expect(response.cookies?.at(0)?.value).toBe("world"); + }); + }); + describe("API Error", () => { it("should throw API Error", async () => { const endpoint = createEndpoint( diff --git a/src/endpoint.ts b/src/endpoint.ts index bd5f2b7..e637783 100644 --- a/src/endpoint.ts +++ b/src/endpoint.ts @@ -13,7 +13,12 @@ import { type InputContext, type Method, } from "./context"; -import type { CookieOptions, CookiePrefixOptions } from "./cookies"; +import { + extractSetCookes, + type SetCookies, + type CookieOptions, + type CookiePrefixOptions, +} from "./cookies"; import { APIError, type _statusCode, type Status } from "./error"; import type { OpenAPIParameter, OpenAPISchemaType } from "./openapi"; import type { StandardSchemaV1 } from "./standard-schema"; @@ -320,13 +325,27 @@ export const createEndpoint = ) => Promise, ) => { type Context = InputContext; + const internalHandler = async < AsResponse extends boolean = false, ReturnHeaders extends boolean = false, + ReturnCookies extends boolean = false, >( ...inputCtx: HasRequiredKeys extends true - ? [Context & { asResponse?: AsResponse; returnHeaders?: ReturnHeaders }] - : [(Context & { asResponse?: AsResponse; returnHeaders?: ReturnHeaders })?] + ? [ + Context & { + asResponse?: AsResponse; + returnHeaders?: ReturnHeaders; + returnCookies?: ReturnCookies; + }, + ] + : [ + (Context & { + asResponse?: AsResponse; + returnHeaders?: ReturnHeaders; + returnCookies?: ReturnCookies; + })?, + ] ) => { const context = (inputCtx[0] || {}) as InputContext; const internalContext = await createInternalContext(context, { @@ -346,24 +365,41 @@ export const createEndpoint = {} @@ -151,12 +152,20 @@ export function createMiddleware(optionsOrHandler: any, handler?: any) { } const response = await _handler(internalContext as any); const headers = internalContext.responseHeaders; - return context.returnHeaders - ? { - headers, - response, - } - : response; + + if (context.returnHeaders && context.returnCookies) { + return { response, headers, cookies: extractSetCookes(headers) } + } + + if (context.returnHeaders) { + return { response, headers } + } + + if (context.returnCookies) { + return { response, cookies: extractSetCookes(headers) } + } + + return response; }; internalHandler.options = typeof optionsOrHandler === "function" ? {} : optionsOrHandler; return internalHandler; @@ -168,6 +177,7 @@ export type MiddlewareInputContext = InferBod InferHeadersInput & { asResponse?: boolean; returnHeaders?: boolean; + returnCookies?: boolean; use?: Middleware[]; };