From 6fe46cc8ff3d0c5ec22e1f943591eaf2e415b110 Mon Sep 17 00:00:00 2001 From: Matthew Phillips Date: Mon, 26 Sep 2022 13:41:39 -0400 Subject: [PATCH 1/5] Astro.cookies implementation --- packages/astro/package.json | 3 + packages/astro/src/@types/astro.ts | 7 + packages/astro/src/core/app/index.ts | 5 + packages/astro/src/core/cookies/cookies.ts | 236 ++++++++++++++++++ packages/astro/src/core/cookies/index.ts | 9 + packages/astro/src/core/cookies/response.ts | 26 ++ packages/astro/src/core/endpoint/index.ts | 18 +- packages/astro/src/core/render/core.ts | 11 +- packages/astro/src/core/render/result.ts | 13 + packages/astro/src/runtime/server/endpoint.ts | 13 +- .../src/vite-plugin-astro-server/index.ts | 6 + packages/astro/test/astro-cookies.test.js | 119 +++++++++ .../test/fixtures/astro-cookies/package.json | 8 + .../src/pages/early-return.astro | 14 ++ .../astro-cookies/src/pages/get-json.astro | 17 ++ .../astro-cookies/src/pages/set-prefs.js | 15 ++ .../astro-cookies/src/pages/set-value.astro | 15 ++ .../astro/test/units/cookies/delete.test.js | 60 +++++ packages/astro/test/units/cookies/get.test.js | 56 +++++ packages/astro/test/units/cookies/has.test.js | 32 +++ packages/astro/test/units/cookies/set.test.js | 116 +++++++++ .../cloudflare/src/server.advanced.ts | 12 +- .../cloudflare/src/server.directory.ts | 10 +- packages/integrations/deno/src/server.ts | 17 +- .../netlify/src/netlify-edge-functions.ts | 8 +- .../netlify/src/netlify-functions.ts | 10 + packages/integrations/node/src/server.ts | 12 +- .../vercel/src/edge/entrypoint.ts | 8 +- .../src/serverless/request-transform.ts | 10 +- pnpm-lock.yaml | 29 ++- 30 files changed, 886 insertions(+), 29 deletions(-) create mode 100644 packages/astro/src/core/cookies/cookies.ts create mode 100644 packages/astro/src/core/cookies/index.ts create mode 100644 packages/astro/src/core/cookies/response.ts create mode 100644 packages/astro/test/astro-cookies.test.js create mode 100644 packages/astro/test/fixtures/astro-cookies/package.json create mode 100644 packages/astro/test/fixtures/astro-cookies/src/pages/early-return.astro create mode 100644 packages/astro/test/fixtures/astro-cookies/src/pages/get-json.astro create mode 100644 packages/astro/test/fixtures/astro-cookies/src/pages/set-prefs.js create mode 100644 packages/astro/test/fixtures/astro-cookies/src/pages/set-value.astro create mode 100644 packages/astro/test/units/cookies/delete.test.js create mode 100644 packages/astro/test/units/cookies/get.test.js create mode 100644 packages/astro/test/units/cookies/has.test.js create mode 100644 packages/astro/test/units/cookies/set.test.js diff --git a/packages/astro/package.json b/packages/astro/package.json index 0792f118a0b3..dcf636f42992 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", @@ -128,6 +129,7 @@ "kleur": "^4.1.4", "magic-string": "^0.25.9", "mime": "^3.0.0", + "ms": "^2.1.3", "ora": "^6.1.0", "path-browserify": "^1.0.1", "path-to-regexp": "^6.2.1", @@ -161,6 +163,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 c423a1abf594..7c19dcca67ec 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 413dba20ced0..ce3422738735 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 000000000000..94e5d713dd3c --- /dev/null +++ b/packages/astro/src/core/cookies/cookies.ts @@ -0,0 +1,236 @@ +import type { CookieSerializeOptions } from 'cookie'; +import { parse, serialize } from 'cookie'; +import ms from 'ms'; + +interface AstroCookieSetOptions { + domain?: string; + expires?: number | Date | string; + 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() { + if(this.value === undefined) { + throw new Error(`Cannot convert undefined to a 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; + } + } + + let expires: Date | undefined = undefined; + if(options?.expires) { + let rawExpires = options.expires; + switch(typeof rawExpires) { + case 'string': { + let numberOfMs = ms(rawExpires); + if(numberOfMs === undefined) { + if(rawExpires.includes('month')) { + throw new Error(`Cannot convert months because there is no fixed duration. Use days instead.`); + } else { + throw new Error(`Unable to convert expires expression [${rawExpires}]`); + } + } + let now = Date.now(); + expires = new Date(now + numberOfMs); + break; + } + case 'number': { + expires = new Date(rawExpires); + break; + } + default: { + expires = rawExpires; + break; + } + } + } + + const serializeOptions: CookieSerializeOptions = {}; + if(options) { + Object.assign(serializeOptions, options, { + expires + }); + } + + 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 000000000000..18dc3ebcab53 --- /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 000000000000..0e52ac8cb373 --- /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 7fee0c428e51..75e451e6fc17 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 c9efe02de97f..7e5fe1f96c14 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 d4704ca1f6f9..c0e650a8f74f 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 31a0069dbf2f..a93d02adbf51 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 72f2ce9bad06..e976280c89ee 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 000000000000..ece374d82e86 --- /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 000000000000..42009b4af085 --- /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 000000000000..2796b3989bbc --- /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 000000000000..034881d225b4 --- /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 000000000000..ccbdceff618f --- /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 000000000000..57600d42a27d --- /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: '30 days' + }); +} +--- + + + 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 000000000000..e049995d4ae0 --- /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 000000000000..c5f50a42cfa7 --- /dev/null +++ b/packages/astro/test/units/cookies/get.test.js @@ -0,0 +1,56 @@ +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); + }); + }); + }); +}); 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 000000000000..d9a7eb66fed9 --- /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 000000000000..0e6dbebe761d --- /dev/null +++ b/packages/astro/test/units/cookies/set.test.js @@ -0,0 +1,116 @@ +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 pass a string distance from now as expires', () => { + let req = new Request('http://example.com/'); + let cookies = new AstroCookies(req); + cookies.set('one', 2, { + expires: '1 week' + }); + let headers = Array.from(cookies.headers()); + expect(headers).to.have.a.lengthOf(1); + expect(headers[0]).to.match(/one=2/); + expect(headers[0]).to.match(/Expires/); + }); + + it('can pass a date as a number to expires', () => { + let req = new Request('http://example.com/'); + let expiration = new Date('Fri, 01 Jan 2044 00:00:00 GMT'); + let cookies = new AstroCookies(req); + cookies.set('one', 2, { + expires: expiration.valueOf() + }); + let headers = Array.from(cookies.headers()); + expect(headers).to.have.a.lengthOf(1); + expect(headers[0]).to.equal('one=2; Expires=Fri, 01 Jan 2044 00:00:00 GMT'); + }); + + it('throws if passing a string to expires that ms does not convert', () => { + let req = new Request('http://example.com/'); + let cookies = new AstroCookies(req); + expect(() => { + cookies.set('one', 2, { + expires: 'unknown date' + }); + }).to.throw(`Unable to convert expires expression [unknown date]`); + }); + + 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 7b88c7b1e4d1..6d7472f5bfb5 100644 --- a/packages/integrations/cloudflare/src/server.advanced.ts +++ b/packages/integrations/cloudflare/src/server.advanced.ts @@ -1,7 +1,7 @@ import './shim.js'; import type { SSRManifest } from 'astro'; -import { App } from 'astro/app'; +import { App, getSetCookiesFromResponse } from 'astro/app'; type Env = { ASSETS: { fetch: (req: Request) => Promise }; @@ -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 58e83be34f05..7a484378cca0 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 d8eb3320d5df..d8c6aede9f1d 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 a2c883585947..c788b5f67dda 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 94c9b6eeea2a..7945b4687517 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 12fcf04484a4..794580ee9153 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 8063c271a9de..b3742174427b 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/request-transform.ts b/packages/integrations/vercel/src/serverless/request-transform.ts index 6f3a063bd709..97337751f697 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 98ba35f22c9c..c201050bc5cb 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 @@ -407,6 +409,7 @@ importers: magic-string: ^0.25.9 mime: ^3.0.0 mocha: ^9.2.2 + ms: ^2.1.3 node-fetch: ^3.2.5 ora: ^6.1.0 path-browserify: ^1.0.1 @@ -460,6 +463,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 @@ -474,6 +478,7 @@ importers: kleur: 4.1.5 magic-string: 0.25.9 mime: 3.0.0 + ms: 2.1.3 ora: 6.1.2 path-browserify: 1.0.1 path-to-regexp: 6.2.1 @@ -506,6 +511,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 +1206,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 +5009,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 +9298,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 +10327,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 +10839,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 From 983ca459002ff0f50f3f6847989c863add630564 Mon Sep 17 00:00:00 2001 From: Matthew Phillips Date: Mon, 26 Sep 2022 13:48:19 -0400 Subject: [PATCH 2/5] Remove unused var --- packages/integrations/cloudflare/src/server.advanced.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/integrations/cloudflare/src/server.advanced.ts b/packages/integrations/cloudflare/src/server.advanced.ts index 6d7472f5bfb5..62adb44ece00 100644 --- a/packages/integrations/cloudflare/src/server.advanced.ts +++ b/packages/integrations/cloudflare/src/server.advanced.ts @@ -1,7 +1,7 @@ import './shim.js'; import type { SSRManifest } from 'astro'; -import { App, getSetCookiesFromResponse } from 'astro/app'; +import { App } from 'astro/app'; type Env = { ASSETS: { fetch: (req: Request) => Promise }; From cd736fbee763f013c69fb6a6bc919652ea8128f4 Mon Sep 17 00:00:00 2001 From: Matthew Phillips Date: Mon, 26 Sep 2022 14:02:26 -0400 Subject: [PATCH 3/5] Fix build --- packages/integrations/vercel/src/serverless/entrypoint.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/integrations/vercel/src/serverless/entrypoint.ts b/packages/integrations/vercel/src/serverless/entrypoint.ts index 6b94f201cc42..e41d0a4381a5 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 }; From a4d83bb97919e399f254ccee7ae545b891beb076 Mon Sep 17 00:00:00 2001 From: Matthew Phillips Date: Tue, 27 Sep 2022 15:59:01 -0400 Subject: [PATCH 4/5] Add a changesetp --- .changeset/thin-news-collect.md | 48 +++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 .changeset/thin-news-collect.md diff --git a/.changeset/thin-news-collect.md b/.changeset/thin-news-collect.md new file mode 100644 index 000000000000..7779013b8a13 --- /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. From 43df66a9f947148dde1cc83f446502e8bf60fbcb Mon Sep 17 00:00:00 2001 From: Matthew Phillips Date: Wed, 28 Sep 2022 10:54:31 -0400 Subject: [PATCH 5/5] Remove spoken-word expires --- packages/astro/package.json | 1 - packages/astro/src/core/cookies/cookies.ts | 38 +-------- .../astro-cookies/src/pages/set-value.astro | 2 +- packages/astro/test/units/cookies/get.test.js | 80 +++++++++++++++++++ packages/astro/test/units/cookies/set.test.js | 34 -------- pnpm-lock.yaml | 2 - 6 files changed, 83 insertions(+), 74 deletions(-) diff --git a/packages/astro/package.json b/packages/astro/package.json index dcf636f42992..0a39c75e1aad 100644 --- a/packages/astro/package.json +++ b/packages/astro/package.json @@ -129,7 +129,6 @@ "kleur": "^4.1.4", "magic-string": "^0.25.9", "mime": "^3.0.0", - "ms": "^2.1.3", "ora": "^6.1.0", "path-browserify": "^1.0.1", "path-to-regexp": "^6.2.1", diff --git a/packages/astro/src/core/cookies/cookies.ts b/packages/astro/src/core/cookies/cookies.ts index 94e5d713dd3c..7f530ce858bb 100644 --- a/packages/astro/src/core/cookies/cookies.ts +++ b/packages/astro/src/core/cookies/cookies.ts @@ -1,10 +1,9 @@ import type { CookieSerializeOptions } from 'cookie'; import { parse, serialize } from 'cookie'; -import ms from 'ms'; interface AstroCookieSetOptions { domain?: string; - expires?: number | Date | string; + expires?: Date; httpOnly?: boolean; maxAge?: number; path?: string; @@ -42,9 +41,6 @@ class AstroCookie implements AstroCookieInterface { return JSON.parse(this.value); } number() { - if(this.value === undefined) { - throw new Error(`Cannot convert undefined to a number.`); - } return Number(this.value); } boolean() { @@ -149,39 +145,9 @@ class AstroCookies implements AstroCookiesInterface { } } - let expires: Date | undefined = undefined; - if(options?.expires) { - let rawExpires = options.expires; - switch(typeof rawExpires) { - case 'string': { - let numberOfMs = ms(rawExpires); - if(numberOfMs === undefined) { - if(rawExpires.includes('month')) { - throw new Error(`Cannot convert months because there is no fixed duration. Use days instead.`); - } else { - throw new Error(`Unable to convert expires expression [${rawExpires}]`); - } - } - let now = Date.now(); - expires = new Date(now + numberOfMs); - break; - } - case 'number': { - expires = new Date(rawExpires); - break; - } - default: { - expires = rawExpires; - break; - } - } - } - const serializeOptions: CookieSerializeOptions = {}; if(options) { - Object.assign(serializeOptions, options, { - expires - }); + Object.assign(serializeOptions, options); } this.#ensureOutgoingMap().set(key, [ 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 index 57600d42a27d..cd286da9edf2 100644 --- a/packages/astro/test/fixtures/astro-cookies/src/pages/set-value.astro +++ b/packages/astro/test/fixtures/astro-cookies/src/pages/set-value.astro @@ -1,7 +1,7 @@ --- if(Astro.request.method === 'POST') { Astro.cookies.set('admin', 'true', { - expires: '30 days' + expires: new Date() }); } --- diff --git a/packages/astro/test/units/cookies/get.test.js b/packages/astro/test/units/cookies/get.test.js index c5f50a42cfa7..837c2075e26d 100644 --- a/packages/astro/test/units/cookies/get.test.js +++ b/packages/astro/test/units/cookies/get.test.js @@ -51,6 +51,86 @@ describe('astro/src/core/cookies', () => { 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/set.test.js b/packages/astro/test/units/cookies/set.test.js index 0e6dbebe761d..acf436766f0d 100644 --- a/packages/astro/test/units/cookies/set.test.js +++ b/packages/astro/test/units/cookies/set.test.js @@ -45,40 +45,6 @@ describe('astro/src/core/cookies', () => { expect(headers[0]).to.equal('one=2'); }); - it('can pass a string distance from now as expires', () => { - let req = new Request('http://example.com/'); - let cookies = new AstroCookies(req); - cookies.set('one', 2, { - expires: '1 week' - }); - let headers = Array.from(cookies.headers()); - expect(headers).to.have.a.lengthOf(1); - expect(headers[0]).to.match(/one=2/); - expect(headers[0]).to.match(/Expires/); - }); - - it('can pass a date as a number to expires', () => { - let req = new Request('http://example.com/'); - let expiration = new Date('Fri, 01 Jan 2044 00:00:00 GMT'); - let cookies = new AstroCookies(req); - cookies.set('one', 2, { - expires: expiration.valueOf() - }); - let headers = Array.from(cookies.headers()); - expect(headers).to.have.a.lengthOf(1); - expect(headers[0]).to.equal('one=2; Expires=Fri, 01 Jan 2044 00:00:00 GMT'); - }); - - it('throws if passing a string to expires that ms does not convert', () => { - let req = new Request('http://example.com/'); - let cookies = new AstroCookies(req); - expect(() => { - cookies.set('one', 2, { - expires: 'unknown date' - }); - }).to.throw(`Unable to convert expires expression [unknown date]`); - }); - it('Can get the value after setting', () => { let req = new Request('http://example.com/'); let cookies = new AstroCookies(req); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c201050bc5cb..aaeec592cd2c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -409,7 +409,6 @@ importers: magic-string: ^0.25.9 mime: ^3.0.0 mocha: ^9.2.2 - ms: ^2.1.3 node-fetch: ^3.2.5 ora: ^6.1.0 path-browserify: ^1.0.1 @@ -478,7 +477,6 @@ importers: kleur: 4.1.5 magic-string: 0.25.9 mime: 3.0.0 - ms: 2.1.3 ora: 6.1.2 path-browserify: 1.0.1 path-to-regexp: 6.2.1