diff options
Diffstat (limited to 'packages/astro/src')
-rw-r--r-- | packages/astro/src/@types/astro.ts | 7 | ||||
-rw-r--r-- | packages/astro/src/core/app/index.ts | 5 | ||||
-rw-r--r-- | packages/astro/src/core/cookies/cookies.ts | 202 | ||||
-rw-r--r-- | packages/astro/src/core/cookies/index.ts | 9 | ||||
-rw-r--r-- | packages/astro/src/core/cookies/response.ts | 26 | ||||
-rw-r--r-- | packages/astro/src/core/endpoint/index.ts | 18 | ||||
-rw-r--r-- | packages/astro/src/core/render/core.ts | 11 | ||||
-rw-r--r-- | packages/astro/src/core/render/result.ts | 13 | ||||
-rw-r--r-- | packages/astro/src/runtime/server/endpoint.ts | 13 | ||||
-rw-r--r-- | packages/astro/src/vite-plugin-astro-server/index.ts | 6 |
10 files changed, 295 insertions, 15 deletions
diff --git a/packages/astro/src/@types/astro.ts b/packages/astro/src/@types/astro.ts index c423a1abf..7c19dcca6 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<SSRElement>; scripts: Set<SSRElement>; links: Set<SSRElement>; + cookies: AstroCookies | undefined; createAstro( Astro: AstroGlobalPartial, props: Record<string, any>, diff --git a/packages/astro/src/core/app/index.ts b/packages/astro/src/core/app/index.ts index 413dba20c..ce3422738 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 000000000..7f530ce85 --- /dev/null +++ b/packages/astro/src/core/cookies/cookies.ts @@ -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 +}; diff --git a/packages/astro/src/core/cookies/index.ts b/packages/astro/src/core/cookies/index.ts new file mode 100644 index 000000000..18dc3ebca --- /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 000000000..0e52ac8cb --- /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<string, void, unknown> { + 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 7fee0c428..75e451e6f 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 c9efe02de..7e5fe1f96 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<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; } diff --git a/packages/astro/src/core/render/result.ts b/packages/astro/src/core/render/result.ts index d4704ca1f..c0e650a8f 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<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, diff --git a/packages/astro/src/runtime/server/endpoint.ts b/packages/astro/src/runtime/server/endpoint.ts index 31a0069db..a93d02adb 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 72f2ce9ba..e976280c8 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) { |