0
Fork 0
mirror of https://github.com/withastro/astro.git synced 2025-02-10 22:38:53 -05:00

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
This commit is contained in:
Matt Kane 2024-11-21 17:25:47 +00:00 committed by GitHub
parent 31aa5eb8e7
commit d96c507c6f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 1138 additions and 55 deletions

View file

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

View file

@ -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> | AstroMiddlewareInstance;
checkOrigin: boolean;
envGetSecretEnabled: boolean;
sessionConfig?: SessionConfig<any>;
};
export type SSRManifestI18n = {

View file

@ -277,5 +277,6 @@ function buildManifest(
envGetSecretEnabled:
(unwrapSupportKind(settings.adapter?.supportedAstroFeatures.envGetSecret) ??
'unsupported') !== 'unsupported',
sessionConfig: settings.config.experimental.session,
};
}

View file

@ -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(),
})

View file

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

View file

@ -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<AstroGlobal, 'props' | 'self' | 'slots'> {
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();
},

View file

@ -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<TDriver extends SessionDriverName = any> {
// The cookies object.
#cookies: AstroCookies;
// The session configuration.
#config: Omit<SessionConfig<TDriver>, 'cookie'>;
// The cookie config
#cookieConfig?: AstroCookieSetOptions;
// The cookie name
#cookieName: string;
// The unstorage object for the session driver.
#storage: Storage | undefined;
#data: Map<string, any> | 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<string>();
// Session keys to delete. Used for sparse data sets to avoid overwriting the deleted value.
#toDelete = new Set<string>();
// 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<SessionConfig<TDriver>, 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<T = any>(key: string): Promise<T | undefined> {
return (await this.#ensureData()).get(key);
}
/**
* Checks if a session value exists.
*/
async has(key: string): Promise<boolean> {
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<T = any>(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<any[]>(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<Storage> {
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<TDriver>['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 },
);
}
}
}

View file

@ -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<AstroCookieSetOptions, 'httpOnly' | 'expires' | 'encode'> & { name?: string });
}
interface BuiltinSessionConfig<TDriver extends keyof BuiltinDriverOptions>
@ -123,8 +121,19 @@ interface CustomSessionConfig extends CommonSessionConfig {
options?: Record<string, unknown>;
}
interface TestSessionConfig extends CommonSessionConfig {
driver: 'test';
options: {
mockStorage: Storage;
};
}
export type SessionConfig<TDriver extends SessionDriverName> =
TDriver extends keyof BuiltinDriverOptions ? BuiltinSessionConfig<TDriver> : CustomSessionConfig;
TDriver extends keyof BuiltinDriverOptions
? BuiltinSessionConfig<TDriver>
: 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<ShikiConfig>}
* @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 {
*
* <a href="/checkout">🛒 {cart?.length ?? 0} items</a>
*
* ```
* 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<TSession>;
/**
/**
* @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';
* ---
*
*
* <Logo size={24} />
* ```
*
*
* 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';
* ---
*
*
* <Logo size={24} mode="sprite" />
* ```
*/

View file

@ -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
*/

View file

@ -174,5 +174,6 @@ export function createDevelopmentManifest(settings: AstroSettings): SSRManifest
onRequest: NOOP_MIDDLEWARE_FN,
};
},
sessionConfig: settings.config.experimental.session,
};
}

View file

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

View file

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

View file

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

View file

@ -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<string> = (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() };
},
}),
};

View file

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

View file

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

View file

@ -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!';
---
<p>Cart: <span id="cart">{JSON.stringify(cart)}</span></p>
<p id="message">{message}</p>
<form action={actions.addToCart} method="POST">
<input type="text" name="productId" value="shoe" />
<button type="submit">Add to Cart</button>
</form>
<input type="button" value="Clear Cart" id="clearCart" />
<script>
import { actions } from "astro:actions";
async function clearCart() {
const result = await actions.clearCart({});
document.getElementById('cart').textContent = JSON.stringify(result.data.cart);
document.getElementById('message').textContent = result.data.message;
}
document.getElementById('clearCart').addEventListener('click', clearCart);
</script>

View file

@ -0,0 +1,6 @@
import type { APIRoute } from 'astro';
export const GET: APIRoute = async (context) => {
await context.session.destroy();
return Response.json({});
};

View file

@ -0,0 +1,13 @@
---
const value = await Astro.session.get('value');
---
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Hi</title>
</head>
<h1>Hi</h1>
<p>{value}</p>
<a href="/cart" style="font-size: 36px">🛒</a>
</html>

View file

@ -0,0 +1,6 @@
import type { APIRoute } from 'astro';
export const GET: APIRoute = async (context) => {
await context.session.regenerate();
return Response.json({});
};

View file

@ -0,0 +1,11 @@
{
"extends": "astro/tsconfigs/base",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"~/assets/*": ["src/assets/*"]
},
},
"include": [".astro/types.d.ts", "**/*"],
"exclude": ["dist"]
}

View file

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

6
pnpm-lock.yaml generated
View file

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