Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Astro.cookies implementation #4876

Merged
merged 5 commits into from
Sep 28, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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>;
delucis marked this conversation as resolved.
Show resolved Hide resolved
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);
delucis marked this conversation as resolved.
Show resolved Hide resolved
}
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]> {
delucis marked this conversation as resolved.
Show resolved Hide resolved
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