diff --git a/.changeset/thin-news-collect.md b/.changeset/thin-news-collect.md new file mode 100644 index 000000000..7779013b8 --- /dev/null +++ b/.changeset/thin-news-collect.md @@ -0,0 +1,48 @@ +--- +'astro': minor +'@astrojs/cloudflare': minor +'@astrojs/deno': minor +'@astrojs/netlify': minor +'@astrojs/node': minor +'@astrojs/vercel': minor +--- + +Adds the Astro.cookies API + +`Astro.cookies` is a new API for manipulating cookies in Astro components and API routes. + +In Astro components, the new `Astro.cookies` object is a map-like object that allows you to get, set, delete, and check for a cookie's existence (`has`): + +```astro +--- +type Prefs = { + darkMode: boolean; +} + +Astro.cookies.set('prefs', { darkMode: true }, { + expires: '1 month' +}); + +const prefs = Astro.cookies.get('prefs').json(); +--- + +``` + +Once you've set a cookie with Astro.cookies it will automatically be included in the outgoing response. + +This API is also available with the same functionality in API routes: + +```js +export function post({ cookies }) { + cookies.set('loggedIn', false); + + return new Response(null, { + status: 302, + headers: { + Location: '/login' + } + }); +} +``` + +See [the RFC](https://github.com/withastro/rfcs/blob/main/proposals/0025-cookie-management.md) to learn more. diff --git a/packages/astro/package.json b/packages/astro/package.json index 0792f118a..0a39c75e1 100644 --- a/packages/astro/package.json +++ b/packages/astro/package.json @@ -114,6 +114,7 @@ "boxen": "^6.2.1", "ci-info": "^3.3.1", "common-ancestor-path": "^1.0.1", + "cookie": "^0.5.0", "debug": "^4.3.4", "diff": "^5.1.0", "eol": "^0.9.1", @@ -161,6 +162,7 @@ "@types/chai": "^4.3.1", "@types/common-ancestor-path": "^1.0.0", "@types/connect": "^3.4.35", + "@types/cookie": "^0.5.1", "@types/debug": "^4.1.7", "@types/diff": "^5.0.2", "@types/estree": "^0.0.51", diff --git a/packages/astro/src/@types/astro.ts b/packages/astro/src/@types/astro.ts index c423a1abf..7c19dcca6 100644 --- a/packages/astro/src/@types/astro.ts +++ b/packages/astro/src/@types/astro.ts @@ -14,6 +14,7 @@ import type * as vite from 'vite'; import type { z } from 'zod'; import type { SerializedSSRManifest } from '../core/app/types'; import type { PageBuildData } from '../core/build/types'; +import type { AstroCookies } from '../core/cookies'; import type { AstroConfigSchema } from '../core/config'; import type { ViteConfigWithSSR } from '../core/create-vite'; import type { AstroComponentFactory, Metadata } from '../runtime/server'; @@ -116,6 +117,10 @@ export interface AstroGlobal extends AstroGlobalPartial { * * [Astro reference](https://docs.astro.build/en/reference/api-reference/#url) */ + /** + * Utility for getting and setting cookies values. + */ + cookies: AstroCookies, url: URL; /** Parameters passed to a dynamic page generated using [getStaticPaths](https://docs.astro.build/en/reference/api-reference/#getstaticpaths) * @@ -1083,6 +1088,7 @@ export interface AstroAdapter { type Body = string; export interface APIContext { + cookies: AstroCookies; params: Params; request: Request; } @@ -1219,6 +1225,7 @@ export interface SSRResult { styles: Set; scripts: Set; links: Set; + cookies: AstroCookies | undefined; createAstro( Astro: AstroGlobalPartial, props: Record, diff --git a/packages/astro/src/core/app/index.ts b/packages/astro/src/core/app/index.ts index 413dba20c..ce3422738 100644 --- a/packages/astro/src/core/app/index.ts +++ b/packages/astro/src/core/app/index.ts @@ -21,6 +21,7 @@ import { } from '../render/ssr-element.js'; import { matchRoute } from '../routing/match.js'; export { deserializeManifest } from './common.js'; +import { getSetCookiesFromResponse } from '../cookies/index.js'; export const pagesVirtualModuleId = '@astrojs-pages-virtual-entry'; export const resolvedPagesVirtualModuleId = '\0' + pagesVirtualModuleId; @@ -116,6 +117,10 @@ export class App { } } + setCookieHeaders(response: Response) { + return getSetCookiesFromResponse(response); + } + async #renderPage( request: Request, routeData: RouteData, diff --git a/packages/astro/src/core/cookies/cookies.ts b/packages/astro/src/core/cookies/cookies.ts new file mode 100644 index 000000000..7f530ce85 --- /dev/null +++ b/packages/astro/src/core/cookies/cookies.ts @@ -0,0 +1,202 @@ +import type { CookieSerializeOptions } from 'cookie'; +import { parse, serialize } from 'cookie'; + +interface AstroCookieSetOptions { + domain?: string; + expires?: Date; + httpOnly?: boolean; + maxAge?: number; + path?: string; + sameSite?: boolean | 'lax' | 'none' | 'strict'; + secure?: boolean; +} + +interface AstroCookieDeleteOptions { + path?: string; +} + +interface AstroCookieInterface { + value: string | undefined; + json(): Record; + number(): number; + boolean(): boolean; +} + +interface AstroCookiesInterface { + get(key: string): AstroCookieInterface; + has(key: string): boolean; + set(key: string, value: string | Record, options?: AstroCookieSetOptions): void; + delete(key: string, options?: AstroCookieDeleteOptions): void; +} + +const DELETED_EXPIRATION = new Date(0); +const DELETED_VALUE = 'deleted'; + +class AstroCookie implements AstroCookieInterface { + constructor(public value: string | undefined) {} + json() { + if(this.value === undefined) { + throw new Error(`Cannot convert undefined to an object.`); + } + return JSON.parse(this.value); + } + number() { + return Number(this.value); + } + boolean() { + if(this.value === 'false') return false; + if(this.value === '0') return false; + return Boolean(this.value); + } +} + +class AstroCookies implements AstroCookiesInterface { + #request: Request; + #requestValues: Record | null; + #outgoing: Map | null; + constructor(request: Request) { + this.#request = request; + this.#requestValues = null; + this.#outgoing = null; + } + + /** + * Astro.cookies.delete(key) is used to delete a cookie. Using this method will result + * in a Set-Cookie header added to the response. + * @param key The cookie to delete + * @param options Options related to this deletion, such as the path of the cookie. + */ + delete(key: string, options?: AstroCookieDeleteOptions): void { + const serializeOptions: CookieSerializeOptions = { + expires: DELETED_EXPIRATION + }; + + if(options?.path) { + serializeOptions.path = options.path; + } + + // Set-Cookie: token=deleted; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT + this.#ensureOutgoingMap().set(key, [ + DELETED_VALUE, + serialize(key, DELETED_VALUE, serializeOptions), + false + ]); + } + + /** + * Astro.cookies.get(key) is used to get a cookie value. The cookie value is read from the + * request. If you have set a cookie via Astro.cookies.set(key, value), the value will be taken + * from that set call, overriding any values already part of the request. + * @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 { + // Check for outgoing Set-Cookie values first + if(this.#outgoing !== null && this.#outgoing.has(key)) { + let [serializedValue,, isSetValue] = this.#outgoing.get(key)!; + if(isSetValue) { + return new AstroCookie(serializedValue); + } else { + return new AstroCookie(undefined); + } + } + + const values = this.#ensureParsed(); + const value = values[key]; + return new AstroCookie(value); + } + + /** + * Astro.cookies.has(key) returns a boolean indicating whether this cookie is either + * part of the initial request or set via Astro.cookies.set(key) + * @param key The cookie to check for. + * @returns + */ + has(key: string): boolean { + if(this.#outgoing !== null && this.#outgoing.has(key)) { + let [,,isSetValue] = this.#outgoing.get(key)!; + return isSetValue; + } + const values = this.#ensureParsed(); + return !!values[key]; + } + + /** + * Astro.cookies.set(key, value) is used to set a cookie's value. If provided + * an object it will be stringified via JSON.stringify(value). Additionally you + * can provide options customizing how this cookie will be set, such as setting httpOnly + * in order to prevent the cookie from being read in client-side JavaScript. + * @param key The name of the cookie to set. + * @param value A value, either a string or other primitive or an object. + * @param options Options for the cookie, such as the path and security settings. + */ + set(key: string, value: string | Record, options?: AstroCookieSetOptions): void { + let serializedValue: string; + if(typeof value === 'string') { + serializedValue = value; + } else { + // Support stringifying JSON objects for convenience. First check that this is + // a plain object and if it is, stringify. If not, allow support for toString() overrides. + let toStringValue = value.toString(); + if(toStringValue === Object.prototype.toString.call(value)) { + serializedValue = JSON.stringify(value); + } else { + serializedValue = toStringValue; + } + } + + const serializeOptions: CookieSerializeOptions = {}; + if(options) { + Object.assign(serializeOptions, options); + } + + this.#ensureOutgoingMap().set(key, [ + serializedValue, + serialize(key, serializedValue, serializeOptions), + true + ]); + } + + /** + * Astro.cookies.header() returns an iterator for the cookies that have previously + * been set by either Astro.cookies.set() or Astro.cookies.delete(). + * This method is primarily used by adapters to set the header on outgoing responses. + * @returns + */ + *headers(): Generator { + if(this.#outgoing == null) return; + for(const [,value] of this.#outgoing) { + yield value[1]; + } + } + + #ensureParsed(): Record { + if(!this.#requestValues) { + this.#parse(); + } + if(!this.#requestValues) { + this.#requestValues = {}; + } + return this.#requestValues; + } + + #ensureOutgoingMap(): Map { + if(!this.#outgoing) { + this.#outgoing = new Map(); + } + return this.#outgoing; + } + + #parse() { + const raw = this.#request.headers.get('cookie'); + if(!raw) { + return; + } + + this.#requestValues = parse(raw); + } +} + +export { + AstroCookies +}; diff --git a/packages/astro/src/core/cookies/index.ts b/packages/astro/src/core/cookies/index.ts new file mode 100644 index 000000000..18dc3ebca --- /dev/null +++ b/packages/astro/src/core/cookies/index.ts @@ -0,0 +1,9 @@ + +export { + AstroCookies +} from './cookies.js'; + +export { + attachToResponse, + getSetCookiesFromResponse +} from './response.js'; diff --git a/packages/astro/src/core/cookies/response.ts b/packages/astro/src/core/cookies/response.ts new file mode 100644 index 000000000..0e52ac8cb --- /dev/null +++ b/packages/astro/src/core/cookies/response.ts @@ -0,0 +1,26 @@ +import type { AstroCookies } from './cookies'; + +const astroCookiesSymbol = Symbol.for('astro.cookies'); + +export function attachToResponse(response: Response, cookies: AstroCookies) { + Reflect.set(response, astroCookiesSymbol, cookies); +} + +function getFromResponse(response: Response): AstroCookies | undefined { + let cookies = Reflect.get(response, astroCookiesSymbol); + if(cookies != null) { + return cookies as AstroCookies; + } else { + return undefined; + } +} + +export function * getSetCookiesFromResponse(response: Response): Generator { + const cookies = getFromResponse(response); + if(!cookies) { + return; + } + for(const headerValue of cookies.headers()) { + yield headerValue; + } +} diff --git a/packages/astro/src/core/endpoint/index.ts b/packages/astro/src/core/endpoint/index.ts index 7fee0c428..75e451e6f 100644 --- a/packages/astro/src/core/endpoint/index.ts +++ b/packages/astro/src/core/endpoint/index.ts @@ -1,6 +1,8 @@ -import type { EndpointHandler } from '../../@types/astro'; -import { renderEndpoint } from '../../runtime/server/index.js'; +import type { APIContext, EndpointHandler, Params } from '../../@types/astro'; import type { RenderOptions } from '../render/core'; + +import { AstroCookies, attachToResponse } from '../cookies/index.js'; +import { renderEndpoint } from '../../runtime/server/index.js'; import { getParamsAndProps, GetParamsAndPropsError } from '../render/core.js'; export type EndpointOptions = Pick< @@ -28,6 +30,14 @@ type EndpointCallResult = response: Response; }; +function createAPIContext(request: Request, params: Params): APIContext { + return { + cookies: new AstroCookies(request), + request, + params + }; +} + export async function call( mod: EndpointHandler, opts: EndpointOptions @@ -41,9 +51,11 @@ export async function call( } const [params] = paramsAndPropsResp; - const response = await renderEndpoint(mod, opts.request, params, opts.ssr); + const context = createAPIContext(opts.request, params); + const response = await renderEndpoint(mod, context, opts.ssr); if (response instanceof Response) { + attachToResponse(response, context.cookies); return { type: 'response', response, diff --git a/packages/astro/src/core/render/core.ts b/packages/astro/src/core/render/core.ts index c9efe02de..7e5fe1f96 100644 --- a/packages/astro/src/core/render/core.ts +++ b/packages/astro/src/core/render/core.ts @@ -10,6 +10,7 @@ import type { } from '../../@types/astro'; import type { LogOptions } from '../logger/core.js'; +import { attachToResponse } from '../cookies/index.js'; import { Fragment, renderPage } from '../../runtime/server/index.js'; import { getParams } from '../routing/params.js'; import { createResult } from './result.js'; @@ -164,5 +165,13 @@ export async function render(opts: RenderOptions): Promise { }); } - return await renderPage(result, Component, pageProps, null, streaming); + const response = await renderPage(result, Component, pageProps, null, streaming); + + // If there is an Astro.cookies instance, attach it to the response so that + // adapters can grab the Set-Cookie headers. + if(result.cookies) { + attachToResponse(response, result.cookies); + } + + return response; } diff --git a/packages/astro/src/core/render/result.ts b/packages/astro/src/core/render/result.ts index d4704ca1f..c0e650a8f 100644 --- a/packages/astro/src/core/render/result.ts +++ b/packages/astro/src/core/render/result.ts @@ -11,6 +11,7 @@ import type { SSRResult, } from '../../@types/astro'; import { renderSlot } from '../../runtime/server/index.js'; +import { AstroCookies } from '../cookies/index.js'; import { LogOptions, warn } from '../logger/core.js'; import { isScriptRequest } from './script.js'; import { isCSSRequest } from './util.js'; @@ -139,6 +140,9 @@ export function createResult(args: CreateResultArgs): SSRResult { writable: false, }); + // Astro.cookies is defined lazily to avoid the cost on pages that do not use it. + let cookies: AstroCookies | undefined = undefined; + // Create the result object that will be passed into the render function. // This object starts here as an empty shell (not yet the result) but then // calling the render() function will populate the object with scripts, styles, etc. @@ -146,6 +150,7 @@ export function createResult(args: CreateResultArgs): SSRResult { styles: args.styles ?? new Set(), scripts: args.scripts ?? new Set(), links: args.links ?? new Set(), + cookies, /** This function returns the `Astro` faux-global */ createAstro( astroGlobal: AstroGlobalPartial, @@ -171,6 +176,14 @@ export function createResult(args: CreateResultArgs): SSRResult { return Reflect.get(request, clientAddressSymbol); }, + get cookies() { + if(cookies) { + return cookies; + } + cookies = new AstroCookies(request); + result.cookies = cookies; + return cookies; + }, params, props, request, diff --git a/packages/astro/src/runtime/server/endpoint.ts b/packages/astro/src/runtime/server/endpoint.ts index 31a0069db..a93d02adb 100644 --- a/packages/astro/src/runtime/server/endpoint.ts +++ b/packages/astro/src/runtime/server/endpoint.ts @@ -18,12 +18,8 @@ function getHandlerFromModule(mod: EndpointHandler, method: string) { } /** Renders an endpoint request to completion, returning the body. */ -export async function renderEndpoint( - mod: EndpointHandler, - request: Request, - params: Params, - ssr?: boolean -) { +export async function renderEndpoint(mod: EndpointHandler, context: APIContext, ssr: boolean) { + const { request, params } = context; const chosenMethod = request.method?.toLowerCase(); const handler = getHandlerFromModule(mod, chosenMethod); if (!ssr && ssr === false && chosenMethod && chosenMethod !== 'get') { @@ -56,11 +52,6 @@ export function get({ params, request }) { Update your code to remove this warning.`); } - const context = { - request, - params, - }; - const proxy = new Proxy(context, { get(target, prop) { if (prop in target) { diff --git a/packages/astro/src/vite-plugin-astro-server/index.ts b/packages/astro/src/vite-plugin-astro-server/index.ts index 72f2ce9ba..e976280c8 100644 --- a/packages/astro/src/vite-plugin-astro-server/index.ts +++ b/packages/astro/src/vite-plugin-astro-server/index.ts @@ -6,6 +6,7 @@ import type { SSROptions } from '../core/render/dev/index'; import { Readable } from 'stream'; import { call as callEndpoint } from '../core/endpoint/dev/index.js'; +import { getSetCookiesFromResponse } from '../core/cookies/index.js'; import { collectErrorMetadata, ErrorWithMetadata, @@ -62,6 +63,11 @@ async function writeWebResponse(res: http.ServerResponse, webResponse: Response) _headers = Object.fromEntries(headers.entries()); } + // Attach any set-cookie headers added via Astro.cookies.set() + const setCookieHeaders = Array.from(getSetCookiesFromResponse(webResponse)); + if(setCookieHeaders.length) { + res.setHeader('Set-Cookie', setCookieHeaders); + } res.writeHead(status, _headers); if (body) { if (Symbol.for('astro.responseBody') in webResponse) { diff --git a/packages/astro/test/astro-cookies.test.js b/packages/astro/test/astro-cookies.test.js new file mode 100644 index 000000000..ece374d82 --- /dev/null +++ b/packages/astro/test/astro-cookies.test.js @@ -0,0 +1,119 @@ +import { expect } from 'chai'; +import * as cheerio from 'cheerio'; +import { loadFixture } from './test-utils.js'; +import testAdapter from './test-adapter.js'; + +describe('Astro.cookies', () => { + /** @type {import('./test-utils').Fixture} */ + let fixture; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/astro-cookies/', + output: 'server', + adapter: testAdapter(), + }); + }); + + describe('Development', () => { + /** @type {import('./test-utils').DevServer} */ + let devServer; + + before(async () => { + devServer = await fixture.startDevServer(); + }); + + after(async () => { + await devServer.stop(); + }); + + it('is able to get cookies from the request', async () => { + const response = await fixture.fetch('/get-json', { + headers: { + cookie: `prefs=${encodeURIComponent(JSON.stringify({ mode: 'light' }))}` + } + }); + expect(response.status).to.equal(200); + const html = await response.text(); + + const $ = cheerio.load(html); + expect($('dd').text()).to.equal('light'); + }); + + it('can set the cookie value', async () => { + const response = await fixture.fetch('/set-value', { + method: 'POST' + }); + expect(response.status).to.equal(200); + expect(response.headers.has('set-cookie')).to.equal(true); + }); + }); + + describe('Production', () => { + let app; + before(async () => { + await fixture.build(); + app = await fixture.loadTestAdapterApp(); + }); + + async function fetchResponse(path, requestInit) { + const request = new Request('http://example.com' + path, requestInit); + const response = await app.render(request); + return response; + } + + it('is able to get cookies from the request', async () => { + const response = await fetchResponse('/get-json', { + headers: { + cookie: `prefs=${encodeURIComponent(JSON.stringify({ mode: 'light' }))}` + } + }); + expect(response.status).to.equal(200); + const html = await response.text(); + + const $ = cheerio.load(html); + expect($('dd').text()).to.equal('light'); + }); + + it('can set the cookie value', async () => { + const response = await fetchResponse('/set-value', { + method: 'POST' + }); + expect(response.status).to.equal(200); + let headers = Array.from(app.setCookieHeaders(response)); + expect(headers).to.have.a.lengthOf(1); + expect(headers[0]).to.match(/Expires/); + }); + + it('Early returning a Response still includes set headers', async () => { + const response = await fetchResponse('/early-return', { + headers: { + cookie: `prefs=${encodeURIComponent(JSON.stringify({ mode: 'light' }))}` + } + }); + expect(response.status).to.equal(302); + let headers = Array.from(app.setCookieHeaders(response)); + expect(headers).to.have.a.lengthOf(1); + let raw = headers[0].slice(6); + let data = JSON.parse(decodeURIComponent(raw)); + expect(data).to.be.an('object'); + expect(data.mode).to.equal('dark'); + }); + + it('API route can get and set cookies', async () => { + const response = await fetchResponse('/set-prefs', { + method: 'POST', + headers: { + cookie: `prefs=${encodeURIComponent(JSON.stringify({ mode: 'light' }))}` + } + }); + expect(response.status).to.equal(302); + let headers = Array.from(app.setCookieHeaders(response)); + expect(headers).to.have.a.lengthOf(1); + let raw = headers[0].slice(6); + let data = JSON.parse(decodeURIComponent(raw)); + expect(data).to.be.an('object'); + expect(data.mode).to.equal('dark'); + }); + }) +}); diff --git a/packages/astro/test/fixtures/astro-cookies/package.json b/packages/astro/test/fixtures/astro-cookies/package.json new file mode 100644 index 000000000..42009b4af --- /dev/null +++ b/packages/astro/test/fixtures/astro-cookies/package.json @@ -0,0 +1,8 @@ +{ + "name": "@test/astro-cookies", + "version": "0.0.0", + "private": true, + "dependencies": { + "astro": "workspace:*" + } +} diff --git a/packages/astro/test/fixtures/astro-cookies/src/pages/early-return.astro b/packages/astro/test/fixtures/astro-cookies/src/pages/early-return.astro new file mode 100644 index 000000000..2796b3989 --- /dev/null +++ b/packages/astro/test/fixtures/astro-cookies/src/pages/early-return.astro @@ -0,0 +1,14 @@ +--- +const mode = Astro.cookies.get('prefs').json().mode; + +Astro.cookies.set('prefs', { + mode: mode === 'light' ? 'dark' : 'light' +}); + +return new Response(null, { + status: 302, + headers: { + 'Location': '/prefs' + } +}) +--- diff --git a/packages/astro/test/fixtures/astro-cookies/src/pages/get-json.astro b/packages/astro/test/fixtures/astro-cookies/src/pages/get-json.astro new file mode 100644 index 000000000..034881d22 --- /dev/null +++ b/packages/astro/test/fixtures/astro-cookies/src/pages/get-json.astro @@ -0,0 +1,17 @@ +--- +const cookie = Astro.cookies.get('prefs'); +const prefs = cookie.json(); +--- + + + Testing + + +

Testing

+

Preferences

+
+
Dark/light mode
+
{ prefs.mode }
+
+ + diff --git a/packages/astro/test/fixtures/astro-cookies/src/pages/set-prefs.js b/packages/astro/test/fixtures/astro-cookies/src/pages/set-prefs.js new file mode 100644 index 000000000..ccbdceff6 --- /dev/null +++ b/packages/astro/test/fixtures/astro-cookies/src/pages/set-prefs.js @@ -0,0 +1,15 @@ + +export function post({ cookies }) { + const mode = cookies.get('prefs').json().mode; + + cookies.set('prefs', { + mode: mode === 'light' ? 'dark' : 'light' + }); + + return new Response(null, { + status: 302, + headers: { + 'Location': '/prefs' + } + }); +} diff --git a/packages/astro/test/fixtures/astro-cookies/src/pages/set-value.astro b/packages/astro/test/fixtures/astro-cookies/src/pages/set-value.astro new file mode 100644 index 000000000..cd286da9e --- /dev/null +++ b/packages/astro/test/fixtures/astro-cookies/src/pages/set-value.astro @@ -0,0 +1,15 @@ +--- +if(Astro.request.method === 'POST') { + Astro.cookies.set('admin', 'true', { + expires: new Date() + }); +} +--- + + + Testing + + +

Testing

+ + diff --git a/packages/astro/test/units/cookies/delete.test.js b/packages/astro/test/units/cookies/delete.test.js new file mode 100644 index 000000000..e049995d4 --- /dev/null +++ b/packages/astro/test/units/cookies/delete.test.js @@ -0,0 +1,60 @@ +import { expect } from 'chai'; +import { AstroCookies } from '../../../dist/core/cookies/index.js'; +import { apply as applyPolyfill } from '../../../dist/core/polyfill.js'; + +applyPolyfill(); + +describe('astro/src/core/cookies', () => { + describe('Astro.cookies.delete', () => { + it('creates a Set-Cookie header to delete it', () => { + let req = new Request('http://example.com/', { + headers: { + 'cookie': 'foo=bar' + } + }); + let cookies = new AstroCookies(req); + expect(cookies.get('foo').value).to.equal('bar'); + + cookies.delete('foo'); + let headers = Array.from(cookies.headers()); + expect(headers).to.have.a.lengthOf(1); + }); + + it('calling cookies.get() after returns undefined', () => { + let req = new Request('http://example.com/', { + headers: { + 'cookie': 'foo=bar' + } + }); + let cookies = new AstroCookies(req); + expect(cookies.get('foo').value).to.equal('bar'); + + cookies.delete('foo'); + expect(cookies.get('foo').value).to.equal(undefined); + }); + + it('calling cookies.has() after returns false', () => { + let req = new Request('http://example.com/', { + headers: { + 'cookie': 'foo=bar' + } + }); + let cookies = new AstroCookies(req); + expect(cookies.has('foo')).to.equal(true); + + cookies.delete('foo'); + expect(cookies.has('foo')).to.equal(false); + }); + + it('can provide a path', () => { + let req = new Request('http://example.com/'); + let cookies = new AstroCookies(req); + cookies.delete('foo', { + path: '/subpath/' + }); + let headers = Array.from(cookies.headers()); + expect(headers).to.have.a.lengthOf(1); + expect(headers[0]).to.match(/Path=\/subpath\//); + }); + }); +}); diff --git a/packages/astro/test/units/cookies/get.test.js b/packages/astro/test/units/cookies/get.test.js new file mode 100644 index 000000000..837c2075e --- /dev/null +++ b/packages/astro/test/units/cookies/get.test.js @@ -0,0 +1,136 @@ +import { expect } from 'chai'; +import { AstroCookies } from '../../../dist/core/cookies/index.js'; +import { apply as applyPolyfill } from '../../../dist/core/polyfill.js'; + +applyPolyfill(); + +describe('astro/src/core/cookies', () => { + describe('Astro.cookies.get', () => { + it('gets the cookie value', () => { + const req = new Request('http://example.com/', { + headers: { + 'cookie': 'foo=bar' + } + }); + const cookies = new AstroCookies(req); + expect(cookies.get('foo').value).to.equal('bar'); + }); + + describe('.json()', () => { + it('returns a JavaScript object', () => { + const req = new Request('http://example.com/', { + headers: { + 'cookie': 'foo=%7B%22key%22%3A%22value%22%7D' + } + }); + let cookies = new AstroCookies(req); + + const json = cookies.get('foo').json(); + expect(json).to.be.an('object'); + expect(json.key).to.equal('value'); + }); + + it('throws if the value is undefined', () => { + const req = new Request('http://example.com/'); + let cookies = new AstroCookies(req); + let cookie = cookies.get('foo'); + expect(() => cookie.json()).to.throw('Cannot convert undefined to an object.'); + }); + }); + + describe('.number()', () => { + it('Coerces into a number', () => { + const req = new Request('http://example.com/', { + headers: { + 'cookie': 'foo=22' + } + }); + let cookies = new AstroCookies(req); + + const value = cookies.get('foo').number(); + expect(value).to.be.an('number'); + expect(value).to.equal(22); + }); + + it('Coerces non-number into NaN', () => { + const req = new Request('http://example.com/', { + headers: { + 'cookie': 'foo=bar' + } + }); + let cookies = new AstroCookies(req); + + const value = cookies.get('foo').number(); + expect(value).to.be.an('number'); + expect(Number.isNaN(value)).to.equal(true); + }); + }); + + describe('.boolean()', () => { + it('Coerces true into `true`', () => { + const req = new Request('http://example.com/', { + headers: { + 'cookie': 'foo=true' + } + }); + let cookies = new AstroCookies(req); + + const value = cookies.get('foo').boolean(); + expect(value).to.be.an('boolean'); + expect(value).to.equal(true); + }); + + it('Coerces false into `false`', () => { + const req = new Request('http://example.com/', { + headers: { + 'cookie': 'foo=false' + } + }); + let cookies = new AstroCookies(req); + + const value = cookies.get('foo').boolean(); + expect(value).to.be.an('boolean'); + expect(value).to.equal(false); + }); + + it('Coerces 1 into `true`', () => { + const req = new Request('http://example.com/', { + headers: { + 'cookie': 'foo=1' + } + }); + let cookies = new AstroCookies(req); + + const value = cookies.get('foo').boolean(); + expect(value).to.be.an('boolean'); + expect(value).to.equal(true); + }); + + it('Coerces 0 into `false`', () => { + const req = new Request('http://example.com/', { + headers: { + 'cookie': 'foo=0' + } + }); + let cookies = new AstroCookies(req); + + const value = cookies.get('foo').boolean(); + expect(value).to.be.an('boolean'); + expect(value).to.equal(false); + }); + + it('Coerces truthy strings into `true`', () => { + const req = new Request('http://example.com/', { + headers: { + 'cookie': 'foo=bar' + } + }); + let cookies = new AstroCookies(req); + + const value = cookies.get('foo').boolean(); + expect(value).to.be.an('boolean'); + expect(value).to.equal(true); + }); + }); + }); +}); diff --git a/packages/astro/test/units/cookies/has.test.js b/packages/astro/test/units/cookies/has.test.js new file mode 100644 index 000000000..d9a7eb66f --- /dev/null +++ b/packages/astro/test/units/cookies/has.test.js @@ -0,0 +1,32 @@ +import { expect } from 'chai'; +import { AstroCookies } from '../../../dist/core/cookies/index.js'; +import { apply as applyPolyfill } from '../../../dist/core/polyfill.js'; + +applyPolyfill(); + +describe('astro/src/core/cookies', () => { + describe('Astro.cookies.has', () => { + it('returns true if the request has the cookie', () => { + let req = new Request('http://example.com/', { + headers: { + 'cookie': 'foo=bar' + } + }); + let cookies = new AstroCookies(req); + expect(cookies.has('foo')).to.equal(true); + }); + + it('returns false if the request does not have the cookie', () => { + let req = new Request('http://example.com/'); + let cookies = new AstroCookies(req); + expect(cookies.has('foo')).to.equal(false); + }); + + it('returns true if the cookie has been set', () => { + let req = new Request('http://example.com/'); + let cookies = new AstroCookies(req); + cookies.set('foo', 'bar'); + expect(cookies.has('foo')).to.equal(true); + }); + }); +}); diff --git a/packages/astro/test/units/cookies/set.test.js b/packages/astro/test/units/cookies/set.test.js new file mode 100644 index 000000000..acf436766 --- /dev/null +++ b/packages/astro/test/units/cookies/set.test.js @@ -0,0 +1,82 @@ +import { expect } from 'chai'; +import { AstroCookies } from '../../../dist/core/cookies/index.js'; +import { apply as applyPolyfill } from '../../../dist/core/polyfill.js'; + +applyPolyfill(); + +describe('astro/src/core/cookies', () => { + describe('Astro.cookies.set', () => { + it('Sets a cookie value that can be serialized', () => { + let req = new Request('http://example.com/'); + let cookies = new AstroCookies(req); + cookies.set('foo', 'bar'); + let headers = Array.from(cookies.headers()); + expect(headers).to.have.a.lengthOf(1); + expect(headers[0]).to.equal('foo=bar'); + }); + + it('Can set cookie options', () => { + let req = new Request('http://example.com/'); + let cookies = new AstroCookies(req); + cookies.set('foo', 'bar', { + httpOnly: true, + path: '/subpath/' + }); + let headers = Array.from(cookies.headers()); + expect(headers).to.have.a.lengthOf(1); + expect(headers[0]).to.equal('foo=bar; Path=/subpath/; HttpOnly'); + }); + + it('Can pass a JavaScript object that will be serialized', () => { + let req = new Request('http://example.com/'); + let cookies = new AstroCookies(req); + cookies.set('options', { one: 'two', three: 4 }); + let headers = Array.from(cookies.headers()); + expect(headers).to.have.a.lengthOf(1); + expect(JSON.parse(decodeURIComponent(headers[0].slice(8))).one).to.equal('two'); + }); + + it('Can pass a number', () => { + let req = new Request('http://example.com/'); + let cookies = new AstroCookies(req); + cookies.set('one', 2); + let headers = Array.from(cookies.headers()); + expect(headers).to.have.a.lengthOf(1); + expect(headers[0]).to.equal('one=2'); + }); + + it('Can get the value after setting', () => { + let req = new Request('http://example.com/'); + let cookies = new AstroCookies(req); + cookies.set('foo', 'bar'); + let r = cookies.get('foo'); + expect(r.value).to.equal('bar'); + }); + + it('Can get the JavaScript object after setting', () => { + let req = new Request('http://example.com/'); + let cookies = new AstroCookies(req); + cookies.set('options', { one: 'two', three: 4 }); + let cook = cookies.get('options'); + let value = cook.json(); + expect(value).to.be.an('object'); + expect(value.one).to.equal('two'); + expect(value.three).to.be.a('number'); + expect(value.three).to.equal(4); + }); + + it('Overrides a value in the request', () => { + let req = new Request('http://example.com/', { + headers: { + 'cookie': 'foo=bar' + } + }); + let cookies = new AstroCookies(req); + expect(cookies.get('foo').value).to.equal('bar'); + + // Set a new value + cookies.set('foo', 'baz'); + expect(cookies.get('foo').value).to.equal('baz'); + }); + }); +}); diff --git a/packages/integrations/cloudflare/src/server.advanced.ts b/packages/integrations/cloudflare/src/server.advanced.ts index 7b88c7b1e..62adb44ec 100644 --- a/packages/integrations/cloudflare/src/server.advanced.ts +++ b/packages/integrations/cloudflare/src/server.advanced.ts @@ -26,7 +26,15 @@ export function createExports(manifest: SSRManifest) { Symbol.for('astro.clientAddress'), request.headers.get('cf-connecting-ip') ); - return app.render(request, routeData); + let response = await app.render(request, routeData); + + if(app.setCookieHeaders) { + for(const setCookieHeader of app.setCookieHeaders(response)) { + response.headers.append('Set-Cookie', setCookieHeader); + } + } + + return response; } return new Response(null, { diff --git a/packages/integrations/cloudflare/src/server.directory.ts b/packages/integrations/cloudflare/src/server.directory.ts index 58e83be34..7a484378c 100644 --- a/packages/integrations/cloudflare/src/server.directory.ts +++ b/packages/integrations/cloudflare/src/server.directory.ts @@ -28,7 +28,15 @@ export function createExports(manifest: SSRManifest) { Symbol.for('astro.clientAddress'), request.headers.get('cf-connecting-ip') ); - return app.render(request, routeData); + let response = await app.render(request, routeData); + + if(app.setCookieHeaders) { + for(const setCookieHeader of app.setCookieHeaders(response)) { + response.headers.append('Set-Cookie', setCookieHeader); + } + } + + return response; } return new Response(null, { diff --git a/packages/integrations/deno/src/server.ts b/packages/integrations/deno/src/server.ts index d8eb3320d..d8c6aede9 100644 --- a/packages/integrations/deno/src/server.ts +++ b/packages/integrations/deno/src/server.ts @@ -26,7 +26,13 @@ export function start(manifest: SSRManifest, options: Options) { if (app.match(request)) { let ip = connInfo?.remoteAddr?.hostname; Reflect.set(request, Symbol.for('astro.clientAddress'), ip); - return await app.render(request); + const response = await app.render(request); + if(app.setCookieHeaders) { + for(const setCookieHeader of app.setCookieHeaders(response)) { + response.headers.append('Set-Cookie', setCookieHeader); + } + } + return response; } // If the request path wasn't found in astro, @@ -38,7 +44,14 @@ export function start(manifest: SSRManifest, options: Options) { // If the static file can't be found if (fileResp.status == 404) { // Render the astro custom 404 page - return await app.render(request); + const response = await app.render(request); + + if(app.setCookieHeaders) { + for(const setCookieHeader of app.setCookieHeaders(response)) { + response.headers.append('Set-Cookie', setCookieHeader); + } + } + return response; // If the static file is found } else { diff --git a/packages/integrations/netlify/src/netlify-edge-functions.ts b/packages/integrations/netlify/src/netlify-edge-functions.ts index a2c883585..c788b5f67 100644 --- a/packages/integrations/netlify/src/netlify-edge-functions.ts +++ b/packages/integrations/netlify/src/netlify-edge-functions.ts @@ -17,7 +17,13 @@ export function createExports(manifest: SSRManifest) { if (app.match(request)) { const ip = request.headers.get('x-nf-client-connection-ip'); Reflect.set(request, clientAddressSymbol, ip); - return app.render(request); + const response = await app.render(request); + if(app.setCookieHeaders) { + for(const setCookieHeader of app.setCookieHeaders(response)) { + response.headers.append('Set-Cookie', setCookieHeader); + } + } + return response; } return new Response(null, { diff --git a/packages/integrations/netlify/src/netlify-functions.ts b/packages/integrations/netlify/src/netlify-functions.ts index 94c9b6eee..7945b4687 100644 --- a/packages/integrations/netlify/src/netlify-functions.ts +++ b/packages/integrations/netlify/src/netlify-functions.ts @@ -120,6 +120,16 @@ export const createExports = (manifest: SSRManifest, args: Args) => { } } + // Apply cookies set via Astro.cookies.set/delete + if(app.setCookieHeaders) { + const setCookieHeaders = Array.from(app.setCookieHeaders(response)); + fnResponse.multiValueHeaders = fnResponse.multiValueHeaders || {}; + if(!fnResponse.multiValueHeaders['set-cookie']) { + fnResponse.multiValueHeaders['set-cookie'] = []; + } + fnResponse.multiValueHeaders['set-cookie'].push(...setCookieHeaders); + } + return fnResponse; }; diff --git a/packages/integrations/node/src/server.ts b/packages/integrations/node/src/server.ts index 12fcf0448..794580ee9 100644 --- a/packages/integrations/node/src/server.ts +++ b/packages/integrations/node/src/server.ts @@ -18,7 +18,7 @@ export function createExports(manifest: SSRManifest) { if (route) { try { const response = await app.render(req); - await writeWebResponse(res, response); + await writeWebResponse(app, res, response); } catch (err: unknown) { if (next) { next(err); @@ -39,8 +39,16 @@ export function createExports(manifest: SSRManifest) { }; } -async function writeWebResponse(res: ServerResponse, webResponse: Response) { +async function writeWebResponse(app: NodeApp, res: ServerResponse, webResponse: Response) { const { status, headers, body } = webResponse; + + if(app.setCookieHeaders) { + const setCookieHeaders: Array = Array.from(app.setCookieHeaders(webResponse)); + if(setCookieHeaders.length) { + res.setHeader('Set-Cookie', setCookieHeaders); + } + } + res.writeHead(status, Object.fromEntries(headers.entries())); if (body) { for await (const chunk of body as unknown as Readable) { diff --git a/packages/integrations/vercel/src/edge/entrypoint.ts b/packages/integrations/vercel/src/edge/entrypoint.ts index 8063c271a..b37421744 100644 --- a/packages/integrations/vercel/src/edge/entrypoint.ts +++ b/packages/integrations/vercel/src/edge/entrypoint.ts @@ -15,7 +15,13 @@ export function createExports(manifest: SSRManifest) { const handler = async (request: Request): Promise => { if (app.match(request)) { Reflect.set(request, clientAddressSymbol, request.headers.get('x-forwarded-for')); - return await app.render(request); + const response = await app.render(request); + if(app.setCookieHeaders) { + for(const setCookieHeader of app.setCookieHeaders(response)) { + response.headers.append('Set-Cookie', setCookieHeader); + } + } + return response; } return new Response(null, { diff --git a/packages/integrations/vercel/src/serverless/entrypoint.ts b/packages/integrations/vercel/src/serverless/entrypoint.ts index 6b94f201c..e41d0a438 100644 --- a/packages/integrations/vercel/src/serverless/entrypoint.ts +++ b/packages/integrations/vercel/src/serverless/entrypoint.ts @@ -28,7 +28,7 @@ export const createExports = (manifest: SSRManifest) => { return res.end('Not found'); } - await setResponse(res, await app.render(request, routeData)); + await setResponse(app, res, await app.render(request, routeData)); }; return { default: handler }; diff --git a/packages/integrations/vercel/src/serverless/request-transform.ts b/packages/integrations/vercel/src/serverless/request-transform.ts index 6f3a063bd..97337751f 100644 --- a/packages/integrations/vercel/src/serverless/request-transform.ts +++ b/packages/integrations/vercel/src/serverless/request-transform.ts @@ -1,4 +1,5 @@ import type { IncomingMessage, ServerResponse } from 'node:http'; +import type { App } from 'astro/app'; import { Readable } from 'node:stream'; const clientAddressSymbol = Symbol.for('astro.clientAddress'); @@ -77,7 +78,7 @@ export async function getRequest(base: string, req: IncomingMessage): Promise { +export async function setResponse(app: App, res: ServerResponse, response: Response): Promise { const headers = Object.fromEntries(response.headers); if (response.headers.has('set-cookie')) { @@ -85,6 +86,13 @@ export async function setResponse(res: ServerResponse, response: Response): Prom headers['set-cookie'] = response.headers.raw()['set-cookie']; } + if(app.setCookieHeaders) { + const setCookieHeaders: Array = Array.from(app.setCookieHeaders(response)); + if(setCookieHeaders.length) { + res.setHeader('Set-Cookie', setCookieHeaders); + } + } + res.writeHead(response.status, headers); if (response.body instanceof Readable) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 98ba35f22..aaeec592c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -369,6 +369,7 @@ importers: '@types/chai': ^4.3.1 '@types/common-ancestor-path': ^1.0.0 '@types/connect': ^3.4.35 + '@types/cookie': ^0.5.1 '@types/debug': ^4.1.7 '@types/diff': ^5.0.2 '@types/estree': ^0.0.51 @@ -392,6 +393,7 @@ importers: cheerio: ^1.0.0-rc.11 ci-info: ^3.3.1 common-ancestor-path: ^1.0.1 + cookie: ^0.5.0 debug: ^4.3.4 diff: ^5.1.0 eol: ^0.9.1 @@ -460,6 +462,7 @@ importers: boxen: 6.2.1 ci-info: 3.4.0 common-ancestor-path: 1.0.1 + cookie: 0.5.0 debug: 4.3.4 diff: 5.1.0 eol: 0.9.1 @@ -506,6 +509,7 @@ importers: '@types/chai': 4.3.3 '@types/common-ancestor-path': 1.0.0 '@types/connect': 3.4.35 + '@types/cookie': 0.5.1 '@types/debug': 4.1.7 '@types/diff': 5.0.2 '@types/estree': 0.0.51 @@ -1200,6 +1204,12 @@ importers: dependencies: astro: link:../../.. + packages/astro/test/fixtures/astro-cookies: + specifiers: + astro: workspace:* + dependencies: + astro: link:../../.. + packages/astro/test/fixtures/astro-css-bundling: specifiers: astro: workspace:* @@ -4997,7 +5007,7 @@ packages: babel-plugin-polyfill-corejs2: 0.3.3_@babel+core@7.19.1 babel-plugin-polyfill-corejs3: 0.6.0_@babel+core@7.19.1 babel-plugin-polyfill-regenerator: 0.4.1_@babel+core@7.19.1 - core-js-compat: 3.25.2 + core-js-compat: 3.25.3 semver: 6.3.0 transitivePeerDependencies: - supports-color @@ -9286,6 +9296,10 @@ packages: '@types/node': 18.7.23 dev: true + /@types/cookie/0.5.1: + resolution: {integrity: sha512-COUnqfB2+ckwXXSFInsFdOAWQzCCx+a5hq2ruyj+Vjund94RJQd4LG2u9hnvJrTgunKAaax7ancBYlDrNYxA0g==} + dev: true + /@types/debug/4.1.7: resolution: {integrity: sha512-9AonUzyTjXXhEOa0DnqpzZi6VHlqKMswga9EXjpXnnqxwLtdvPPtlO8evrI5D9S6asFRCQ6v+wpiUKbw+vKqyg==} dependencies: @@ -10311,7 +10325,7 @@ packages: dependencies: '@babel/core': 7.19.1 '@babel/helper-define-polyfill-provider': 0.3.3_@babel+core@7.19.1 - core-js-compat: 3.25.2 + core-js-compat: 3.25.3 transitivePeerDependencies: - supports-color dev: false @@ -10823,8 +10837,13 @@ packages: engines: {node: '>= 0.6'} dev: true - /core-js-compat/3.25.2: - resolution: {integrity: sha512-TxfyECD4smdn3/CjWxczVtJqVLEEC2up7/82t7vC0AzNogr+4nQ8vyF7abxAuTXWvjTClSbvGhU0RgqA4ToQaQ==} + /cookie/0.5.0: + resolution: {integrity: sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==} + engines: {node: '>= 0.6'} + dev: false + + /core-js-compat/3.25.3: + resolution: {integrity: sha512-xVtYpJQ5grszDHEUU9O7XbjjcZ0ccX3LgQsyqSvTnjX97ZqEgn9F5srmrwwwMtbKzDllyFPL+O+2OFMl1lU4TQ==} dependencies: browserslist: 4.21.4 dev: false