Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,7 @@ export type InputContext<
InferHeadersInput<Options> & {
asResponse?: boolean;
returnHeaders?: boolean;
returnCookies?: boolean;
use?: Middleware[];
path?: string;
};
Expand Down
132 changes: 131 additions & 1 deletion src/cookies.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand All @@ -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(
Expand Down Expand Up @@ -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);
});
});
54 changes: 54 additions & 0 deletions src/cookies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
22 changes: 22 additions & 0 deletions src/endpoint.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
72 changes: 54 additions & 18 deletions src/endpoint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -320,13 +325,27 @@ export const createEndpoint = <Path extends string, Options extends EndpointOpti
handler: (context: EndpointContext<Path, Options>) => Promise<R>,
) => {
type Context = InputContext<Path, Options>;

const internalHandler = async <
AsResponse extends boolean = false,
ReturnHeaders extends boolean = false,
ReturnCookies extends boolean = false,
>(
...inputCtx: HasRequiredKeys<Context> 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<any, any>;
const internalContext = await createInternalContext(context, {
Expand All @@ -346,24 +365,41 @@ export const createEndpoint = <Path extends string, Options extends EndpointOpti
throw e;
});
const headers = internalContext.responseHeaders;

type ResultType = [AsResponse] extends [true]
? Response
: [ReturnHeaders] extends [true]
? { headers: Headers; response: R }
: R;
: [ReturnHeaders, ReturnCookies] extends [true, true]
? { response: R; headers: Headers; cookies: SetCookies | null }
: [ReturnHeaders, ReturnCookies] extends [true, false]
? { response: R; headers: Headers }
: [ReturnHeaders, ReturnCookies] extends [false, true]
? { response: R; cookies: SetCookies | null }
: R;

if (context.asResponse) {
return toResponse(response, { headers }) as ResultType;
}

if (context.returnHeaders && context.returnCookies) {
return {
response,
headers,
cookies: extractSetCookes(headers),
} as ResultType;
}

if (context.returnHeaders) {
return { response, headers } as ResultType;
}

if (context.returnCookies) {
return {
response,
cookies: extractSetCookes(headers),
} as ResultType;
}

return (
context.asResponse
? toResponse(response, {
headers,
})
: context.returnHeaders
? {
headers,
response,
}
: response
) as ResultType;
return response as ResultType;
};
internalHandler.options = options;
internalHandler.path = path;
Expand Down
22 changes: 16 additions & 6 deletions src/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
type InputContext,
} from "./context";
import type { Prettify } from "./helper";
import { extractSetCookes } from "./cookies";

export interface MiddlewareOptions extends Omit<EndpointOptions, "method"> {}

Expand Down Expand Up @@ -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;
Expand All @@ -168,6 +177,7 @@ export type MiddlewareInputContext<Options extends MiddlewareOptions> = InferBod
InferHeadersInput<Options> & {
asResponse?: boolean;
returnHeaders?: boolean;
returnCookies?: boolean;
use?: Middleware[];
};

Expand Down