Skip to content

Commit

Permalink
Astro.cookies implementation (#4876)
Browse files Browse the repository at this point in the history
* Astro.cookies implementation

* Remove unused var

* Fix build

* Add a changesetp

* Remove spoken-word expires
  • Loading branch information
matthewp authored Sep 28, 2022
1 parent ec55745 commit d3091f8
Show file tree
Hide file tree
Showing 32 changed files with 943 additions and 29 deletions.
48 changes: 48 additions & 0 deletions .changeset/thin-news-collect.md
Original file line number Diff line number Diff line change
@@ -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>('prefs', { darkMode: true }, {
expires: '1 month'
});
const prefs = Astro.cookies.get<Prefs>('prefs').json();
---
<body data-theme={prefs.darkMode ? 'dark' : 'light'}>
```

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.
2 changes: 2 additions & 0 deletions packages/astro/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
7 changes: 7 additions & 0 deletions packages/astro/src/@types/astro.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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)
*
Expand Down Expand Up @@ -1083,6 +1088,7 @@ export interface AstroAdapter {
type Body = string;

export interface APIContext {
cookies: AstroCookies;
params: Params;
request: Request;
}
Expand Down Expand Up @@ -1219,6 +1225,7 @@ export interface SSRResult {
styles: Set<SSRElement>;
scripts: Set<SSRElement>;
links: Set<SSRElement>;
cookies: AstroCookies | undefined;
createAstro(
Astro: AstroGlobalPartial,
props: Record<string, any>,
Expand Down
5 changes: 5 additions & 0 deletions packages/astro/src/core/app/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -116,6 +117,10 @@ export class App {
}
}

setCookieHeaders(response: Response) {
return getSetCookiesFromResponse(response);
}

async #renderPage(
request: Request,
routeData: RouteData,
Expand Down
202 changes: 202 additions & 0 deletions packages/astro/src/core/cookies/cookies.ts
Original file line number Diff line number Diff line change
@@ -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<string, any>;
number(): number;
boolean(): boolean;
}

interface AstroCookiesInterface {
get(key: string): AstroCookieInterface;
has(key: string): boolean;
set(key: string, value: string | Record<string, any>, 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<string, string> | null;
#outgoing: Map<string, [string, string, boolean]> | 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<string, any>, 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<string, void, unknown> {
if(this.#outgoing == null) return;
for(const [,value] of this.#outgoing) {
yield value[1];
}
}

#ensureParsed(): Record<string, string> {
if(!this.#requestValues) {
this.#parse();
}
if(!this.#requestValues) {
this.#requestValues = {};
}
return this.#requestValues;
}

#ensureOutgoingMap(): Map<string, [string, string, boolean]> {
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
};
9 changes: 9 additions & 0 deletions packages/astro/src/core/cookies/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@

export {
AstroCookies
} from './cookies.js';

export {
attachToResponse,
getSetCookiesFromResponse
} from './response.js';
26 changes: 26 additions & 0 deletions packages/astro/src/core/cookies/response.ts
Original file line number Diff line number Diff line change
@@ -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<string, void, unknown> {
const cookies = getFromResponse(response);
if(!cookies) {
return;
}
for(const headerValue of cookies.headers()) {
yield headerValue;
}
}
18 changes: 15 additions & 3 deletions packages/astro/src/core/endpoint/index.ts
Original file line number Diff line number Diff line change
@@ -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<
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand Down
Loading

0 comments on commit d3091f8

Please sign in to comment.