Skip to content
Merged
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
6 changes: 6 additions & 0 deletions .changeset/cookie-intent-semantics.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@danceroutine/tango-core": patch
"@danceroutine/tango-adapters-express": patch
---

Fix response cookie intent handling so repeated `setCookie()` calls replace the prior cookie for the same name, domain, and path while `appendCookie()` continues to emit additional `Set-Cookie` lines. Express responses now forward multiple `Set-Cookie` lines without collapsing them into a single header value.
6 changes: 6 additions & 0 deletions packages/adapters/express/src/adapter/ExpressAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -256,9 +256,15 @@ export class ExpressAdapter implements FrameworkAdapter<Response, RequestHandler
res.status(response.status);

response.headers.forEach((value, key) => {
if (key.toLowerCase() === 'set-cookie') return;
res.setHeader(key, value);
});

const setCookie = tangoResponse.headers.getSetCookie();
if (setCookie.length > 0) {
res.setHeader('set-cookie', setCookie);
}

if (response.body === null) {
res.end();
return;
Expand Down
27 changes: 27 additions & 0 deletions packages/adapters/express/src/adapter/tests/ExpressAdapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,33 @@ describe(ExpressAdapter, () => {
expect(next).not.toHaveBeenCalled();
});

it('forwards set-cookie header lines as one Express header array', async () => {
const adapter = new ExpressAdapter();
const routeHandler = adapter.adapt(async () => {
const response = textResponse('ok');
response.setCookie('session', 'old');
response.setCookie('session', 'new');
response.appendCookie('theme', 'dark');
return response;
});

const req = anExpressRequest({
originalUrl: '/users',
url: '/users',
});
const res = anExpressResponse();
const next = vi.fn() as unknown as NextFunction;

await routeHandler(req, res, next);

const setHeader = vi.mocked(res.setHeader);
const setCookieCalls = setHeader.mock.calls.filter(([name]) => String(name).toLowerCase() === 'set-cookie');

expect(setCookieCalls).toEqual([['set-cookie', ['session=new; Path=/', 'theme=dark; Path=/']]]);
expect(res.send).toHaveBeenCalledWith('ok');
expect(next).not.toHaveBeenCalled();
});

it('normalizes Express requests into Tango query params', () => {
const adapter = new ExpressAdapter();
const req = anExpressRequest({
Expand Down
232 changes: 167 additions & 65 deletions packages/core/src/http/TangoHeaders.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,30 @@
import { isArrayBuffer, isBlob, isUint8Array } from '../runtime/index';

type CookieOptions = {
domain?: string;
expires?: Date;
httpOnly?: boolean;
maxAge?: number;
path?: string;
sameSite?: 'Strict' | 'Lax' | 'None';
secure?: boolean;
priority?: 'Low' | 'Medium' | 'High';
partitioned?: boolean;
};

type DeleteCookieOptions = Omit<CookieOptions, 'expires' | 'maxAge' | 'httpOnly'>;

type CookieIdentity = {
name: string;
domain: string;
path: string;
};

type CookieEntry = {
line: string;
identity: CookieIdentity | null;
};

/**
* TangoHeaders extends the Web Headers class, adding ergonomic helpers
* for common HTTP header patterns, convenience features, and a consistent API for
Expand All @@ -17,6 +42,12 @@ import { isArrayBuffer, isBlob, isUint8Array } from '../runtime/index';
export class TangoHeaders extends Headers {
static readonly BRAND = 'tango.http.headers' as const;
readonly __tangoBrand: typeof TangoHeaders.BRAND = TangoHeaders.BRAND;
private cookieEntries: CookieEntry[] = [];

constructor(init?: HeadersInit) {
super();
this.appendHeadersInit(init);
}

/**
* Narrow an unknown value to `TangoHeaders`.
Expand All @@ -32,21 +63,7 @@ export class TangoHeaders extends Headers {
/**
* Serialize a cookie for the Set-Cookie header line.
*/
private static serializeCookie(
name: string,
value: string,
options: {
domain?: string;
expires?: Date;
httpOnly?: boolean;
maxAge?: number;
path?: string;
sameSite?: 'Strict' | 'Lax' | 'None';
secure?: boolean;
priority?: 'Low' | 'Medium' | 'High';
partitioned?: boolean;
} = {}
): string {
private static serializeCookie(name: string, value: string, options: CookieOptions = {}): string {
let cookie = encodeURIComponent(name) + '=' + encodeURIComponent(value ?? '');
if (options.domain) cookie += `; Domain=${options.domain}`;
if (options.path) cookie += `; Path=${options.path}`;
Expand All @@ -61,6 +78,34 @@ export class TangoHeaders extends Headers {
return cookie;
}

private static getCookieIdentity(
name: string,
options: Pick<CookieOptions, 'domain' | 'path'> = {}
): CookieIdentity {
return {
name,
domain: options.domain?.toLowerCase() ?? '',
path: options.path ?? '/',
};
}

private static hasCookieIdentity(entry: CookieEntry, identity: CookieIdentity): boolean {
return (
entry.identity !== null &&
entry.identity.name === identity.name &&
entry.identity.domain === identity.domain &&
entry.identity.path === identity.path
);
}

private static isSetCookieName(name: string): boolean {
return name.toLowerCase() === 'set-cookie';
}

private static hasHeaderEntries(value: HeadersInit): value is Headers {
return typeof (value as { entries?: unknown }).entries === 'function';
}

private static hasNumberSize(value: unknown): value is { size: number } {
return typeof value === 'object' && value !== null && typeof (value as { size?: unknown }).size === 'number';
}
Expand All @@ -78,6 +123,36 @@ export class TangoHeaders extends Headers {
);
}

override append(name: string, value: string): void {
if (!TangoHeaders.isSetCookieName(name)) {
super.append(name, value);
return;
}

this.cookieEntries.push({ line: value, identity: null });
this.syncSetCookieHeader();
}

override set(name: string, value: string): void {
if (!TangoHeaders.isSetCookieName(name)) {
super.set(name, value);
return;
}

this.cookieEntries = [{ line: value, identity: null }];
this.syncSetCookieHeader();
}

override delete(name: string): void {
if (!TangoHeaders.isSetCookieName(name)) {
super.delete(name);
return;
}

this.cookieEntries = [];
super.delete('Set-Cookie');
}

/**
* Sets the Content-Disposition header with type "inline" and the specified filename.
* This is useful to indicate that the content should be displayed inline in the browser,
Expand Down Expand Up @@ -107,8 +182,15 @@ export class TangoHeaders extends Headers {
clone(): TangoHeaders {
const copy = new TangoHeaders();
for (const [name, value] of this.entries()) {
copy.append(name, value);
if (!TangoHeaders.isSetCookieName(name)) {
copy.append(name, value);
}
}
copy.cookieEntries = this.cookieEntries.map((entry) => ({
line: entry.line,
identity: entry.identity === null ? null : { ...entry.identity },
}));
copy.syncSetCookieHeader();
return copy;
}

Expand Down Expand Up @@ -175,64 +257,35 @@ export class TangoHeaders extends Headers {
}

/**
* Set a cookie header (for Set-Cookie).
* @param name
* @param value
* @param options
* Set a `Set-Cookie` line, replacing prior helper-managed intent for the same name, domain, and path.
*/
setCookie(
name: string,
value: string,
options?: {
domain?: string;
expires?: Date;
httpOnly?: boolean;
maxAge?: number;
path?: string;
sameSite?: 'Strict' | 'Lax' | 'None';
secure?: boolean;
priority?: 'Low' | 'Medium' | 'High';
partitioned?: boolean;
}
): void {
this.append('Set-Cookie', TangoHeaders.serializeCookie(name, value, options));
setCookie(name: string, value: string, options?: CookieOptions): void {
const identity = TangoHeaders.getCookieIdentity(name, options);
this.replaceCookieIdentity(identity, TangoHeaders.serializeCookie(name, value, options));
}

/**
* Append (additionally) a new cookie.
* Append another `Set-Cookie` line without replacing earlier cookie intent.
*/
appendCookie(
name: string,
value: string,
options?: {
domain?: string;
expires?: Date;
httpOnly?: boolean;
maxAge?: number;
path?: string;
sameSite?: 'Strict' | 'Lax' | 'None';
secure?: boolean;
priority?: 'Low' | 'Medium' | 'High';
partitioned?: boolean;
}
): void {
this.append('Set-Cookie', TangoHeaders.serializeCookie(name, value, options));
appendCookie(name: string, value: string, options?: CookieOptions): void {
this.cookieEntries.push({
line: TangoHeaders.serializeCookie(name, value, options),
identity: TangoHeaders.getCookieIdentity(name, options),
});
this.syncSetCookieHeader();
}

/**
* Delete a cookie ("unset" it via expired date).
* Return each `Set-Cookie` header line separately.
*/
deleteCookie(
name: string,
options?: {
domain?: string;
path?: string;
sameSite?: 'Strict' | 'Lax' | 'None';
secure?: boolean;
priority?: 'Low' | 'Medium' | 'High';
partitioned?: boolean;
}
): void {
override getSetCookie(): string[] {
return this.cookieEntries.map((entry) => entry.line);
}

/**
* Expire a cookie, replacing prior helper-managed intent for the same name, domain, and path.
*/
deleteCookie(name: string, options?: DeleteCookieOptions): void {
this.setCookie(name, '', {
...options,
expires: new Date(0),
Expand Down Expand Up @@ -474,4 +527,53 @@ export class TangoHeaders extends Headers {
getResponseTime(): string | null {
return this.get('X-Response-Time');
}

private appendHeadersInit(init: HeadersInit | undefined): void {
if (!init) return;

if (TangoHeaders.isTangoHeaders(init)) {
for (const [name, value] of init.entries()) {
if (!TangoHeaders.isSetCookieName(name)) {
super.append(name, value);
}
}
this.cookieEntries = init.cookieEntries.map((entry) => ({
line: entry.line,
identity: entry.identity === null ? null : { ...entry.identity },
}));
this.syncSetCookieHeader();
return;
}

if (Array.isArray(init)) {
for (const [name, value] of init) {
this.append(name, value);
}
return;
}

if (TangoHeaders.hasHeaderEntries(init)) {
for (const [name, value] of init.entries()) {
this.append(name, value);
}
return;
}

for (const [name, value] of Object.entries(init)) {
this.append(name, value);
}
}

private replaceCookieIdentity(identity: CookieIdentity, line: string): void {
this.cookieEntries = this.cookieEntries.filter((entry) => !TangoHeaders.hasCookieIdentity(entry, identity));
this.cookieEntries.push({ line, identity });
this.syncSetCookieHeader();
}

private syncSetCookieHeader(): void {
super.delete('Set-Cookie');
for (const entry of this.cookieEntries) {
super.append('Set-Cookie', entry.line);
}
}
}
19 changes: 15 additions & 4 deletions packages/core/src/http/TangoResponse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -739,19 +739,19 @@ export class TangoResponse implements Response {
}

/**
* Add a `Set-Cookie` header that replaces prior application intent.
* Add a `Set-Cookie` header that replaces prior helper-managed intent for the same name, domain, and path.
*/
setCookie(name: string, value: string, options?: Parameters<TangoHeaders['setCookie']>[2]): void {
this.headers.setCookie(name, value, options);
}
/**
* Append another `Set-Cookie` header.
* Append another `Set-Cookie` header without replacing earlier cookie intent.
*/
appendCookie(name: string, value: string, options?: Parameters<TangoHeaders['appendCookie']>[2]): void {
this.headers.appendCookie(name, value, options);
}
/**
* Expire a cookie by issuing a matching deletion cookie header.
* Expire a cookie by replacing prior helper-managed intent for the same name, domain, and path.
*/
deleteCookie(name: string, options?: Parameters<TangoHeaders['deleteCookie']>[1]): void {
this.headers.deleteCookie(name, options);
Expand Down Expand Up @@ -880,9 +880,20 @@ export class TangoResponse implements Response {
toWebResponse(): Response {
const responseForTransfer = !this.bodyUsed && isReadableStream(this.bodySource) ? this.clone() : this;
const body = TangoResponse.normalizeWebBody(responseForTransfer.bodySource);
const headers = new Headers();

responseForTransfer.headers.forEach((value, key) => {
if (key.toLowerCase() !== 'set-cookie') {
headers.append(key, value);
}
});

for (const cookie of responseForTransfer.headers.getSetCookie()) {
headers.append('Set-Cookie', cookie);
}

return new Response(body, {
headers: new Headers(responseForTransfer.headers),
headers,
status: responseForTransfer.status,
statusText: responseForTransfer.statusText,
});
Expand Down
Loading