Skip to content

Commit b4fec3c

Browse files
ascorbicsarah11918ematipico
authored
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 <[email protected]> * Update packages/astro/src/types/public/config.ts Co-authored-by: Sarah Rainsberger <[email protected]> * Add link to Sessions RFC in config.ts * Move session into experimental --------- Co-authored-by: Sarah Rainsberger <[email protected]> * 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 <[email protected]> --------- Co-authored-by: Emanuele Stoppa <[email protected]> * 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 <[email protected]> --------- Co-authored-by: Sarah Rainsberger <[email protected]> Co-authored-by: Emanuele Stoppa <[email protected]> Co-authored-by: Sarah Rainsberger <[email protected]>
1 parent 3dc02c5 commit b4fec3c

File tree

27 files changed

+2174
-520
lines changed

27 files changed

+2174
-520
lines changed

.changeset/poor-mangos-fold.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
---
2+
'astro': minor
3+
---
4+
5+
Adds experimental session support
6+
7+
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.
8+
9+
```astro
10+
---
11+
export const prerender = false; // Not needed in 'server' mode
12+
const cart = await Astro.session.get('cart');
13+
---
14+
15+
<a href="/checkout">🛒 {cart?.length ?? 0} items</a>
16+
```
17+
18+
Sessions are available in on-demand rendered/SSR pages, API endpoints, actions and middleware. To enable session support, you must configure a storage driver.
19+
20+
If you are using the Node.js adapter, you can use the `fs` driver to store session data on the filesystem:
21+
22+
```js
23+
// astro.config.mjs
24+
{
25+
adapter: node({ mode: 'standalone' }),
26+
experimental: {
27+
session: {
28+
// Required: the name of the Unstorage driver
29+
driver: "fs",
30+
},
31+
},
32+
}
33+
```
34+
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.
35+
36+
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).

packages/astro/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,7 @@
166166
"tsconfck": "^3.1.4",
167167
"ultrahtml": "^1.5.3",
168168
"unist-util-visit": "^5.0.0",
169+
"unstorage": "^1.12.0",
169170
"vfile": "^6.0.3",
170171
"vite": "^6.0.1",
171172
"vitefu": "^1.0.4",

packages/astro/src/config/index.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,21 @@
11
import type { UserConfig as ViteUserConfig, UserConfigFn as ViteUserConfigFn } from 'vite';
22
import { createRouteManifest } from '../core/routing/index.js';
3-
import type { AstroInlineConfig, AstroUserConfig, Locales } from '../types/public/config.js';
3+
import type {
4+
AstroInlineConfig,
5+
AstroUserConfig,
6+
Locales,
7+
SessionDriverName,
8+
} from '../types/public/config.js';
49
import { createDevelopmentManifest } from '../vite-plugin-astro-server/plugin.js';
510

611
/**
712
* See the full Astro Configuration API Documentation
813
* https://astro.build/config
914
*/
10-
export function defineConfig<const TLocales extends Locales = never>(
11-
config: AstroUserConfig<TLocales>,
12-
) {
15+
export function defineConfig<
16+
const TLocales extends Locales = never,
17+
const TDriver extends SessionDriverName = never,
18+
>(config: AstroUserConfig<TLocales, TDriver>) {
1319
return config;
1420
}
1521

packages/astro/src/core/app/index.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import { createAssetLink } from '../render/ssr-element.js';
2323
import { ensure404Route } from '../routing/astro-designed-error-pages.js';
2424
import { createDefaultRoutes } from '../routing/default.js';
2525
import { matchRoute } from '../routing/match.js';
26+
import { type AstroSession, PERSIST_SYMBOL } from '../session.js';
2627
import { AppPipeline } from './pipeline.js';
2728

2829
export { deserializeManifest } from './common.js';
@@ -277,6 +278,7 @@ export class App {
277278
const defaultStatus = this.#getDefaultStatusCode(routeData, pathname);
278279

279280
let response;
281+
let session: AstroSession | undefined;
280282
try {
281283
// Load route module. We also catch its error here if it fails on initialization
282284
const mod = await this.#pipeline.getModuleForRoute(routeData);
@@ -290,10 +292,13 @@ export class App {
290292
status: defaultStatus,
291293
clientAddress,
292294
});
295+
session = renderContext.session;
293296
response = await renderContext.render(await mod.page());
294297
} catch (err: any) {
295298
this.#logger.error(null, err.stack || err.message || String(err));
296299
return this.#renderError(request, { locals, status: 500, error: err, clientAddress });
300+
} finally {
301+
session?.[PERSIST_SYMBOL]();
297302
}
298303

299304
if (
@@ -379,6 +384,7 @@ export class App {
379384
}
380385
}
381386
const mod = await this.#pipeline.getModuleForRoute(errorRouteData);
387+
let session: AstroSession | undefined;
382388
try {
383389
const renderContext = await RenderContext.create({
384390
locals,
@@ -391,6 +397,7 @@ export class App {
391397
props: { error },
392398
clientAddress,
393399
});
400+
session = renderContext.session;
394401
const response = await renderContext.render(await mod.page());
395402
return this.#mergeResponses(response, originalResponse);
396403
} catch {
@@ -404,6 +411,8 @@ export class App {
404411
clientAddress,
405412
});
406413
}
414+
} finally {
415+
session?.[PERSIST_SYMBOL]();
407416
}
408417
}
409418

packages/astro/src/core/app/types.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { RoutingStrategies } from '../../i18n/utils.js';
22
import type { ComponentInstance, SerializedRouteData } from '../../types/astro.js';
33
import type { AstroMiddlewareInstance } from '../../types/public/common.js';
4-
import type { Locales } from '../../types/public/config.js';
4+
import type { Locales, ResolvedSessionConfig, SessionConfig } from '../../types/public/config.js';
55
import type {
66
RouteData,
77
SSRComponentMetadata,
@@ -69,6 +69,7 @@ export type SSRManifest = {
6969
i18n: SSRManifestI18n | undefined;
7070
middleware?: () => Promise<AstroMiddlewareInstance> | AstroMiddlewareInstance;
7171
checkOrigin: boolean;
72+
sessionConfig?: ResolvedSessionConfig<any>
7273
};
7374

7475
export type SSRManifestI18n = {

packages/astro/src/core/build/plugins/plugin-manifest.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,14 +22,15 @@ import { type BuildInternals, cssOrder, mergeInlineCss } from '../internal.js';
2222
import type { AstroBuildPlugin } from '../plugin.js';
2323
import type { StaticBuildOptions } from '../types.js';
2424
import { makePageDataKey } from './util.js';
25+
import { resolveSessionDriver } from '../../session.js';
2526

2627
const manifestReplace = '@@ASTRO_MANIFEST_REPLACE@@';
2728
const replaceExp = new RegExp(`['"]${manifestReplace}['"]`, 'g');
2829

2930
export const SSR_MANIFEST_VIRTUAL_MODULE_ID = '@astrojs-manifest';
3031
export const RESOLVED_SSR_MANIFEST_VIRTUAL_MODULE_ID = '\0' + SSR_MANIFEST_VIRTUAL_MODULE_ID;
3132

32-
function vitePluginManifest(_options: StaticBuildOptions, internals: BuildInternals): VitePlugin {
33+
function vitePluginManifest(options: StaticBuildOptions, internals: BuildInternals): VitePlugin {
3334
return {
3435
name: '@astro/plugin-build-manifest',
3536
enforce: 'post',
@@ -52,11 +53,16 @@ function vitePluginManifest(_options: StaticBuildOptions, internals: BuildIntern
5253
`import { deserializeManifest as _deserializeManifest } from 'astro/app'`,
5354
`import { _privateSetManifestDontUseThis } from 'astro:ssr-manifest'`,
5455
];
56+
57+
const resolvedDriver = await resolveSessionDriver(options.settings.config.experimental?.session?.driver);
58+
5559
const contents = [
5660
`const manifest = _deserializeManifest('${manifestReplace}');`,
61+
`if (manifest.sessionConfig) manifest.sessionConfig.driverModule = ${resolvedDriver ? `() => import(${JSON.stringify(resolvedDriver)})` : 'null'};`,
5762
`_privateSetManifestDontUseThis(manifest);`,
5863
];
5964
const exports = [`export { manifest }`];
65+
6066
return [...imports, ...contents, ...exports].join('\n');
6167
}
6268
},
@@ -290,5 +296,6 @@ function buildManifest(
290296
(settings.config.security?.checkOrigin && settings.buildOutput === 'server') ?? false,
291297
serverIslandNameMap: Array.from(settings.serverIslandNameMap),
292298
key: encodedKey,
299+
sessionConfig: settings.config.experimental.session,
293300
};
294301
}

packages/astro/src/core/config/schema.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -536,6 +536,32 @@ export const AstroConfigSchema = z.object({
536536
.boolean()
537537
.optional()
538538
.default(ASTRO_CONFIG_DEFAULTS.experimental.responsiveImages),
539+
session: z
540+
.object({
541+
driver: z.string(),
542+
options: z.record(z.any()).optional(),
543+
cookie: z
544+
.union([
545+
z.object({
546+
name: z.string().optional(),
547+
domain: z.string().optional(),
548+
path: z.string().optional(),
549+
maxAge: z.number().optional(),
550+
sameSite: z.union([z.enum(['strict', 'lax', 'none']), z.boolean()]).optional(),
551+
secure: z.boolean().optional(),
552+
}),
553+
z.string(),
554+
])
555+
.transform((val) => {
556+
if (typeof val === 'string') {
557+
return { name: val };
558+
}
559+
return val;
560+
})
561+
.optional(),
562+
ttl: z.number().optional(),
563+
})
564+
.optional(),
539565
svg: z
540566
.union([
541567
z.boolean(),

packages/astro/src/core/errors/errors-data.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -868,6 +868,36 @@ export const AstroResponseHeadersReassigned = {
868868
hint: 'Consider using `Astro.response.headers.add()`, and `Astro.response.headers.delete()`.',
869869
} satisfies ErrorData;
870870

871+
/**
872+
* @docs
873+
* @see
874+
* - [experimental.session](https://5-0-0-beta.docs.astro.build/en/reference/configuration-reference/#experimentalsession)
875+
* @description
876+
* Thrown when the session storage could not be initialized.
877+
*/
878+
export const SessionStorageInitError = {
879+
name: 'SessionStorageInitError',
880+
title: 'Session storage could not be initialized.',
881+
message: (error: string, driver?: string) =>
882+
`Error when initializing session storage${driver ? ` with driver ${driver}` : ''}. ${error ?? ''}`,
883+
hint: 'For more information, see https://5-0-0-beta.docs.astro.build/en/reference/configuration-reference/#experimentalsession',
884+
} satisfies ErrorData;
885+
886+
/**
887+
* @docs
888+
* @see
889+
* - [experimental.session](https://5-0-0-beta.docs.astro.build/en/reference/configuration-reference/#experimentalsession)
890+
* @description
891+
* Thrown when the session data could not be saved.
892+
*/
893+
export const SessionStorageSaveError = {
894+
name: 'SessionStorageSaveError',
895+
title: 'Session data could not be saved.',
896+
message: (error: string, driver?: string) =>
897+
`Error when saving session data${driver ? ` with driver ${driver}` : ''}. ${error ?? ''}`,
898+
hint: 'For more information, see https://5-0-0-beta.docs.astro.build/en/reference/configuration-reference/#experimentalsession',
899+
} satisfies ErrorData;
900+
871901
/**
872902
* @docs
873903
* @description

packages/astro/src/core/render-context.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import { type Pipeline, Slots, getParams, getProps } from './render/index.js';
3232
import { isRoute404or500 } from './routing/match.js';
3333
import { copyRequest, getOriginPathname, setOriginPathname } from './routing/rewrite.js';
3434
import { SERVER_ISLAND_COMPONENT } from './server-islands/endpoint.js';
35+
import { AstroSession } from './session.js';
3536

3637
export const apiContextRoutesSymbol = Symbol.for('context.routes');
3738

@@ -54,6 +55,9 @@ export class RenderContext {
5455
protected url = new URL(request.url),
5556
public props: Props = {},
5657
public partial: undefined | boolean = undefined,
58+
public session: AstroSession | undefined = pipeline.manifest.sessionConfig
59+
? new AstroSession(cookies, pipeline.manifest.sessionConfig)
60+
: undefined,
5761
) {}
5862

5963
/**
@@ -300,7 +304,7 @@ export class RenderContext {
300304

301305
createActionAPIContext(): ActionAPIContext {
302306
const renderContext = this;
303-
const { cookies, params, pipeline, url } = this;
307+
const { cookies, params, pipeline, url, session } = this;
304308
const generator = `Astro v${ASTRO_VERSION}`;
305309

306310
const rewrite = async (reroutePayload: RewritePayload) => {
@@ -338,6 +342,7 @@ export class RenderContext {
338342
get originPathname() {
339343
return getOriginPathname(renderContext.request);
340344
},
345+
session,
341346
};
342347
}
343348

@@ -470,7 +475,7 @@ export class RenderContext {
470475
astroStaticPartial: AstroGlobalPartial,
471476
): Omit<AstroGlobal, 'props' | 'self' | 'slots'> {
472477
const renderContext = this;
473-
const { cookies, locals, params, pipeline, url } = this;
478+
const { cookies, locals, params, pipeline, url, session } = this;
474479
const { response } = result;
475480
const redirect = (path: string, status = 302) => {
476481
// If the response is already sent, error as we cannot proceed with the redirect.
@@ -492,6 +497,7 @@ export class RenderContext {
492497
routePattern: this.routeData.route,
493498
isPrerendered: this.routeData.prerender,
494499
cookies,
500+
session,
495501
get clientAddress() {
496502
return renderContext.getClientAddress();
497503
},

0 commit comments

Comments
 (0)