From e1ba1c045a612fb86ea1bda6c5a430aebfd3ce5e Mon Sep 17 00:00:00 2001 From: frectonz Date: Thu, 14 Aug 2025 19:20:56 +0300 Subject: [PATCH 1/7] feat: add `Set-Cookie` parser --- src/cookies.ts | 48 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/src/cookies.ts b/src/cookies.ts index 3ecfa34..ce4d0d1 100644 --- a/src/cookies.ts +++ b/src/cookies.ts @@ -242,3 +242,51 @@ export const serializeSignedCookie = async ( value = await signCookieValue(value, secret); return _serialize(key, value, opt); }; + +export type ParsedSetCookie = { + name: string; + value: string; +} & CookieOptions; + +export function parseSetCookie(header: string): ParsedSetCookie { + const parts = header.split(";").map((p) => p.trim()); + const [nameValue, ...attributes] = parts; + const [name, rawValue] = nameValue.split("="); + const value = decodeURIComponent(rawValue); + + const cookie: ParsedSetCookie = { 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; +} From 9159fd0750d4668adc515c341ea4236c9593dd1e Mon Sep 17 00:00:00 2001 From: frectonz Date: Thu, 14 Aug 2025 19:31:55 +0300 Subject: [PATCH 2/7] feat: add support for returning parsed cookies --- src/endpoint.ts | 41 ++++++++++++++++++++++++++++------------- 1 file changed, 28 insertions(+), 13 deletions(-) diff --git a/src/endpoint.ts b/src/endpoint.ts index bd5f2b7..49c527a 100644 --- a/src/endpoint.ts +++ b/src/endpoint.ts @@ -13,7 +13,7 @@ import { type InputContext, type Method, } from "./context"; -import type { CookieOptions, CookiePrefixOptions } from "./cookies"; +import { parseCookies, 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"; @@ -352,18 +352,33 @@ export const createEndpoint = Date: Thu, 14 Aug 2025 19:49:32 +0300 Subject: [PATCH 3/7] feat: support for returning cookies set by and endpoint --- src/context.ts | 1 + src/cookies.ts | 12 ++++++-- src/endpoint.ts | 77 +++++++++++++++++++++++++++++++------------------ 3 files changed, 59 insertions(+), 31 deletions(-) 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.ts b/src/cookies.ts index ce4d0d1..9c9c202 100644 --- a/src/cookies.ts +++ b/src/cookies.ts @@ -243,18 +243,18 @@ export const serializeSignedCookie = async ( return _serialize(key, value, opt); }; -export type ParsedSetCookie = { +export type SetCookie = { name: string; value: string; } & CookieOptions; -export function parseSetCookie(header: string): ParsedSetCookie { +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: ParsedSetCookie = { name, value }; + const cookie: SetCookie = { name, value }; for (const attr of attributes) { if (attr.includes("=")) { @@ -290,3 +290,9 @@ export function parseSetCookie(header: string): ParsedSetCookie { return cookie; } + +export type SetCookies = SetCookie[]; + +export function extractSetCookes(headers: Headers): SetCookies { + return headers.getSetCookie().map((c) => parseSetCookie(c)); +} diff --git a/src/endpoint.ts b/src/endpoint.ts index 49c527a..e637783 100644 --- a/src/endpoint.ts +++ b/src/endpoint.ts @@ -13,7 +13,12 @@ import { type InputContext, type Method, } from "./context"; -import { parseCookies, type CookieOptions, type 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,39 +365,41 @@ export const createEndpoint = Date: Thu, 14 Aug 2025 20:01:59 +0300 Subject: [PATCH 4/7] test: add set cookie parser tests --- src/cookies.test.ts | 54 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 53 insertions(+), 1 deletion(-) diff --git a/src/cookies.test.ts b/src/cookies.test.ts index aec12fc..cf51ec9 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( From 35b44b1a672d28f980c60085aa48101473fe1f9d Mon Sep 17 00:00:00 2001 From: frectonz Date: Thu, 14 Aug 2025 20:12:30 +0300 Subject: [PATCH 5/7] test: add tests for `returnCookies` --- src/cookies.test.ts | 78 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) diff --git a/src/cookies.test.ts b/src/cookies.test.ts index cf51ec9..9db9689 100644 --- a/src/cookies.test.ts +++ b/src/cookies.test.ts @@ -309,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); + }); +}); From 9587f59001f0440baff7011169707de16751160b Mon Sep 17 00:00:00 2001 From: frectonz Date: Thu, 14 Aug 2025 20:27:06 +0300 Subject: [PATCH 6/7] test: add tests for `returnCookies` in endpoint.test --- src/endpoint.test.ts | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) 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( From eb522e775addb88163901ad27333fd5ccebab7fa Mon Sep 17 00:00:00 2001 From: frectonz Date: Thu, 14 Aug 2025 20:32:22 +0300 Subject: [PATCH 7/7] feat: support `returnCookies` in middleware --- src/middleware.ts | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/src/middleware.ts b/src/middleware.ts index 5be6607..70394fc 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -20,6 +20,7 @@ import { type InputContext, } from "./context"; import type { Prettify } from "./helper"; +import { extractSetCookes } from "./cookies"; export interface MiddlewareOptions extends Omit {} @@ -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[]; };