Astro.cookies implementation (#4876)

* Astro.cookies implementation

* Remove unused var

* Fix build

* Add a changesetp

* Remove spoken-word expires
This commit is contained in:
Matthew Phillips 2022-09-28 16:55:27 -04:00 committed by GitHub
parent ec55745ae5
commit d3091f89e9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
32 changed files with 943 additions and 29 deletions

View file

@ -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.

View file

@ -114,6 +114,7 @@
"boxen": "^6.2.1",
"ci-info": "^3.3.1",
"common-ancestor-path": "^1.0.1",
"cookie": "^0.5.0",
"debug": "^4.3.4",
"diff": "^5.1.0",
"eol": "^0.9.1",
@ -161,6 +162,7 @@
"@types/chai": "^4.3.1",
"@types/common-ancestor-path": "^1.0.0",
"@types/connect": "^3.4.35",
"@types/cookie": "^0.5.1",
"@types/debug": "^4.1.7",
"@types/diff": "^5.0.2",
"@types/estree": "^0.0.51",

View file

@ -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<SSRElement>;
scripts: Set<SSRElement>;
links: Set<SSRElement>;
cookies: AstroCookies | undefined;
createAstro(
Astro: AstroGlobalPartial,
props: Record<string, any>,

View file

@ -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,

View file

@ -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
};

View file

@ -0,0 +1,9 @@
export {
AstroCookies
} from './cookies.js';
export {
attachToResponse,
getSetCookiesFromResponse
} from './response.js';

View file

@ -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;
}
}

View file

@ -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,

View file

@ -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<Response> {
});
}
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;
}

View file

@ -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<SSRElement>(),
scripts: args.scripts ?? new Set<SSRElement>(),
links: args.links ?? new Set<SSRElement>(),
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,

View file

@ -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) {

View file

@ -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) {

View file

@ -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');
});
})
});

View file

@ -0,0 +1,8 @@
{
"name": "@test/astro-cookies",
"version": "0.0.0",
"private": true,
"dependencies": {
"astro": "workspace:*"
}
}

View file

@ -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'
}
})
---

View file

@ -0,0 +1,17 @@
---
const cookie = Astro.cookies.get('prefs');
const prefs = cookie.json();
---
<html>
<head>
<title>Testing</title>
</head>
<body>
<h1>Testing</h1>
<h2>Preferences</h2>
<dl>
<dt>Dark/light mode</dt>
<dd>{ prefs.mode }</dd>
</dl>
</body>
</html>

View file

@ -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'
}
});
}

View file

@ -0,0 +1,15 @@
---
if(Astro.request.method === 'POST') {
Astro.cookies.set('admin', 'true', {
expires: new Date()
});
}
---
<html>
<head>
<title>Testing</title>
</head>
<body>
<h1>Testing</h1>
</body>
</html>

View file

@ -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\//);
});
});
});

View file

@ -0,0 +1,136 @@
import { expect } from 'chai';
import { AstroCookies } from '../../../dist/core/cookies/index.js';
import { apply as applyPolyfill } from '../../../dist/core/polyfill.js';
applyPolyfill();
describe('astro/src/core/cookies', () => {
describe('Astro.cookies.get', () => {
it('gets the cookie value', () => {
const req = new Request('http://example.com/', {
headers: {
'cookie': 'foo=bar'
}
});
const cookies = new AstroCookies(req);
expect(cookies.get('foo').value).to.equal('bar');
});
describe('.json()', () => {
it('returns a JavaScript object', () => {
const req = new Request('http://example.com/', {
headers: {
'cookie': 'foo=%7B%22key%22%3A%22value%22%7D'
}
});
let cookies = new AstroCookies(req);
const json = cookies.get('foo').json();
expect(json).to.be.an('object');
expect(json.key).to.equal('value');
});
it('throws if the value is undefined', () => {
const req = new Request('http://example.com/');
let cookies = new AstroCookies(req);
let cookie = cookies.get('foo');
expect(() => cookie.json()).to.throw('Cannot convert undefined to an object.');
});
});
describe('.number()', () => {
it('Coerces into a number', () => {
const req = new Request('http://example.com/', {
headers: {
'cookie': 'foo=22'
}
});
let cookies = new AstroCookies(req);
const value = cookies.get('foo').number();
expect(value).to.be.an('number');
expect(value).to.equal(22);
});
it('Coerces non-number into NaN', () => {
const req = new Request('http://example.com/', {
headers: {
'cookie': 'foo=bar'
}
});
let cookies = new AstroCookies(req);
const value = cookies.get('foo').number();
expect(value).to.be.an('number');
expect(Number.isNaN(value)).to.equal(true);
});
});
describe('.boolean()', () => {
it('Coerces true into `true`', () => {
const req = new Request('http://example.com/', {
headers: {
'cookie': 'foo=true'
}
});
let cookies = new AstroCookies(req);
const value = cookies.get('foo').boolean();
expect(value).to.be.an('boolean');
expect(value).to.equal(true);
});
it('Coerces false into `false`', () => {
const req = new Request('http://example.com/', {
headers: {
'cookie': 'foo=false'
}
});
let cookies = new AstroCookies(req);
const value = cookies.get('foo').boolean();
expect(value).to.be.an('boolean');
expect(value).to.equal(false);
});
it('Coerces 1 into `true`', () => {
const req = new Request('http://example.com/', {
headers: {
'cookie': 'foo=1'
}
});
let cookies = new AstroCookies(req);
const value = cookies.get('foo').boolean();
expect(value).to.be.an('boolean');
expect(value).to.equal(true);
});
it('Coerces 0 into `false`', () => {
const req = new Request('http://example.com/', {
headers: {
'cookie': 'foo=0'
}
});
let cookies = new AstroCookies(req);
const value = cookies.get('foo').boolean();
expect(value).to.be.an('boolean');
expect(value).to.equal(false);
});
it('Coerces truthy strings into `true`', () => {
const req = new Request('http://example.com/', {
headers: {
'cookie': 'foo=bar'
}
});
let cookies = new AstroCookies(req);
const value = cookies.get('foo').boolean();
expect(value).to.be.an('boolean');
expect(value).to.equal(true);
});
});
});
});

View file

@ -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);
});
});
});

View file

@ -0,0 +1,82 @@
import { expect } from 'chai';
import { AstroCookies } from '../../../dist/core/cookies/index.js';
import { apply as applyPolyfill } from '../../../dist/core/polyfill.js';
applyPolyfill();
describe('astro/src/core/cookies', () => {
describe('Astro.cookies.set', () => {
it('Sets a cookie value that can be serialized', () => {
let req = new Request('http://example.com/');
let cookies = new AstroCookies(req);
cookies.set('foo', 'bar');
let headers = Array.from(cookies.headers());
expect(headers).to.have.a.lengthOf(1);
expect(headers[0]).to.equal('foo=bar');
});
it('Can set cookie options', () => {
let req = new Request('http://example.com/');
let cookies = new AstroCookies(req);
cookies.set('foo', 'bar', {
httpOnly: true,
path: '/subpath/'
});
let headers = Array.from(cookies.headers());
expect(headers).to.have.a.lengthOf(1);
expect(headers[0]).to.equal('foo=bar; Path=/subpath/; HttpOnly');
});
it('Can pass a JavaScript object that will be serialized', () => {
let req = new Request('http://example.com/');
let cookies = new AstroCookies(req);
cookies.set('options', { one: 'two', three: 4 });
let headers = Array.from(cookies.headers());
expect(headers).to.have.a.lengthOf(1);
expect(JSON.parse(decodeURIComponent(headers[0].slice(8))).one).to.equal('two');
});
it('Can pass a number', () => {
let req = new Request('http://example.com/');
let cookies = new AstroCookies(req);
cookies.set('one', 2);
let headers = Array.from(cookies.headers());
expect(headers).to.have.a.lengthOf(1);
expect(headers[0]).to.equal('one=2');
});
it('Can get the value after setting', () => {
let req = new Request('http://example.com/');
let cookies = new AstroCookies(req);
cookies.set('foo', 'bar');
let r = cookies.get('foo');
expect(r.value).to.equal('bar');
});
it('Can get the JavaScript object after setting', () => {
let req = new Request('http://example.com/');
let cookies = new AstroCookies(req);
cookies.set('options', { one: 'two', three: 4 });
let cook = cookies.get('options');
let value = cook.json();
expect(value).to.be.an('object');
expect(value.one).to.equal('two');
expect(value.three).to.be.a('number');
expect(value.three).to.equal(4);
});
it('Overrides a value in the request', () => {
let req = new Request('http://example.com/', {
headers: {
'cookie': 'foo=bar'
}
});
let cookies = new AstroCookies(req);
expect(cookies.get('foo').value).to.equal('bar');
// Set a new value
cookies.set('foo', 'baz');
expect(cookies.get('foo').value).to.equal('baz');
});
});
});

View file

@ -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, {

View file

@ -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, {

View file

@ -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 {

View file

@ -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, {

View file

@ -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;
};

View file

@ -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<string> = 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) {

View file

@ -15,7 +15,13 @@ export function createExports(manifest: SSRManifest) {
const handler = async (request: Request): Promise<Response> => {
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, {

View file

@ -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 };

View file

@ -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<Re
return request;
}
export async function setResponse(res: ServerResponse, response: Response): Promise<void> {
export async function setResponse(app: App, res: ServerResponse, response: Response): Promise<void> {
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<string> = Array.from(app.setCookieHeaders(response));
if(setCookieHeaders.length) {
res.setHeader('Set-Cookie', setCookieHeaders);
}
}
res.writeHead(response.status, headers);
if (response.body instanceof Readable) {

View file

@ -369,6 +369,7 @@ importers:
'@types/chai': ^4.3.1
'@types/common-ancestor-path': ^1.0.0
'@types/connect': ^3.4.35
'@types/cookie': ^0.5.1
'@types/debug': ^4.1.7
'@types/diff': ^5.0.2
'@types/estree': ^0.0.51
@ -392,6 +393,7 @@ importers:
cheerio: ^1.0.0-rc.11
ci-info: ^3.3.1
common-ancestor-path: ^1.0.1
cookie: ^0.5.0
debug: ^4.3.4
diff: ^5.1.0
eol: ^0.9.1
@ -460,6 +462,7 @@ importers:
boxen: 6.2.1
ci-info: 3.4.0
common-ancestor-path: 1.0.1
cookie: 0.5.0
debug: 4.3.4
diff: 5.1.0
eol: 0.9.1
@ -506,6 +509,7 @@ importers:
'@types/chai': 4.3.3
'@types/common-ancestor-path': 1.0.0
'@types/connect': 3.4.35
'@types/cookie': 0.5.1
'@types/debug': 4.1.7
'@types/diff': 5.0.2
'@types/estree': 0.0.51
@ -1200,6 +1204,12 @@ importers:
dependencies:
astro: link:../../..
packages/astro/test/fixtures/astro-cookies:
specifiers:
astro: workspace:*
dependencies:
astro: link:../../..
packages/astro/test/fixtures/astro-css-bundling:
specifiers:
astro: workspace:*
@ -4997,7 +5007,7 @@ packages:
babel-plugin-polyfill-corejs2: 0.3.3_@babel+core@7.19.1
babel-plugin-polyfill-corejs3: 0.6.0_@babel+core@7.19.1
babel-plugin-polyfill-regenerator: 0.4.1_@babel+core@7.19.1
core-js-compat: 3.25.2
core-js-compat: 3.25.3
semver: 6.3.0
transitivePeerDependencies:
- supports-color
@ -9286,6 +9296,10 @@ packages:
'@types/node': 18.7.23
dev: true
/@types/cookie/0.5.1:
resolution: {integrity: sha512-COUnqfB2+ckwXXSFInsFdOAWQzCCx+a5hq2ruyj+Vjund94RJQd4LG2u9hnvJrTgunKAaax7ancBYlDrNYxA0g==}
dev: true
/@types/debug/4.1.7:
resolution: {integrity: sha512-9AonUzyTjXXhEOa0DnqpzZi6VHlqKMswga9EXjpXnnqxwLtdvPPtlO8evrI5D9S6asFRCQ6v+wpiUKbw+vKqyg==}
dependencies:
@ -10311,7 +10325,7 @@ packages:
dependencies:
'@babel/core': 7.19.1
'@babel/helper-define-polyfill-provider': 0.3.3_@babel+core@7.19.1
core-js-compat: 3.25.2
core-js-compat: 3.25.3
transitivePeerDependencies:
- supports-color
dev: false
@ -10823,8 +10837,13 @@ packages:
engines: {node: '>= 0.6'}
dev: true
/core-js-compat/3.25.2:
resolution: {integrity: sha512-TxfyECD4smdn3/CjWxczVtJqVLEEC2up7/82t7vC0AzNogr+4nQ8vyF7abxAuTXWvjTClSbvGhU0RgqA4ToQaQ==}
/cookie/0.5.0:
resolution: {integrity: sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==}
engines: {node: '>= 0.6'}
dev: false
/core-js-compat/3.25.3:
resolution: {integrity: sha512-xVtYpJQ5grszDHEUU9O7XbjjcZ0ccX3LgQsyqSvTnjX97ZqEgn9F5srmrwwwMtbKzDllyFPL+O+2OFMl1lU4TQ==}
dependencies:
browserslist: 4.21.4
dev: false