Skip to content

Commit

Permalink
Astro.cookies implementation
Browse files Browse the repository at this point in the history
  • Loading branch information
matthewp committed Sep 26, 2022
1 parent 9077073 commit 240fee7
Show file tree
Hide file tree
Showing 30 changed files with 882 additions and 20 deletions.
3 changes: 3 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 All @@ -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",
Expand Down Expand Up @@ -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",
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 @@ -1218,6 +1224,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
236 changes: 236 additions & 0 deletions packages/astro/src/core/cookies/cookies.ts
Original file line number Diff line number Diff line change
@@ -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<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() {
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<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;
}
}

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<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);
const context = createAPIContext(opts.request, params);
const response = await renderEndpoint(mod, context);

if (response instanceof Response) {
attachToResponse(response, context.cookies);
return {
type: 'response',
response,
Expand Down
Loading

0 comments on commit 240fee7

Please sign in to comment.