mirror of
https://github.com/withastro/astro.git
synced 2024-12-30 22:03:56 -05:00
Sessions API (#12441)
* wip: experimental sessions * feat: adds session options (#12450) * feat: add session config * chore: add session config docs * Fix * Expand doc * Handle schema * Remove example * Format * Lock * Fix schema * Update packages/astro/src/types/public/config.ts Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca> * Update packages/astro/src/types/public/config.ts Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca> * Add link to Sessions RFC in config.ts * Move session into experimental --------- Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca> * Lock * 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 * ci: changeset base * ci: exit from changeset pre mode * Lockfile * Update base * fix: use virtual import for storage drivers (#12520) * fix: use virtual import for storage drivers * Don't try to resolve anythign in build * Fix test * Polyfill node:url * Handle custom drivers directly * No need for path * Update packages/astro/src/core/session.ts Co-authored-by: Emanuele Stoppa <my.burning@gmail.com> --------- Co-authored-by: Emanuele Stoppa <my.burning@gmail.com> * Fix jsdoc * fix: set default storage path * Update changeset config for now * Revert config workaround * Lock * Remove unneeded ts-expect-error directive * fix: [sessions] import storage driver in manifest (#12654) * wip * wip * Export manifest in middleware * Changeset conf * Pass session to edge middleware * Support initial session data * Persist edge session on redirect * Remove middleware-related changes * Refactor * Remove vite plugin * Format * Simplify import * Handle missing config * Handle async resolution * Lockfile * feat(sessions): implement ttl and flash (#12693) * feat(sessions): implement ttl and flash * chore: add unit tests * Make set arg an object * Add more tests * Add test fixtures * Add comment * Remove session.flash for now (#12745) * Changeset * Apply suggestions from code review Co-authored-by: Sarah Rainsberger <5098874+sarah11918@users.noreply.github.com> --------- Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca> Co-authored-by: Emanuele Stoppa <my.burning@gmail.com> Co-authored-by: Sarah Rainsberger <5098874+sarah11918@users.noreply.github.com>
This commit is contained in:
parent
3dc02c57e4
commit
b4fec3c7d1
27 changed files with 2174 additions and 520 deletions
36
.changeset/poor-mangos-fold.md
Normal file
36
.changeset/poor-mangos-fold.md
Normal file
|
@ -0,0 +1,36 @@
|
|||
---
|
||||
'astro': minor
|
||||
---
|
||||
|
||||
Adds experimental session support
|
||||
|
||||
Sessions are used to store user state between requests for server-rendered pages, such as login status, shopping cart contents, or other user-specific data.
|
||||
|
||||
```astro
|
||||
---
|
||||
export const prerender = false; // Not needed in 'server' mode
|
||||
const cart = await Astro.session.get('cart');
|
||||
---
|
||||
|
||||
<a href="/checkout">🛒 {cart?.length ?? 0} items</a>
|
||||
```
|
||||
|
||||
Sessions are available in on-demand rendered/SSR pages, API endpoints, actions and middleware. To enable session support, you must configure a storage driver.
|
||||
|
||||
If you are using the Node.js adapter, you can use the `fs` driver to store session data on the filesystem:
|
||||
|
||||
```js
|
||||
// astro.config.mjs
|
||||
{
|
||||
adapter: node({ mode: 'standalone' }),
|
||||
experimental: {
|
||||
session: {
|
||||
// Required: the name of the Unstorage driver
|
||||
driver: "fs",
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
If you are deploying to a serverless environment, you can use drivers such as `redis` or `netlifyBlobs` or `cloudflareKV` and optionally pass additional configuration options.
|
||||
|
||||
For more information, including using the session API with other adapters and a full list of supported drivers, see [the docs for experimental session support](https://docs.astro.build/en/reference/experimental-flags/sessions/). For even more details, and to leave feedback and participate in the development of this feature, [the Sessions RFC](https://github.com/withastro/roadmap/pull/1055).
|
|
@ -166,6 +166,7 @@
|
|||
"tsconfck": "^3.1.4",
|
||||
"ultrahtml": "^1.5.3",
|
||||
"unist-util-visit": "^5.0.0",
|
||||
"unstorage": "^1.12.0",
|
||||
"vfile": "^6.0.3",
|
||||
"vite": "^6.0.1",
|
||||
"vitefu": "^1.0.4",
|
||||
|
|
|
@ -1,15 +1,21 @@
|
|||
import type { UserConfig as ViteUserConfig, UserConfigFn as ViteUserConfigFn } from 'vite';
|
||||
import { createRouteManifest } from '../core/routing/index.js';
|
||||
import type { AstroInlineConfig, AstroUserConfig, Locales } from '../types/public/config.js';
|
||||
import type {
|
||||
AstroInlineConfig,
|
||||
AstroUserConfig,
|
||||
Locales,
|
||||
SessionDriverName,
|
||||
} from '../types/public/config.js';
|
||||
import { createDevelopmentManifest } from '../vite-plugin-astro-server/plugin.js';
|
||||
|
||||
/**
|
||||
* See the full Astro Configuration API Documentation
|
||||
* https://astro.build/config
|
||||
*/
|
||||
export function defineConfig<const TLocales extends Locales = never>(
|
||||
config: AstroUserConfig<TLocales>,
|
||||
) {
|
||||
export function defineConfig<
|
||||
const TLocales extends Locales = never,
|
||||
const TDriver extends SessionDriverName = never,
|
||||
>(config: AstroUserConfig<TLocales, TDriver>) {
|
||||
return config;
|
||||
}
|
||||
|
||||
|
|
|
@ -23,6 +23,7 @@ import { createAssetLink } from '../render/ssr-element.js';
|
|||
import { ensure404Route } from '../routing/astro-designed-error-pages.js';
|
||||
import { createDefaultRoutes } 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);
|
||||
|
@ -290,10 +292,13 @@ export class App {
|
|||
status: defaultStatus,
|
||||
clientAddress,
|
||||
});
|
||||
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, clientAddress });
|
||||
} finally {
|
||||
session?.[PERSIST_SYMBOL]();
|
||||
}
|
||||
|
||||
if (
|
||||
|
@ -379,6 +384,7 @@ export class App {
|
|||
}
|
||||
}
|
||||
const mod = await this.#pipeline.getModuleForRoute(errorRouteData);
|
||||
let session: AstroSession | undefined;
|
||||
try {
|
||||
const renderContext = await RenderContext.create({
|
||||
locals,
|
||||
|
@ -391,6 +397,7 @@ export class App {
|
|||
props: { error },
|
||||
clientAddress,
|
||||
});
|
||||
session = renderContext.session;
|
||||
const response = await renderContext.render(await mod.page());
|
||||
return this.#mergeResponses(response, originalResponse);
|
||||
} catch {
|
||||
|
@ -404,6 +411,8 @@ export class App {
|
|||
clientAddress,
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
session?.[PERSIST_SYMBOL]();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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, ResolvedSessionConfig, SessionConfig } from '../../types/public/config.js';
|
||||
import type {
|
||||
RouteData,
|
||||
SSRComponentMetadata,
|
||||
|
@ -69,6 +69,7 @@ export type SSRManifest = {
|
|||
i18n: SSRManifestI18n | undefined;
|
||||
middleware?: () => Promise<AstroMiddlewareInstance> | AstroMiddlewareInstance;
|
||||
checkOrigin: boolean;
|
||||
sessionConfig?: ResolvedSessionConfig<any>
|
||||
};
|
||||
|
||||
export type SSRManifestI18n = {
|
||||
|
|
|
@ -22,6 +22,7 @@ import { type BuildInternals, cssOrder, mergeInlineCss } from '../internal.js';
|
|||
import type { AstroBuildPlugin } from '../plugin.js';
|
||||
import type { StaticBuildOptions } from '../types.js';
|
||||
import { makePageDataKey } from './util.js';
|
||||
import { resolveSessionDriver } from '../../session.js';
|
||||
|
||||
const manifestReplace = '@@ASTRO_MANIFEST_REPLACE@@';
|
||||
const replaceExp = new RegExp(`['"]${manifestReplace}['"]`, 'g');
|
||||
|
@ -29,7 +30,7 @@ const replaceExp = new RegExp(`['"]${manifestReplace}['"]`, 'g');
|
|||
export const SSR_MANIFEST_VIRTUAL_MODULE_ID = '@astrojs-manifest';
|
||||
export const RESOLVED_SSR_MANIFEST_VIRTUAL_MODULE_ID = '\0' + SSR_MANIFEST_VIRTUAL_MODULE_ID;
|
||||
|
||||
function vitePluginManifest(_options: StaticBuildOptions, internals: BuildInternals): VitePlugin {
|
||||
function vitePluginManifest(options: StaticBuildOptions, internals: BuildInternals): VitePlugin {
|
||||
return {
|
||||
name: '@astro/plugin-build-manifest',
|
||||
enforce: 'post',
|
||||
|
@ -52,11 +53,16 @@ function vitePluginManifest(_options: StaticBuildOptions, internals: BuildIntern
|
|||
`import { deserializeManifest as _deserializeManifest } from 'astro/app'`,
|
||||
`import { _privateSetManifestDontUseThis } from 'astro:ssr-manifest'`,
|
||||
];
|
||||
|
||||
const resolvedDriver = await resolveSessionDriver(options.settings.config.experimental?.session?.driver);
|
||||
|
||||
const contents = [
|
||||
`const manifest = _deserializeManifest('${manifestReplace}');`,
|
||||
`if (manifest.sessionConfig) manifest.sessionConfig.driverModule = ${resolvedDriver ? `() => import(${JSON.stringify(resolvedDriver)})` : 'null'};`,
|
||||
`_privateSetManifestDontUseThis(manifest);`,
|
||||
];
|
||||
const exports = [`export { manifest }`];
|
||||
|
||||
return [...imports, ...contents, ...exports].join('\n');
|
||||
}
|
||||
},
|
||||
|
@ -290,5 +296,6 @@ function buildManifest(
|
|||
(settings.config.security?.checkOrigin && settings.buildOutput === 'server') ?? false,
|
||||
serverIslandNameMap: Array.from(settings.serverIslandNameMap),
|
||||
key: encodedKey,
|
||||
sessionConfig: settings.config.experimental.session,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -536,6 +536,32 @@ export const AstroConfigSchema = z.object({
|
|||
.boolean()
|
||||
.optional()
|
||||
.default(ASTRO_CONFIG_DEFAULTS.experimental.responsiveImages),
|
||||
session: z
|
||||
.object({
|
||||
driver: z.string(),
|
||||
options: z.record(z.any()).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(),
|
||||
ttl: z.number().optional(),
|
||||
})
|
||||
.optional(),
|
||||
svg: z
|
||||
.union([
|
||||
z.boolean(),
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -32,6 +32,7 @@ import { type Pipeline, Slots, getParams, getProps } from './render/index.js';
|
|||
import { isRoute404or500 } from './routing/match.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');
|
||||
|
||||
|
@ -54,6 +55,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,
|
||||
) {}
|
||||
|
||||
/**
|
||||
|
@ -300,7 +304,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) => {
|
||||
|
@ -338,6 +342,7 @@ export class RenderContext {
|
|||
get originPathname() {
|
||||
return getOriginPathname(renderContext.request);
|
||||
},
|
||||
session,
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -470,7 +475,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.
|
||||
|
@ -492,6 +497,7 @@ export class RenderContext {
|
|||
routePattern: this.routeData.route,
|
||||
isPrerendered: this.routeData.prerender,
|
||||
cookies,
|
||||
session,
|
||||
get clientAddress() {
|
||||
return renderContext.getClientAddress();
|
||||
},
|
||||
|
|
459
packages/astro/src/core/session.ts
Normal file
459
packages/astro/src/core/session.ts
Normal file
|
@ -0,0 +1,459 @@
|
|||
import { stringify, unflatten } from 'devalue';
|
||||
import {
|
||||
type BuiltinDriverOptions,
|
||||
type Driver,
|
||||
type Storage,
|
||||
builtinDrivers,
|
||||
createStorage,
|
||||
} from 'unstorage';
|
||||
import type {
|
||||
ResolvedSessionConfig,
|
||||
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-]+$/;
|
||||
|
||||
interface SessionEntry {
|
||||
data: any;
|
||||
expires?: number;
|
||||
}
|
||||
|
||||
export class AstroSession<TDriver extends SessionDriverName = any> {
|
||||
// The cookies object.
|
||||
#cookies: AstroCookies;
|
||||
// The session configuration.
|
||||
#config: Omit<ResolvedSessionConfig<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, SessionEntry> | 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 partial 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;
|
||||
// The local data is "partial" if it has not been loaded from storage yet and 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 partial data,
|
||||
// preserving in-memory changes and deletions.
|
||||
#partial = true;
|
||||
|
||||
constructor(
|
||||
cookies: AstroCookies,
|
||||
{
|
||||
cookie: cookieConfig = DEFAULT_COOKIE_NAME,
|
||||
...config
|
||||
}: Exclude<ResolvedSessionConfig<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)?.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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()].map((entry) => entry.data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets all session entries.
|
||||
*/
|
||||
async entries() {
|
||||
return [...(await this.#ensureData()).entries()].map(([key, entry]) => [key, entry.data]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a session value.
|
||||
*/
|
||||
delete(key: string) {
|
||||
this.#data?.delete(key);
|
||||
if (this.#partial) {
|
||||
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, { ttl }: { ttl?: number } = {}) {
|
||||
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();
|
||||
const lifetime = ttl ?? this.#config.ttl;
|
||||
// If ttl is numeric, it is the number of seconds until expiry. To get an expiry timestamp, we convert to milliseconds and add to the current time.
|
||||
const expires = typeof lifetime === 'number' ? Date.now() + lifetime * 1000 : lifetime;
|
||||
this.#data.set(key, {
|
||||
data: value,
|
||||
expires,
|
||||
});
|
||||
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();
|
||||
this.#toDelete.forEach((key) => data.delete(key));
|
||||
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 partial data, it will be merged into the new data object.
|
||||
*/
|
||||
|
||||
async #ensureData() {
|
||||
const storage = await this.#ensureStorage();
|
||||
if (this.#data && !this.#partial) {
|
||||
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,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
// Only copy values from storage that:
|
||||
// 1. Don't exist in memory (preserving in-memory changes)
|
||||
// 2. Haven't been marked for deletion
|
||||
// 3. Haven't expired
|
||||
for (const [key, value] of storedMap) {
|
||||
const expired = typeof value.expires === 'number' && value.expires < now;
|
||||
if (!this.#data.has(key) && !this.#toDelete.has(key) && !expired) {
|
||||
this.#data.set(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
this.#partial = 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;
|
||||
}
|
||||
// Use fsLite rather than fs, because fs can't be bundled. Add a default base path if not provided.
|
||||
if (this.#config.driver === 'fs' || this.#config.driver === 'fsLite') {
|
||||
this.#config.options ??= {};
|
||||
this.#config.driver = 'fsLite';
|
||||
(this.#config.options as BuiltinDriverOptions['fsLite']).base ??= '.astro/session';
|
||||
}
|
||||
|
||||
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 driverPackage = await resolveSessionDriver(this.#config.driver);
|
||||
try {
|
||||
if (this.#config.driverModule) {
|
||||
driver = (await this.#config.driverModule()).default;
|
||||
} else if (driverPackage) {
|
||||
driver = (await import(driverPackage)).default;
|
||||
}
|
||||
} 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 '${driverPackage}'`)
|
||||
? '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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
// TODO: make this sync when we drop support for Node < 18.19.0
|
||||
export function resolveSessionDriver(driver: string | undefined): Promise<string> | string | null {
|
||||
if (!driver) {
|
||||
return null;
|
||||
}
|
||||
if (driver === 'fs') {
|
||||
return import.meta.resolve(builtinDrivers.fsLite);
|
||||
}
|
||||
if (driver in builtinDrivers) {
|
||||
return import.meta.resolve(builtinDrivers[driver as keyof typeof builtinDrivers]);
|
||||
}
|
||||
return driver;
|
||||
}
|
|
@ -5,6 +5,7 @@ import type {
|
|||
RemarkRehype,
|
||||
ShikiConfig,
|
||||
} from '@astrojs/markdown-remark';
|
||||
import type { BuiltinDriverName, BuiltinDriverOptions, Driver, 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';
|
||||
|
@ -12,10 +13,10 @@ import type { SvgRenderMode } from '../../assets/utils/svg.js';
|
|||
import type { AssetsPrefix } from '../../core/app/types.js';
|
||||
import type { AstroConfigType } from '../../core/config/schema.js';
|
||||
import type { REDIRECT_STATUS_CODES } from '../../core/constants.js';
|
||||
import type { AstroCookieSetOptions } from '../../core/cookies/cookies.js';
|
||||
import type { Logger, LoggerLevel } from '../../core/logger/core.js';
|
||||
import type { EnvSchema } from '../../env/schema.js';
|
||||
import type { AstroIntegration } from './integrations.js';
|
||||
|
||||
export type Locales = (string | { codes: string[]; path: string })[];
|
||||
|
||||
type NormalizeLocales<T extends Locales> = {
|
||||
|
@ -96,6 +97,53 @@ export type ServerConfig = {
|
|||
open?: string | boolean;
|
||||
};
|
||||
|
||||
export type SessionDriverName = BuiltinDriverName | 'custom' | 'test';
|
||||
|
||||
interface CommonSessionConfig {
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
cookie?:
|
||||
| string
|
||||
| (Omit<AstroCookieSetOptions, 'httpOnly' | 'expires' | 'encode'> & { name?: string });
|
||||
|
||||
/**
|
||||
* Default session duration in seconds. If not set, the session will be stored until deleted, or until the cookie expires.
|
||||
*/
|
||||
ttl?: number;
|
||||
}
|
||||
|
||||
interface BuiltinSessionConfig<TDriver extends keyof BuiltinDriverOptions>
|
||||
extends CommonSessionConfig {
|
||||
driver: TDriver;
|
||||
options?: BuiltinDriverOptions[TDriver];
|
||||
}
|
||||
|
||||
interface CustomSessionConfig extends CommonSessionConfig {
|
||||
/** Entrypoint for a custom session driver */
|
||||
driver: string;
|
||||
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>
|
||||
: TDriver extends 'test'
|
||||
? TestSessionConfig
|
||||
: CustomSessionConfig;
|
||||
|
||||
export type ResolvedSessionConfig<TDriver extends SessionDriverName> = SessionConfig<TDriver> & {
|
||||
driverModule?: () => Promise<{ default: () => Driver }>;
|
||||
};
|
||||
|
||||
export interface ViteUserConfig extends OriginalViteUserConfig {
|
||||
ssr?: ViteSSROptions;
|
||||
}
|
||||
|
@ -113,7 +161,10 @@ export interface ViteUserConfig extends OriginalViteUserConfig {
|
|||
* Docs: https://docs.astro.build/reference/configuration-reference/
|
||||
*
|
||||
* Generics do not follow semver and may change at any time.
|
||||
*/ export interface AstroUserConfig<TLocales extends Locales = never> {
|
||||
*/ export interface AstroUserConfig<
|
||||
TLocales extends Locales = never,
|
||||
TSession extends SessionDriverName = never,
|
||||
> {
|
||||
/**
|
||||
* @docs
|
||||
* @kind heading
|
||||
|
@ -1906,6 +1957,50 @@ export interface ViteUserConfig extends OriginalViteUserConfig {
|
|||
|
||||
responsiveImages?: boolean;
|
||||
|
||||
/**
|
||||
*
|
||||
* @name experimental.session
|
||||
* @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
|
||||
* ---
|
||||
* export const prerender = false; // Not needed in 'server' mode
|
||||
* const cart = await Astro.session.get('cart');
|
||||
* ---
|
||||
*
|
||||
* <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.
|
||||
*
|
||||
* You can specify [any driver from Unstorage](https://unstorage.unjs.io/drivers) or provide a custom config which will override your adapter's default.
|
||||
*
|
||||
* ```js title="astro.config.mjs"
|
||||
* {
|
||||
* experimental: {
|
||||
* session: {
|
||||
* // Required: the name of the Unstorage driver
|
||||
* driver: "redis",
|
||||
* // 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
|
||||
|
|
|
@ -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
|
||||
*/
|
||||
|
|
|
@ -192,5 +192,6 @@ export function createDevelopmentManifest(settings: AstroSettings): SSRManifest
|
|||
onRequest: NOOP_MIDDLEWARE_FN,
|
||||
};
|
||||
},
|
||||
sessionConfig: settings.config.experimental.session,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -15,6 +15,7 @@ import { 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';
|
||||
|
@ -223,6 +224,8 @@ export async function handleRoute({
|
|||
renderContext.props.error = err;
|
||||
response = await renderContext.render(preloaded500Component);
|
||||
statusCode = 500;
|
||||
} finally {
|
||||
renderContext.session?.[PERSIST_SYMBOL]();
|
||||
}
|
||||
|
||||
if (isLoggedRequest(pathname)) {
|
||||
|
|
14
packages/astro/test/fixtures/sessions/astro.config.mjs
vendored
Normal file
14
packages/astro/test/fixtures/sessions/astro.config.mjs
vendored
Normal file
|
@ -0,0 +1,14 @@
|
|||
// @ts-check
|
||||
import { defineConfig } from 'astro/config';
|
||||
import testAdapter from '../../test-adapter.js';
|
||||
|
||||
export default defineConfig({
|
||||
adapter: testAdapter(),
|
||||
output: 'server',
|
||||
experimental: {
|
||||
session: {
|
||||
driver: 'fs',
|
||||
ttl: 20,
|
||||
},
|
||||
},
|
||||
});
|
8
packages/astro/test/fixtures/sessions/package.json
vendored
Normal file
8
packages/astro/test/fixtures/sessions/package.json
vendored
Normal file
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"name": "@test/sessions",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"astro": "workspace:*"
|
||||
}
|
||||
}
|
27
packages/astro/test/fixtures/sessions/src/actions/index.ts
vendored
Normal file
27
packages/astro/test/fixtures/sessions/src/actions/index.ts
vendored
Normal 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() };
|
||||
},
|
||||
}),
|
||||
};
|
49
packages/astro/test/fixtures/sessions/src/middleware.ts
vendored
Normal file
49
packages/astro/test/fixtures/sessions/src/middleware.ts
vendored
Normal 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();
|
||||
});
|
15
packages/astro/test/fixtures/sessions/src/pages/api.ts
vendored
Normal file
15
packages/astro/test/fixtures/sessions/src/pages/api.ts
vendored
Normal 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 });
|
||||
};
|
24
packages/astro/test/fixtures/sessions/src/pages/cart.astro
vendored
Normal file
24
packages/astro/test/fixtures/sessions/src/pages/cart.astro
vendored
Normal 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>
|
6
packages/astro/test/fixtures/sessions/src/pages/destroy.ts
vendored
Normal file
6
packages/astro/test/fixtures/sessions/src/pages/destroy.ts
vendored
Normal file
|
@ -0,0 +1,6 @@
|
|||
import type { APIRoute } from 'astro';
|
||||
|
||||
export const GET: APIRoute = async (context) => {
|
||||
await context.session.destroy();
|
||||
return Response.json({});
|
||||
};
|
13
packages/astro/test/fixtures/sessions/src/pages/index.astro
vendored
Normal file
13
packages/astro/test/fixtures/sessions/src/pages/index.astro
vendored
Normal 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>
|
6
packages/astro/test/fixtures/sessions/src/pages/regenerate.ts
vendored
Normal file
6
packages/astro/test/fixtures/sessions/src/pages/regenerate.ts
vendored
Normal file
|
@ -0,0 +1,6 @@
|
|||
import type { APIRoute } from 'astro';
|
||||
|
||||
export const GET: APIRoute = async (context) => {
|
||||
await context.session.regenerate();
|
||||
return Response.json({});
|
||||
};
|
11
packages/astro/test/fixtures/sessions/tsconfig.json
vendored
Normal file
11
packages/astro/test/fixtures/sessions/tsconfig.json
vendored
Normal file
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"extends": "astro/tsconfigs/base",
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"~/assets/*": ["src/assets/*"]
|
||||
},
|
||||
},
|
||||
"include": [".astro/types.d.ts", "**/*"],
|
||||
"exclude": ["dist"]
|
||||
}
|
431
packages/astro/test/units/sessions/astro-session.test.js
Normal file
431
packages/astro/test/units/sessions/astro-session.test.js
Normal file
|
@ -0,0 +1,431 @@
|
|||
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',
|
||||
ttl: 60,
|
||||
};
|
||||
|
||||
// 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', { data: 'value' }]])),
|
||||
setItem: async () => {},
|
||||
};
|
||||
|
||||
const session = createSession(defaultConfig, defaultMockCookies, mockStorage);
|
||||
|
||||
const value = await session.get('key');
|
||||
assert.equal(value, 'value');
|
||||
});
|
||||
|
||||
await t.test('should remove expired session data', async () => {
|
||||
const mockStorage = {
|
||||
get: async () => stringify(new Map([['key', { data: 'value', expires: -1 }]])),
|
||||
setItem: async () => {},
|
||||
};
|
||||
|
||||
const session = createSession(defaultConfig, defaultMockCookies, mockStorage);
|
||||
|
||||
const value = await session.get('key');
|
||||
|
||||
assert.equal(value, undefined);
|
||||
});
|
||||
|
||||
|
||||
|
||||
});
|
||||
|
||||
|
||||
|
||||
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', { data: 'original' }],
|
||||
['delete', { data: 'remove' }],
|
||||
['update', { data: '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/,
|
||||
);
|
||||
});
|
||||
});
|
|
@ -16,7 +16,6 @@ type Props = {
|
|||
const StaticHtml = ({ value, name, hydrate = true }: Props) => {
|
||||
if (!value) return null;
|
||||
const tagName = hydrate ? 'astro-slot' : 'astro-static-slot';
|
||||
// @ts-expect-error pass `name` as a prop, ignoring type errors
|
||||
return h(tagName, { name, dangerouslySetInnerHTML: { __html: value } });
|
||||
};
|
||||
|
||||
|
|
1384
pnpm-lock.yaml
1384
pnpm-lock.yaml
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue