diff --git a/.changeset/tiny-days-dance.md b/.changeset/tiny-days-dance.md new file mode 100644 index 000000000000..ae62b609f485 --- /dev/null +++ b/.changeset/tiny-days-dance.md @@ -0,0 +1,28 @@ +--- +'astro': minor +--- + +Cookie encoding / decoding can now be customized + +Adds new `encode` and `decode` functions to allow customizing how cookies are encoded and decoded. For example, you can bypass the default encoding via `encodeURIComponent` when adding a URL as part of a cookie: + +```astro +--- +import { encodeCookieValue } from "./cookies"; +Astro.cookies.set('url', Astro.url.toString(), { + // Override the default encoding so that URI components are not encoded + encode: value => encodeCookieValue(value) +}); +--- +``` + +Later, you can decode the URL in the same way: + +```astro +--- +import { decodeCookieValue } from "./cookies"; +const url = Astro.cookies.get('url', { + decode: value => decodeCookieValue(value) +}); +--- +``` diff --git a/packages/astro/src/@types/astro.ts b/packages/astro/src/@types/astro.ts index cadc704cd68e..e9c73fc90f3a 100644 --- a/packages/astro/src/@types/astro.ts +++ b/packages/astro/src/@types/astro.ts @@ -64,7 +64,7 @@ export type { } from '../assets/types.js'; export type { RemotePattern } from '../assets/utils/remotePattern.js'; export type { SSRManifest } from '../core/app/types.js'; -export type { AstroCookies } from '../core/cookies/index.js'; +export type { AstroCookies, AstroCookieSetOptions, AstroCookieGetOptions } from '../core/cookies/index.js'; export interface AstroBuiltinProps { 'client:load'?: boolean; diff --git a/packages/astro/src/core/cookies/cookies.ts b/packages/astro/src/core/cookies/cookies.ts index 604f30e63b92..7ed87b3ca91e 100644 --- a/packages/astro/src/core/cookies/cookies.ts +++ b/packages/astro/src/core/cookies/cookies.ts @@ -2,7 +2,7 @@ import type { CookieSerializeOptions } from 'cookie'; import { parse, serialize } from 'cookie'; import { AstroError, AstroErrorData } from '../errors/index.js'; -interface AstroCookieSetOptions { +export interface AstroCookieSetOptions { domain?: string; expires?: Date; httpOnly?: boolean; @@ -10,8 +10,13 @@ interface AstroCookieSetOptions { path?: string; sameSite?: boolean | 'lax' | 'none' | 'strict'; secure?: boolean; + encode?: (value: string) => string; } +export interface AstroCookieGetOptions { + decode?: (value: string) => string; +}; + type AstroCookieDeleteOptions = Pick; interface AstroCookieInterface { @@ -97,7 +102,7 @@ class AstroCookies implements AstroCookiesInterface { * @param key The cookie to get. * @returns An object containing the cookie value as well as convenience methods for converting its value. */ - get(key: string): AstroCookie | undefined { + get(key: string, options: AstroCookieGetOptions | undefined = undefined): AstroCookie | undefined { // Check for outgoing Set-Cookie values first if (this.#outgoing?.has(key)) { let [serializedValue, , isSetValue] = this.#outgoing.get(key)!; @@ -108,7 +113,7 @@ class AstroCookies implements AstroCookiesInterface { } } - const values = this.#ensureParsed(); + const values = this.#ensureParsed(options); if (key in values) { const value = values[key]; return new AstroCookie(value); @@ -121,12 +126,12 @@ class AstroCookies implements AstroCookiesInterface { * @param key The cookie to check for. * @returns */ - has(key: string): boolean { + has(key: string, options: AstroCookieGetOptions | undefined = undefined): boolean { if (this.#outgoing?.has(key)) { let [, , isSetValue] = this.#outgoing.get(key)!; return isSetValue; } - const values = this.#ensureParsed(); + const values = this.#ensureParsed(options); return !!values[key]; } @@ -185,9 +190,9 @@ class AstroCookies implements AstroCookiesInterface { } } - #ensureParsed(): Record { + #ensureParsed(options: AstroCookieGetOptions | undefined = undefined): Record { if (!this.#requestValues) { - this.#parse(); + this.#parse(options); } if (!this.#requestValues) { this.#requestValues = {}; @@ -202,13 +207,13 @@ class AstroCookies implements AstroCookiesInterface { return this.#outgoing; } - #parse() { + #parse(options: AstroCookieGetOptions | undefined = undefined) { const raw = this.#request.headers.get('cookie'); if (!raw) { return; } - this.#requestValues = parse(raw); + this.#requestValues = parse(raw, options); } } diff --git a/packages/astro/src/core/cookies/index.ts b/packages/astro/src/core/cookies/index.ts index c8869f9ae236..912fd2721e19 100644 --- a/packages/astro/src/core/cookies/index.ts +++ b/packages/astro/src/core/cookies/index.ts @@ -4,3 +4,4 @@ export { getSetCookiesFromResponse, responseHasCookies, } from './response.js'; +export type { AstroCookieSetOptions, AstroCookieGetOptions } from "./cookies.js"; \ No newline at end of file diff --git a/packages/astro/test/units/cookies/get.test.js b/packages/astro/test/units/cookies/get.test.js index 216128907543..104ff1f759bb 100644 --- a/packages/astro/test/units/cookies/get.test.js +++ b/packages/astro/test/units/cookies/get.test.js @@ -16,6 +16,30 @@ describe('astro/src/core/cookies', () => { expect(cookies.get('foo').value).to.equal('bar'); }); + it('gets the cookie value with default decode', () => { + const url = 'http://localhost'; + const req = new Request('http://example.com/', { + headers: { + cookie: `url=${encodeURIComponent(url)}`, + }, + }); + const cookies = new AstroCookies(req); + // by default decodeURIComponent is used on the value + expect(cookies.get('url').value).to.equal(url); + }); + + it('gets the cookie value with custom decode', () => { + const url = 'http://localhost'; + const req = new Request('http://example.com/', { + headers: { + cookie: `url=${encodeURIComponent(url)}`, + }, + }); + const cookies = new AstroCookies(req); + // set decode to the identity function to prevent decodeURIComponent on the value + expect(cookies.get('url', { decode: o => o }).value).to.equal(encodeURIComponent(url)); + }); + it("Returns undefined is the value doesn't exist", () => { const req = new Request('http://example.com/'); let cookies = new AstroCookies(req); diff --git a/packages/astro/test/units/cookies/set.test.js b/packages/astro/test/units/cookies/set.test.js index 0913bcc7da1c..16c0d2e874ac 100644 --- a/packages/astro/test/units/cookies/set.test.js +++ b/packages/astro/test/units/cookies/set.test.js @@ -15,6 +15,29 @@ describe('astro/src/core/cookies', () => { expect(headers[0]).to.equal('foo=bar'); }); + it('Sets a cookie value that can be serialized w/ defaultencodeURIComponent behavior', () => { + let req = new Request('http://example.com/'); + let cookies = new AstroCookies(req); + const url = 'http://localhost/path'; + cookies.set('url', url); + let headers = Array.from(cookies.headers()); + expect(headers).to.have.a.lengthOf(1); + // by default cookie value is URI encoded + expect(headers[0]).to.equal(`url=${encodeURIComponent(url)}`); + }); + + it('Sets a cookie value that can be serialized w/ custom encode behavior', () => { + let req = new Request('http://example.com/'); + let cookies = new AstroCookies(req); + const url = 'http://localhost/path'; + // set encode option to the identity function + cookies.set('url', url, { encode: o => o }); + let headers = Array.from(cookies.headers()); + expect(headers).to.have.a.lengthOf(1); + // no longer URI encoded + expect(headers[0]).to.equal(`url=${url}`); + }); + it('Can set cookie options', () => { let req = new Request('http://example.com/'); let cookies = new AstroCookies(req);