From d96c507c6f4e769b399b56fa37732cd6491458c1 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Thu, 21 Nov 2024 17:25:47 +0000 Subject: [PATCH] feat: prototype session support (#12471) * feat: add session object * Add tests and fix logic * Fixes * Allow string as cookie option * wip: implement sessions (#12478) * feat: implement sessions * Add middleware * Action middleware test * Support URLs * Remove comment * Changes from review * Update test * Ensure test file is run --- packages/astro/src/core/app/index.ts | 9 + packages/astro/src/core/app/types.ts | 3 +- .../src/core/build/plugins/plugin-manifest.ts | 1 + packages/astro/src/core/config/schema.ts | 28 +- packages/astro/src/core/errors/errors-data.ts | 30 ++ packages/astro/src/core/render-context.ts | 10 +- packages/astro/src/core/session.ts | 417 ++++++++++++++++++ packages/astro/src/types/public/config.ts | 91 ++-- packages/astro/src/types/public/context.ts | 5 + .../src/vite-plugin-astro-server/plugin.ts | 1 + .../src/vite-plugin-astro-server/route.ts | 3 + .../test/fixtures/sessions/astro.config.mjs | 18 + .../astro/test/fixtures/sessions/package.json | 8 + .../fixtures/sessions/src/actions/index.ts | 27 ++ .../test/fixtures/sessions/src/middleware.ts | 49 ++ .../test/fixtures/sessions/src/pages/api.ts | 15 + .../fixtures/sessions/src/pages/cart.astro | 24 + .../fixtures/sessions/src/pages/destroy.ts | 6 + .../fixtures/sessions/src/pages/index.astro | 13 + .../fixtures/sessions/src/pages/regenerate.ts | 6 + .../test/fixtures/sessions/tsconfig.json | 11 + .../test/units/sessions/astro-session.test.js | 412 +++++++++++++++++ pnpm-lock.yaml | 6 + 23 files changed, 1138 insertions(+), 55 deletions(-) create mode 100644 packages/astro/src/core/session.ts create mode 100644 packages/astro/test/fixtures/sessions/astro.config.mjs create mode 100644 packages/astro/test/fixtures/sessions/package.json create mode 100644 packages/astro/test/fixtures/sessions/src/actions/index.ts create mode 100644 packages/astro/test/fixtures/sessions/src/middleware.ts create mode 100644 packages/astro/test/fixtures/sessions/src/pages/api.ts create mode 100644 packages/astro/test/fixtures/sessions/src/pages/cart.astro create mode 100644 packages/astro/test/fixtures/sessions/src/pages/destroy.ts create mode 100644 packages/astro/test/fixtures/sessions/src/pages/index.astro create mode 100644 packages/astro/test/fixtures/sessions/src/pages/regenerate.ts create mode 100644 packages/astro/test/fixtures/sessions/tsconfig.json create mode 100644 packages/astro/test/units/sessions/astro-session.test.js diff --git a/packages/astro/src/core/app/index.ts b/packages/astro/src/core/app/index.ts index 09b3edea1269..67944d7a6a26 100644 --- a/packages/astro/src/core/app/index.ts +++ b/packages/astro/src/core/app/index.ts @@ -23,6 +23,7 @@ import { RenderContext } from '../render-context.js'; import { createAssetLink } from '../render/ssr-element.js'; import { createDefaultRoutes, injectDefaultRoutes } from '../routing/default.js'; import { matchRoute } from '../routing/match.js'; +import { type AstroSession, PERSIST_SYMBOL } from '../session.js'; import { AppPipeline } from './pipeline.js'; export { deserializeManifest } from './common.js'; @@ -277,6 +278,7 @@ export class App { const defaultStatus = this.#getDefaultStatusCode(routeData, pathname); let response; + let session: AstroSession | undefined; try { // Load route module. We also catch its error here if it fails on initialization const mod = await this.#pipeline.getModuleForRoute(routeData); @@ -289,10 +291,13 @@ export class App { routeData, status: defaultStatus, }); + session = renderContext.session; response = await renderContext.render(await mod.page()); } catch (err: any) { this.#logger.error(null, err.stack || err.message || String(err)); return this.#renderError(request, { locals, status: 500, error: err }); + } finally { + session?.[PERSIST_SYMBOL](); } if ( @@ -376,6 +381,7 @@ export class App { } } const mod = await this.#pipeline.getModuleForRoute(errorRouteData); + let session: AstroSession | undefined; try { const renderContext = await RenderContext.create({ locals, @@ -387,6 +393,7 @@ export class App { status, props: { error }, }); + session = renderContext.session; const response = await renderContext.render(await mod.page()); return this.#mergeResponses(response, originalResponse); } catch { @@ -399,6 +406,8 @@ export class App { skipMiddleware: true, }); } + } finally { + session?.[PERSIST_SYMBOL](); } } diff --git a/packages/astro/src/core/app/types.ts b/packages/astro/src/core/app/types.ts index 0fb627f718a1..16aaa54117bd 100644 --- a/packages/astro/src/core/app/types.ts +++ b/packages/astro/src/core/app/types.ts @@ -1,7 +1,7 @@ import type { RoutingStrategies } from '../../i18n/utils.js'; import type { ComponentInstance, SerializedRouteData } from '../../types/astro.js'; import type { AstroMiddlewareInstance } from '../../types/public/common.js'; -import type { Locales } from '../../types/public/config.js'; +import type { Locales, SessionConfig } from '../../types/public/config.js'; import type { RouteData, SSRComponentMetadata, @@ -70,6 +70,7 @@ export type SSRManifest = { middleware?: () => Promise | AstroMiddlewareInstance; checkOrigin: boolean; envGetSecretEnabled: boolean; + sessionConfig?: SessionConfig; }; export type SSRManifestI18n = { diff --git a/packages/astro/src/core/build/plugins/plugin-manifest.ts b/packages/astro/src/core/build/plugins/plugin-manifest.ts index 12fdf65b17bb..07ac03065d9e 100644 --- a/packages/astro/src/core/build/plugins/plugin-manifest.ts +++ b/packages/astro/src/core/build/plugins/plugin-manifest.ts @@ -277,5 +277,6 @@ function buildManifest( envGetSecretEnabled: (unwrapSupportKind(settings.adapter?.supportedAstroFeatures.envGetSecret) ?? 'unsupported') !== 'unsupported', + sessionConfig: settings.config.experimental.session, }; } diff --git a/packages/astro/src/core/config/schema.ts b/packages/astro/src/core/config/schema.ts index 5e309d90cdef..a3099fc8c733 100644 --- a/packages/astro/src/core/config/schema.ts +++ b/packages/astro/src/core/config/schema.ts @@ -541,17 +541,23 @@ export const AstroConfigSchema = z.object({ .object({ driver: z.string(), options: z.record(z.any()).optional(), - cookieName: z.string().optional(), - cookieOptions: z - .object({ - domain: z.string().optional(), - path: z.string().optional(), - expires: z.string().optional(), - maxAge: z.number().optional(), - httpOnly: z.boolean().optional(), - sameSite: z.string().optional(), - secure: z.boolean().optional(), - encode: z.string().optional(), + cookie: z + .union([ + z.object({ + name: z.string().optional(), + domain: z.string().optional(), + path: z.string().optional(), + maxAge: z.number().optional(), + sameSite: z.union([z.enum(['strict', 'lax', 'none']), z.boolean()]).optional(), + secure: z.boolean().optional(), + }), + z.string(), + ]) + .transform((val) => { + if (typeof val === 'string') { + return { name: val }; + } + return val; }) .optional(), }) diff --git a/packages/astro/src/core/errors/errors-data.ts b/packages/astro/src/core/errors/errors-data.ts index 7c5479a665f5..246694540242 100644 --- a/packages/astro/src/core/errors/errors-data.ts +++ b/packages/astro/src/core/errors/errors-data.ts @@ -868,6 +868,36 @@ export const AstroResponseHeadersReassigned = { hint: 'Consider using `Astro.response.headers.add()`, and `Astro.response.headers.delete()`.', } satisfies ErrorData; +/** + * @docs + * @see + * - [experimental.session](https://5-0-0-beta.docs.astro.build/en/reference/configuration-reference/#experimentalsession) + * @description + * Thrown when the session storage could not be initialized. + */ +export const SessionStorageInitError = { + name: 'SessionStorageInitError', + title: 'Session storage could not be initialized.', + message: (error: string, driver?: string) => + `Error when initializing session storage${driver ? ` with driver ${driver}` : ''}. ${error ?? ''}`, + hint: 'For more information, see https://5-0-0-beta.docs.astro.build/en/reference/configuration-reference/#experimentalsession', +} satisfies ErrorData; + +/** + * @docs + * @see + * - [experimental.session](https://5-0-0-beta.docs.astro.build/en/reference/configuration-reference/#experimentalsession) + * @description + * Thrown when the session data could not be saved. + */ +export const SessionStorageSaveError = { + name: 'SessionStorageSaveError', + title: 'Session data could not be saved.', + message: (error: string, driver?: string) => + `Error when saving session data${driver ? ` with driver ${driver}` : ''}. ${error ?? ''}`, + hint: 'For more information, see https://5-0-0-beta.docs.astro.build/en/reference/configuration-reference/#experimentalsession', +} satisfies ErrorData; + /** * @docs * @description diff --git a/packages/astro/src/core/render-context.ts b/packages/astro/src/core/render-context.ts index 691613412e91..33cb241d30eb 100644 --- a/packages/astro/src/core/render-context.ts +++ b/packages/astro/src/core/render-context.ts @@ -31,6 +31,7 @@ import { renderRedirect } from './redirects/render.js'; import { type Pipeline, Slots, getParams, getProps } from './render/index.js'; import { copyRequest, getOriginPathname, setOriginPathname } from './routing/rewrite.js'; import { SERVER_ISLAND_COMPONENT } from './server-islands/endpoint.js'; +import { AstroSession } from './session.js'; export const apiContextRoutesSymbol = Symbol.for('context.routes'); @@ -52,6 +53,9 @@ export class RenderContext { protected url = new URL(request.url), public props: Props = {}, public partial: undefined | boolean = undefined, + public session: AstroSession | undefined = pipeline.manifest.sessionConfig + ? new AstroSession(cookies, pipeline.manifest.sessionConfig) + : undefined, ) {} /** @@ -296,7 +300,7 @@ export class RenderContext { createActionAPIContext(): ActionAPIContext { const renderContext = this; - const { cookies, params, pipeline, url } = this; + const { cookies, params, pipeline, url, session } = this; const generator = `Astro v${ASTRO_VERSION}`; const rewrite = async (reroutePayload: RewritePayload) => { @@ -334,6 +338,7 @@ export class RenderContext { get originPathname() { return getOriginPathname(renderContext.request); }, + session, }; } @@ -466,7 +471,7 @@ export class RenderContext { astroStaticPartial: AstroGlobalPartial, ): Omit { const renderContext = this; - const { cookies, locals, params, pipeline, url } = this; + const { cookies, locals, params, pipeline, url, session } = this; const { response } = result; const redirect = (path: string, status = 302) => { // If the response is already sent, error as we cannot proceed with the redirect. @@ -488,6 +493,7 @@ export class RenderContext { routePattern: this.routeData.route, isPrerendered: this.routeData.prerender, cookies, + session, get clientAddress() { return renderContext.clientAddress(); }, diff --git a/packages/astro/src/core/session.ts b/packages/astro/src/core/session.ts new file mode 100644 index 000000000000..23cdf9b4f745 --- /dev/null +++ b/packages/astro/src/core/session.ts @@ -0,0 +1,417 @@ +import { stringify, unflatten } from 'devalue'; +import { type Driver, type Storage, builtinDrivers, createStorage } from 'unstorage'; +import type { SessionConfig, SessionDriverName } from '../types/public/config.js'; +import type { AstroCookies } from './cookies/cookies.js'; +import type { AstroCookieSetOptions } from './cookies/cookies.js'; +import { SessionStorageInitError, SessionStorageSaveError } from './errors/errors-data.js'; +import { AstroError } from './errors/index.js'; + +export const PERSIST_SYMBOL = Symbol(); + +const DEFAULT_COOKIE_NAME = 'astro-session'; +const VALID_COOKIE_REGEX = /^[\w-]+$/; + +export class AstroSession { + // The cookies object. + #cookies: AstroCookies; + // The session configuration. + #config: Omit, 'cookie'>; + // The cookie config + #cookieConfig?: AstroCookieSetOptions; + // The cookie name + #cookieName: string; + // The unstorage object for the session driver. + #storage: Storage | undefined; + #data: Map | undefined; + // The session ID. A v4 UUID. + #sessionID: string | undefined; + // Sessions to destroy. Needed because we won't have the old session ID after it's destroyed locally. + #toDestroy = new Set(); + // Session keys to delete. Used for sparse data sets to avoid overwriting the deleted value. + #toDelete = new Set(); + // Whether the session is dirty and needs to be saved. + #dirty = false; + // Whether the session cookie has been set. + #cookieSet = false; + // Whether the session data is sparse and needs to be merged with the existing data. + #sparse = true; + + constructor( + cookies: AstroCookies, + { + cookie: cookieConfig = DEFAULT_COOKIE_NAME, + ...config + }: Exclude, undefined>, + ) { + this.#cookies = cookies; + if (typeof cookieConfig === 'object') { + this.#cookieConfig = cookieConfig; + this.#cookieName = cookieConfig.name || DEFAULT_COOKIE_NAME; + } else { + this.#cookieName = cookieConfig || DEFAULT_COOKIE_NAME; + } + this.#config = config; + } + + /** + * Gets a session value. Returns `undefined` if the session or value does not exist. + */ + async get(key: string): Promise { + return (await this.#ensureData()).get(key); + } + + /** + * Checks if a session value exists. + */ + async has(key: string): Promise { + return (await this.#ensureData()).has(key); + } + + /** + * Gets all session values. + */ + async keys() { + return (await this.#ensureData()).keys(); + } + + /** + * Gets all session values. + */ + async values() { + return (await this.#ensureData()).values(); + } + + /** + * Gets all session entries. + */ + async entries() { + return (await this.#ensureData()).entries(); + } + + /** + * Deletes a session value. + */ + delete(key: string) { + this.#data?.delete(key); + if (this.#sparse) { + this.#toDelete.add(key); + } + this.#dirty = true; + } + + /** + * Sets a session value. The session is created if it does not exist. + */ + + set(key: string, value: T) { + if (!key) { + throw new AstroError({ + ...SessionStorageSaveError, + message: 'The session key was not provided.', + }); + } + try { + // Attempt to serialize the value so we can throw an error early if needed + stringify(value); + } catch (err) { + throw new AstroError( + { + ...SessionStorageSaveError, + message: `The session data for ${key} could not be serialized.`, + hint: 'See the devalue library for all supported types: https://github.com/rich-harris/devalue', + }, + { cause: err }, + ); + } + if (!this.#cookieSet) { + this.#setCookie(); + this.#cookieSet = true; + } + this.#data ??= new Map(); + this.#data.set(key, value); + this.#dirty = true; + } + + /** + * Destroys the session, clearing the cookie and storage if it exists. + */ + + destroy() { + this.#destroySafe(); + } + + /** + * Regenerates the session, creating a new session ID. The existing session data is preserved. + */ + + async regenerate() { + let data = new Map(); + try { + data = await this.#ensureData(); + } catch (err) { + // Log the error but continue with empty data + console.error('Failed to load session data during regeneration:', err); + } + + // Store the old session ID for cleanup + const oldSessionId = this.#sessionID; + + // Create new session + this.#sessionID = undefined; + this.#data = data; + this.#ensureSessionID(); + await this.#setCookie(); + + // Clean up old session asynchronously + if (oldSessionId && this.#storage) { + this.#storage.removeItem(oldSessionId).catch((err) => { + console.error('Failed to remove old session data:', err); + }); + } + } + + // Persists the session data to storage. + // This is called automatically at the end of the request. + // Uses a symbol to prevent users from calling it directly. + async [PERSIST_SYMBOL]() { + // Handle session data persistence + + if (!this.#dirty && !this.#toDestroy.size) { + return; + } + + const storage = await this.#ensureStorage(); + + if (this.#dirty && this.#data) { + const data = await this.#ensureData(); + const key = this.#ensureSessionID(); + let serialized; + try { + serialized = stringify(data, { + // Support URL objects + URL: (val) => val instanceof URL && val.href, + }); + } catch (err) { + throw new AstroError( + { + ...SessionStorageSaveError, + message: SessionStorageSaveError.message( + 'The session data could not be serialized.', + this.#config.driver, + ), + }, + { cause: err }, + ); + } + await storage.setItem(key, serialized); + this.#dirty = false; + } + + // Handle destroyed session cleanup + if (this.#toDestroy.size > 0) { + const cleanupPromises = [...this.#toDestroy].map((sessionId) => + storage.removeItem(sessionId).catch((err) => { + console.error(`Failed to clean up session ${sessionId}:`, err); + }), + ); + await Promise.all(cleanupPromises); + this.#toDestroy.clear(); + } + } + + get sessionID() { + return this.#sessionID; + } + + /** + * Sets the session cookie. + */ + async #setCookie() { + if (!VALID_COOKIE_REGEX.test(this.#cookieName)) { + throw new AstroError({ + ...SessionStorageSaveError, + message: 'Invalid cookie name. Cookie names can only contain letters, numbers, and dashes.', + }); + } + const cookieOptions: AstroCookieSetOptions = { + sameSite: 'lax', + secure: true, + path: '/', + ...this.#cookieConfig, + httpOnly: true, + }; + const value = this.#ensureSessionID(); + this.#cookies.set(this.#cookieName, value, cookieOptions); + } + + /** + * Attempts to load the session data from storage, or creates a new data object if none exists. + * If there is existing sparse data, it will be merged into the new data object. + */ + + async #ensureData() { + const storage = await this.#ensureStorage(); + if (this.#data && !this.#sparse) { + return this.#data; + } + this.#data ??= new Map(); + + // We stored this as a devalue string, but unstorage will have parsed it as JSON + const raw = await storage.get(this.#ensureSessionID()); + if (!raw) { + // If there is no existing data in storage we don't need to merge anything + // and can just return the existing local data. + return this.#data; + } + + try { + const storedMap = unflatten(raw, { + // Revive URL objects + URL: (href) => new URL(href), + }); + if (!(storedMap instanceof Map)) { + await this.#destroySafe(); + throw new AstroError({ + ...SessionStorageInitError, + message: SessionStorageInitError.message( + 'The session data was an invalid type.', + this.#config.driver, + ), + }); + } + // The local data is "sparse" if it has not been loaded from storage yet. This means + // it only contains values that have been set or deleted in-memory locally. + // We do this to avoid the need to block on loading data when it is only being set. + // When we load the data from storage, we need to merge it with the local sparse data, + // preserving in-memory changes and deletions. + + if (this.#sparse) { + // For sparse updates, only copy values from storage that: + // 1. Don't exist in memory (preserving in-memory changes) + // 2. Haven't been marked for deletion + for (const [key, value] of storedMap) { + if (!this.#data.has(key) && !this.#toDelete.has(key)) { + this.#data.set(key, value); + } + } + } else { + this.#data = storedMap; + } + + this.#sparse = false; + return this.#data; + } catch (err) { + await this.#destroySafe(); + if (err instanceof AstroError) { + throw err; + } + throw new AstroError( + { + ...SessionStorageInitError, + message: SessionStorageInitError.message( + 'The session data could not be parsed.', + this.#config.driver, + ), + }, + { cause: err }, + ); + } + } + /** + * Safely destroys the session, clearing the cookie and storage if it exists. + */ + #destroySafe() { + if (this.#sessionID) { + this.#toDestroy.add(this.#sessionID); + } + if (this.#cookieName) { + this.#cookies.delete(this.#cookieName); + } + this.#sessionID = undefined; + this.#data = undefined; + this.#dirty = true; + } + + /** + * Returns the session ID, generating a new one if it does not exist. + */ + #ensureSessionID() { + this.#sessionID ??= this.#cookies.get(this.#cookieName)?.value ?? crypto.randomUUID(); + return this.#sessionID; + } + + /** + * Ensures the storage is initialized. + * This is called automatically when a storage operation is needed. + */ + async #ensureStorage():Promise { + if (this.#storage) { + return this.#storage; + } + + if (this.#config.driver === 'test') { + this.#storage = (this.#config as SessionConfig<'test'>).options.mockStorage; + return this.#storage; + } + if (!this.#config?.driver) { + throw new AstroError({ + ...SessionStorageInitError, + message: SessionStorageInitError.message( + 'No driver was defined in the session configuration and the adapter did not provide a default driver.', + ), + }); + } + + let driver: ((config: SessionConfig['options']) => Driver) | null = null; + const entry = + builtinDrivers[this.#config.driver as keyof typeof builtinDrivers] || this.#config.driver; + try { + // Try to load the driver from the built-in unstorage drivers. + // Otherwise, assume it's a custom driver and load by name. + + driver = await import(entry).then((r) => r.default || r); + } catch (err: any) { + // If the driver failed to load, throw an error. + if (err.code === 'ERR_MODULE_NOT_FOUND') { + throw new AstroError( + { + ...SessionStorageInitError, + message: SessionStorageInitError.message( + err.message.includes(`Cannot find package '${entry}'`) + ? 'The driver module could not be found.' + : err.message, + this.#config.driver, + ), + }, + { cause: err }, + ); + } + throw err; + } + + if (!driver) { + throw new AstroError({ + ...SessionStorageInitError, + message: SessionStorageInitError.message( + 'The module did not export a driver.', + this.#config.driver, + ), + }); + } + + try { + this.#storage = createStorage({ + driver: driver(this.#config.options), + }); + return this.#storage; + } catch (err) { + throw new AstroError( + { + ...SessionStorageInitError, + message: SessionStorageInitError.message('Unknown error', this.#config.driver), + }, + { cause: err }, + ); + } + } +} diff --git a/packages/astro/src/types/public/config.ts b/packages/astro/src/types/public/config.ts index 48760e28a60b..3a59579d8bb5 100644 --- a/packages/astro/src/types/public/config.ts +++ b/packages/astro/src/types/public/config.ts @@ -5,7 +5,7 @@ import type { RemarkRehype, ShikiConfig, } from '@astrojs/markdown-remark'; -import type { BuiltinDriverName, BuiltinDriverOptions } from 'unstorage'; +import type { BuiltinDriverName, BuiltinDriverOptions, Storage } from 'unstorage'; import type { UserConfig as OriginalViteUserConfig, SSROptions as ViteSSROptions } from 'vite'; import type { ImageFit, ImageLayout } from '../../assets/types.js'; import type { RemotePattern } from '../../assets/utils/remotePattern.js'; @@ -97,18 +97,16 @@ export type ServerConfig = { open?: string | boolean; }; -export type SessionDriverName = BuiltinDriverName | 'custom'; +export type SessionDriverName = BuiltinDriverName | 'custom' | 'test'; interface CommonSessionConfig { /** - * The name of the session cookie - * @default `astro-session` + * Configures the session cookie. If set to a string, it will be used as the cookie name. + * Alternatively, you can pass an object with additional options. */ - cookieName?: string; - /** - * Additional options to pass to the session cookie - */ - cookieOptions?: AstroCookieSetOptions; + cookie?: + | string + | (Omit & { name?: string }); } interface BuiltinSessionConfig @@ -123,8 +121,19 @@ interface CustomSessionConfig extends CommonSessionConfig { options?: Record; } +interface TestSessionConfig extends CommonSessionConfig { + driver: 'test'; + options: { + mockStorage: Storage; + }; +} + export type SessionConfig = - TDriver extends keyof BuiltinDriverOptions ? BuiltinSessionConfig : CustomSessionConfig; + TDriver extends keyof BuiltinDriverOptions + ? BuiltinSessionConfig + : TDriver extends 'test' + ? TestSessionConfig + : CustomSessionConfig; export interface ViteUserConfig extends OriginalViteUserConfig { ssr?: ViteSSROptions; @@ -1163,11 +1172,11 @@ export interface ViteUserConfig extends OriginalViteUserConfig { * @name markdown.shikiConfig * @typeraw {Partial} * @description - * - * Shiki is our default syntax highlighter. You can configure all options via the `markdown.shikiConfig` object: - * - * ```js title="astro.config.mjs" - * import { defineConfig } from 'astro/config'; + * + * Shiki is our default syntax highlighter. You can configure all options via the `markdown.shikiConfig` object: + * + * ```js title="astro.config.mjs" + * import { defineConfig } from 'astro/config'; * * export default defineConfig({ * markdown: { @@ -1179,7 +1188,7 @@ export interface ViteUserConfig extends OriginalViteUserConfig { * // See note below for using dual light/dark themes * themes: { * light: 'github-light', - * dark: 'github-dark', + * dark: 'github-dark', * }, * // Disable the default colors * // https://shiki.style/guide/dual-themes#without-default-color @@ -1803,7 +1812,7 @@ export interface ViteUserConfig extends OriginalViteUserConfig { * @name experimental.contentIntellisense * @type {boolean} * @default `false` - * @version 5.x + * @version 5.x * @description * * Enables Intellisense features (e.g. code completion, quick hints) for your content collection entries in compatible editors. @@ -1947,9 +1956,9 @@ export interface ViteUserConfig extends OriginalViteUserConfig { * @type {SessionConfig} * @version 5.0.0 * @description - * + * * Enables support for sessions in Astro. Sessions are used to store user data across requests, such as user authentication state. - * + * * When enabled you can access the `Astro.session` object to read and write data that persists across requests. You can configure the session driver using the [`session` option](#session), or use the default provided by your adapter. * * ```astro title=src/components/CartButton.astro @@ -1960,8 +1969,8 @@ export interface ViteUserConfig extends OriginalViteUserConfig { * * 🛒 {cart?.length ?? 0} items * - * ``` - * The object configures session management for your Astro site by specifying a `driver` as well as any `options` for your data storage. + * ``` + * The object configures session management for your Astro site by specifying a `driver` as well as any `options` for your data storage. * * You can specify [any driver from Unstorage](https://unstorage.unjs.io/drivers) or provide a custom config which will override your adapter's default. * @@ -1971,31 +1980,31 @@ export interface ViteUserConfig extends OriginalViteUserConfig { * session: { * // Required: the name of the Unstorage driver * driver: "redis", - * // The required options depend on the driver - * options: { - * url: process.env.REDIS_URL, + * // The required options depend on the driver + * options: { + * url: process.env.REDIS_URL, * } * } - * }, + * }, * } * ``` - * + * * For more details, see [the Sessions RFC](https://github.com/withastro/roadmap/blob/sessions/proposals/0054-sessions.md). - * + * */ session?: SessionConfig; - /** + /** * @name experimental.svg * @type {boolean|object} * @default `undefined` - * @version 5.x + * @version 5.x * @description - * + * * This feature allows you to import SVG files directly into your Astro project. By default, Astro will inline the SVG content into your HTML output. - * + * * To enable this feature, set `experimental.svg` to `true` in your Astro config: - * + * * ```js * { * experimental: { @@ -2003,20 +2012,20 @@ export interface ViteUserConfig extends OriginalViteUserConfig { * }, * } * ``` - * + * * To use this feature, import an SVG file in your Astro project, passing any common SVG attributes to the imported component. * Astro also provides a `size` attribute to set equal `height` and `width` properties: - * + * * ```astro * --- * import Logo from './path/to/svg/file.svg'; * --- - * + * * * ``` - * + * * For a complete overview, and to give feedback on this experimental API, - * see the [Feature RFC](https://github.com/withastro/roadmap/pull/1035). + * see the [Feature RFC](https://github.com/withastro/roadmap/pull/1035). */ svg?: { /** @@ -2024,17 +2033,17 @@ export interface ViteUserConfig extends OriginalViteUserConfig { * @name experimental.svg.mode * @type {string} * @default 'inline' - * + * * The default technique for handling imported SVG files. Astro will inline the SVG content into your HTML output if not specified. - * + * * - `inline`: Astro will inline the SVG content into your HTML output. * - `sprite`: Astro will generate a sprite sheet with all imported SVG files. - * + * * ```astro * --- * import Logo from './path/to/svg/file.svg'; * --- - * + * * * ``` */ diff --git a/packages/astro/src/types/public/context.ts b/packages/astro/src/types/public/context.ts index 7a6f3b6be0a2..594376dee5bf 100644 --- a/packages/astro/src/types/public/context.ts +++ b/packages/astro/src/types/public/context.ts @@ -6,6 +6,7 @@ import type { } from '../../actions/runtime/virtual/server.js'; import type { SUPPORTED_MARKDOWN_FILE_EXTENSIONS } from '../../core/constants.js'; import type { AstroCookies } from '../../core/cookies/cookies.js'; +import type { AstroSession } from '../../core/session.js'; import type { AstroComponentFactory } from '../../runtime/server/index.js'; import type { Params, RewritePayload } from './common.js'; import type { ValidRedirectStatus } from './config.js'; @@ -260,6 +261,10 @@ interface AstroSharedContext< * Utility for getting and setting the values of cookies. */ cookies: AstroCookies; + /** + * Utility for handling sessions. + */ + session?: AstroSession; /** * Information about the current request. This is a standard [Request](https://developer.mozilla.org/en-US/docs/Web/API/Request) object */ diff --git a/packages/astro/src/vite-plugin-astro-server/plugin.ts b/packages/astro/src/vite-plugin-astro-server/plugin.ts index 2a22db6c7729..6e5e655ff805 100644 --- a/packages/astro/src/vite-plugin-astro-server/plugin.ts +++ b/packages/astro/src/vite-plugin-astro-server/plugin.ts @@ -174,5 +174,6 @@ export function createDevelopmentManifest(settings: AstroSettings): SSRManifest onRequest: NOOP_MIDDLEWARE_FN, }; }, + sessionConfig: settings.config.experimental.session, }; } diff --git a/packages/astro/src/vite-plugin-astro-server/route.ts b/packages/astro/src/vite-plugin-astro-server/route.ts index 7c2a5bd9a695..f4cb57e8aba2 100644 --- a/packages/astro/src/vite-plugin-astro-server/route.ts +++ b/packages/astro/src/vite-plugin-astro-server/route.ts @@ -15,6 +15,7 @@ import { type SSROptions, getProps } from '../core/render/index.js'; import { createRequest } from '../core/request.js'; import { redirectTemplate } from '../core/routing/3xx.js'; import { matchAllRoutes } from '../core/routing/index.js'; +import { PERSIST_SYMBOL } from '../core/session.js'; import { getSortedPreloadedMatches } from '../prerender/routing.js'; import type { ComponentInstance, ManifestData } from '../types/astro.js'; import type { RouteData } from '../types/public/internal.js'; @@ -234,6 +235,8 @@ export async function handleRoute({ renderContext.props.error = err; response = await renderContext.render(preloaded500Component); statusCode = 500; + } finally { + renderContext.session?.[PERSIST_SYMBOL](); } if (isLoggedRequest(pathname)) { diff --git a/packages/astro/test/fixtures/sessions/astro.config.mjs b/packages/astro/test/fixtures/sessions/astro.config.mjs new file mode 100644 index 000000000000..2d15912f3a28 --- /dev/null +++ b/packages/astro/test/fixtures/sessions/astro.config.mjs @@ -0,0 +1,18 @@ +// @ts-check +import { defineConfig } from 'astro/config'; +import testAdapter from '../../test-adapter.js'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +export default defineConfig({ + adapter: testAdapter(), + output: 'server', + experimental: { + session: { + driver: 'fs', + options: { + base: join(tmpdir(), 'sessions'), + }, + }, + }, +}); diff --git a/packages/astro/test/fixtures/sessions/package.json b/packages/astro/test/fixtures/sessions/package.json new file mode 100644 index 000000000000..453f09a96d4b --- /dev/null +++ b/packages/astro/test/fixtures/sessions/package.json @@ -0,0 +1,8 @@ +{ + "name": "@test/sessions", + "version": "0.0.0", + "private": true, + "dependencies": { + "astro": "workspace:*" + } +} diff --git a/packages/astro/test/fixtures/sessions/src/actions/index.ts b/packages/astro/test/fixtures/sessions/src/actions/index.ts new file mode 100644 index 000000000000..33ac1cb653fb --- /dev/null +++ b/packages/astro/test/fixtures/sessions/src/actions/index.ts @@ -0,0 +1,27 @@ +import { defineAction } from 'astro:actions'; +import { z } from 'astro:schema'; + +export const server = { + addToCart: defineAction({ + accept: 'form', + input: z.object({ productId: z.string() }), + handler: async (input, context) => { + const cart: Array = (await context.session.get('cart')) || []; + cart.push(input.productId); + await context.session.set('cart', cart); + return { cart, message: 'Product added to cart at ' + new Date().toTimeString() }; + }, + }), + getCart: defineAction({ + handler: async (input, context) => { + return await context.session.get('cart'); + }, + }), + clearCart: defineAction({ + accept: 'json', + handler: async (input, context) => { + await context.session.set('cart', []); + return {cart: [], message: 'Cart cleared at ' + new Date().toTimeString() }; + }, + }), +}; diff --git a/packages/astro/test/fixtures/sessions/src/middleware.ts b/packages/astro/test/fixtures/sessions/src/middleware.ts new file mode 100644 index 000000000000..7f56f11f364f --- /dev/null +++ b/packages/astro/test/fixtures/sessions/src/middleware.ts @@ -0,0 +1,49 @@ +import { defineMiddleware } from 'astro:middleware'; +import { getActionContext } from 'astro:actions'; + +const ACTION_SESSION_KEY = 'actionResult' + +export const onRequest = defineMiddleware(async (context, next) => { + // Skip requests for prerendered pages + if (context.isPrerendered) return next(); + + const { action, setActionResult, serializeActionResult } = + getActionContext(context); + + console.log(action?.name) + + const actionPayload = await context.session.get(ACTION_SESSION_KEY); + + if (actionPayload) { + setActionResult(actionPayload.actionName, actionPayload.actionResult); + context.session.delete(ACTION_SESSION_KEY); + return next(); + } + + // If an action was called from an HTML form action, + // call the action handler and redirect to the destination page + if (action?.calledFrom === "form") { + const actionResult = await action.handler(); + + context.session.set(ACTION_SESSION_KEY, { + actionName: action.name, + actionResult: serializeActionResult(actionResult), + }); + + + // Redirect back to the previous page on error + if (actionResult.error) { + const referer = context.request.headers.get("Referer"); + if (!referer) { + throw new Error( + "Internal: Referer unexpectedly missing from Action POST request.", + ); + } + return context.redirect(referer); + } + // Redirect to the destination page on success + return context.redirect(context.originPathname); + } + + return next(); +}); diff --git a/packages/astro/test/fixtures/sessions/src/pages/api.ts b/packages/astro/test/fixtures/sessions/src/pages/api.ts new file mode 100644 index 000000000000..77d50625aab1 --- /dev/null +++ b/packages/astro/test/fixtures/sessions/src/pages/api.ts @@ -0,0 +1,15 @@ +import type { APIRoute } from 'astro'; + +export const prerender = false; + +export const GET: APIRoute = async (context) => { + const url = new URL(context.url, 'http://localhost'); + let value = url.searchParams.get('set'); + if (value) { + context.session.set('value', value); + } else { + value = await context.session.get('value'); + } + const cart = await context.session.get('cart'); + return Response.json({ value, cart }); +}; diff --git a/packages/astro/test/fixtures/sessions/src/pages/cart.astro b/packages/astro/test/fixtures/sessions/src/pages/cart.astro new file mode 100644 index 000000000000..e69a9e5e15b1 --- /dev/null +++ b/packages/astro/test/fixtures/sessions/src/pages/cart.astro @@ -0,0 +1,24 @@ +--- +import { actions } from "astro:actions"; + +const result = Astro.getActionResult(actions.addToCart); + +const cart = result?.data?.cart ?? await Astro.session.get('cart'); +const message = result?.data?.message ?? 'Add something to your cart!'; +--- +

Cart: {JSON.stringify(cart)}

+

{message}

+
+ + +
+ + diff --git a/packages/astro/test/fixtures/sessions/src/pages/destroy.ts b/packages/astro/test/fixtures/sessions/src/pages/destroy.ts new file mode 100644 index 000000000000..e83f6e4b6c31 --- /dev/null +++ b/packages/astro/test/fixtures/sessions/src/pages/destroy.ts @@ -0,0 +1,6 @@ +import type { APIRoute } from 'astro'; + +export const GET: APIRoute = async (context) => { + await context.session.destroy(); + return Response.json({}); +}; diff --git a/packages/astro/test/fixtures/sessions/src/pages/index.astro b/packages/astro/test/fixtures/sessions/src/pages/index.astro new file mode 100644 index 000000000000..30d6a1618796 --- /dev/null +++ b/packages/astro/test/fixtures/sessions/src/pages/index.astro @@ -0,0 +1,13 @@ +--- +const value = await Astro.session.get('value'); +--- + + + + Hi + + +

Hi

+

{value}

+🛒 + diff --git a/packages/astro/test/fixtures/sessions/src/pages/regenerate.ts b/packages/astro/test/fixtures/sessions/src/pages/regenerate.ts new file mode 100644 index 000000000000..6f2240588e4f --- /dev/null +++ b/packages/astro/test/fixtures/sessions/src/pages/regenerate.ts @@ -0,0 +1,6 @@ +import type { APIRoute } from 'astro'; + +export const GET: APIRoute = async (context) => { + await context.session.regenerate(); + return Response.json({}); +}; diff --git a/packages/astro/test/fixtures/sessions/tsconfig.json b/packages/astro/test/fixtures/sessions/tsconfig.json new file mode 100644 index 000000000000..c193287fccd6 --- /dev/null +++ b/packages/astro/test/fixtures/sessions/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "astro/tsconfigs/base", + "compilerOptions": { + "baseUrl": ".", + "paths": { + "~/assets/*": ["src/assets/*"] + }, + }, + "include": [".astro/types.d.ts", "**/*"], + "exclude": ["dist"] +} diff --git a/packages/astro/test/units/sessions/astro-session.test.js b/packages/astro/test/units/sessions/astro-session.test.js new file mode 100644 index 000000000000..be118a403752 --- /dev/null +++ b/packages/astro/test/units/sessions/astro-session.test.js @@ -0,0 +1,412 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { stringify as devalueStringify } from 'devalue'; +import { AstroSession, PERSIST_SYMBOL } from '../../../dist/core/session.js'; +// Mock dependencies +const defaultMockCookies = { + set: () => {}, + delete: () => {}, + get: () => 'sessionid', +}; + +const stringify = (data) => JSON.parse(devalueStringify(data)); + +const defaultConfig = { + driver: 'memory', + cookie: 'test-session', +}; + +// Helper to create a new session instance with mocked dependencies +function createSession(config = defaultConfig, cookies = defaultMockCookies, mockStorage) { + if (mockStorage) { + config.driver = 'test'; + config.options ??= {}; + config.options.mockStorage = mockStorage; + } + return new AstroSession(cookies, config); +} + +test('AstroSession - Basic Operations', async (t) => { + await t.test('should set and get a value', async () => { + const session = createSession(); + + session.set('user', { id: 1, name: 'Test User' }); + const user = await session.get('user'); + + assert.deepEqual(user, { id: 1, name: 'Test User' }); + }); + + await t.test('should check if value exists', async () => { + const session = createSession(); + + session.set('key', 'value'); + const exists = await session.has('key'); + const notExists = await session.has('nonexistent'); + + assert.equal(exists, true); + assert.equal(notExists, false); + }); + + await t.test('should delete a value', async () => { + const session = createSession(); + + session.set('key', 'value'); + session.delete('key'); + const value = await session.get('key'); + + assert.equal(value, undefined); + }); + + await t.test('should list all keys', async () => { + const session = createSession(); + + session.set('key1', 'value1'); + session.set('key2', 'value2'); + const keys = await session.keys(); + + assert.deepEqual([...keys], ['key1', 'key2']); + }); +}); + +test('AstroSession - Cookie Management', async (t) => { + await t.test('should set cookie on first value set', async () => { + let cookieSet = false; + const mockCookies = { + ...defaultMockCookies, + set: () => { + cookieSet = true; + }, + }; + + const session = createSession(defaultConfig, mockCookies); + session.set('key', 'value'); + + assert.equal(cookieSet, true); + }); + + await t.test('should delete cookie on destroy', async () => { + let cookieDeleted = false; + const mockCookies = { + ...defaultMockCookies, + delete: () => { + cookieDeleted = true; + }, + }; + + const session = createSession(defaultConfig, mockCookies); + session.destroy(); + + assert.equal(cookieDeleted, true); + }); +}); + +test('AstroSession - Session Regeneration', async (t) => { + await t.test('should preserve data when regenerating session', async () => { + const session = createSession(); + + session.set('key', 'value'); + await session.regenerate(); + const value = await session.get('key'); + + assert.equal(value, 'value'); + }); + + await t.test('should generate new session ID on regeneration', async () => { + const session = createSession(); + const initialId = await session.sessionID; + + await session.regenerate(); + const newId = await session.sessionID; + + assert.notEqual(initialId, newId); + }); +}); + +test('AstroSession - Data Persistence', async (t) => { + await t.test('should persist data to storage', async () => { + let storedData; + const mockStorage = { + get: async () => null, + setItem: async (_key, value) => { + storedData = value; + }, + }; + + const session = createSession(defaultConfig, defaultMockCookies, mockStorage); + + session.set('key', 'value'); + await session[PERSIST_SYMBOL](); + + assert.ok(storedData?.includes('value')); + }); + + await t.test('should load data from storage', async () => { + const mockStorage = { + get: async () => stringify(new Map([['key', 'value']])), + setItem: async () => {}, + }; + + const session = createSession(defaultConfig, defaultMockCookies, mockStorage); + + const value = await session.get('key'); + assert.equal(value, 'value'); + }); +}); + +test('AstroSession - Error Handling', async (t) => { + await t.test('should throw error when setting invalid data', async () => { + const session = createSession(); + + assert.throws(() => session.set('key', { fun: function () {} }), /could not be serialized/); + }); + + await t.test('should throw error when setting empty key', async () => { + const session = createSession(); + + assert.throws(() => session.set('', 'value'), /key was not provided/); + }); + + await t.test('should handle corrupted storage data', async () => { + const mockStorage = { + get: async () => 'invalid-json', + setItem: async () => {}, + }; + + const session = createSession(defaultConfig, defaultMockCookies, mockStorage); + + await assert.rejects(async () => await session.get('key'), /could not be parsed/); + }); +}); + +test('AstroSession - Configuration', async (t) => { + await t.test('should use custom cookie name from config', async () => { + let cookieName; + const mockCookies = { + ...defaultMockCookies, + set: (name) => { + cookieName = name; + }, + }; + + const session = createSession( + { + ...defaultConfig, + cookie: 'custom-session', + }, + mockCookies, + ); + + session.set('key', 'value'); + assert.equal(cookieName, 'custom-session'); + }); + + await t.test('should use default cookie name if not specified', async () => { + let cookieName; + const mockCookies = { + ...defaultMockCookies, + set: (name) => { + cookieName = name; + }, + }; + + const session = createSession( + { + ...defaultConfig, + // @ts-ignore + cookie: undefined, + }, + mockCookies, + ); + + session.set('key', 'value'); + assert.equal(cookieName, 'astro-session'); + }); +}); + +test('AstroSession - Sparse Data Operations', async (t) => { + await t.test('should handle multiple operations in sparse mode', async () => { + const existingData = stringify( + new Map([ + ['keep', 'original'], + ['delete', 'remove'], + ['update', 'old'], + ]), + ); + + const mockStorage = { + get: async () => existingData, + setItem: async () => {}, + }; + + const session = createSession(defaultConfig, defaultMockCookies, mockStorage); + + // Mixed operations + session.delete('delete'); + session.set('update', 'new'); + session.set('new', 'value'); + + // Verify each operation type + assert.equal(await session.get('keep'), 'original'); + assert.equal(await session.get('delete'), undefined); + assert.equal(await session.get('update'), 'new'); + assert.equal(await session.get('new'), 'value'); + }); + + await t.test('should persist deleted state across multiple operations', async () => { + const existingData = stringify(new Map([['key', 'value']])); + const mockStorage = { + get: async () => existingData, + setItem: async () => {}, + }; + + const session = createSession(defaultConfig, defaultMockCookies, mockStorage); + + session.delete('key'); + + // Multiple gets should all return undefined + assert.equal(await session.get('key'), undefined); + assert.equal(await session.has('key'), false); + + // Setting a different key shouldn't affect the deleted state + session.set('other', 'value'); + assert.equal(await session.get('key'), undefined); + }); + + await t.test('should maintain deletion after persistence', async () => { + let storedData; + const mockStorage = { + get: async () => storedData || stringify(new Map([['key', 'value']])), + setItem: async (_key, value) => { + storedData = value; + }, + }; + + const session = createSession(defaultConfig, defaultMockCookies, mockStorage); + + session.delete('key'); + await session[PERSIST_SYMBOL](); + + // Create a new session using the stored data + const newSession = createSession(defaultConfig, defaultMockCookies, { + get: async () => storedData, + setItem: async () => {}, + }); + + assert.equal(await newSession.get('key'), undefined); + }); + + await t.test('should update existing values in sparse mode', async () => { + const existingData = stringify(new Map([['key', 'old']])); + const mockStorage = { + get: async () => existingData, + setItem: async () => {}, + }; + + const session = createSession(defaultConfig, defaultMockCookies, mockStorage); + + session.set('key', 'new'); + assert.equal(await session.get('key'), 'new'); + + // Verify through keys() as well + const keys = await session.keys(); + assert.deepEqual([...keys], ['key']); + }); +}); + +test('AstroSession - Cleanup Operations', async (t) => { + await t.test('should clean up destroyed sessions on persist', async () => { + const removedKeys = new Set(); + const mockStorage = { + get: async () => stringify(new Map([['key', 'value']])), + setItem: async () => {}, + removeItem: async (key) => { + removedKeys.add(key); + }, + }; + + const session = createSession(defaultConfig, defaultMockCookies, mockStorage); + + // Set up session + session.set('key', 'value'); + const oldId = session.sessionID; + + // Destroy it + session.destroy(); + + // Simulate end of request + await session[PERSIST_SYMBOL](); + + assert.ok(removedKeys.has(oldId), `Session ${oldId} should be removed`); + }); +}); + +test('AstroSession - Cookie Security', async (t) => { + await t.test('should enforce httpOnly cookie setting', async () => { + let cookieOptions; + const mockCookies = { + ...defaultMockCookies, + set: (_name, _value, options) => { + cookieOptions = options; + }, + }; + + const session = createSession( + { + ...defaultConfig, + cookieOptions: { + httpOnly: false, + }, + }, + mockCookies, + ); + + session.set('key', 'value'); + assert.equal(cookieOptions.httpOnly, true); + }); + + await t.test('should set secure and sameSite by default', async () => { + let cookieOptions; + const mockCookies = { + ...defaultMockCookies, + set: (_name, _value, options) => { + cookieOptions = options; + }, + }; + + const session = createSession(defaultConfig, mockCookies); + + session.set('key', 'value'); + assert.equal(cookieOptions.secure, true); + assert.equal(cookieOptions.sameSite, 'lax'); + }); +}); + +test('AstroSession - Storage Errors', async (t) => { + await t.test('should handle storage setItem failures', async () => { + const mockStorage = { + get: async () => stringify(new Map()), + setItem: async () => { + throw new Error('Storage full'); + }, + }; + + const session = createSession(defaultConfig, defaultMockCookies, mockStorage); + session.set('key', 'value'); + + await assert.rejects(async () => await session[PERSIST_SYMBOL](), /Storage full/); + }); + + await t.test('should handle invalid Map data', async () => { + const mockStorage = { + get: async () => stringify({ notAMap: true }), + setItem: async () => {}, + }; + + const session = createSession(defaultConfig, defaultMockCookies, mockStorage); + + await assert.rejects( + async () => await session.get('key'), + /The session data was an invalid type/, + ); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 64bfe3c7c67b..55b371e1ac53 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3643,6 +3643,12 @@ importers: specifier: ^5.1.16 version: 5.1.16 + packages/astro/test/fixtures/sessions: + dependencies: + astro: + specifier: workspace:* + version: link:../../.. + packages/astro/test/fixtures/set-html: dependencies: astro: