Skip to content

Commit

Permalink
Sessions API (withastro#12441)
Browse files Browse the repository at this point in the history
* wip: experimental sessions

* feat: adds session options (withastro#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 (withastro#12471)

* feat: add session object

* Add tests and fix logic

* Fixes

* Allow string as cookie option

* wip: implement sessions (withastro#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 (withastro#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 (withastro#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 (withastro#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 (withastro#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>
  • Loading branch information
4 people authored Dec 18, 2024
1 parent 3dc02c5 commit b4fec3c
Show file tree
Hide file tree
Showing 27 changed files with 2,174 additions and 520 deletions.
36 changes: 36 additions & 0 deletions .changeset/poor-mangos-fold.md
Original file line number Diff line number Diff line change
@@ -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).
1 change: 1 addition & 0 deletions packages/astro/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
14 changes: 10 additions & 4 deletions packages/astro/src/config/index.ts
Original file line number Diff line number Diff line change
@@ -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;
}

Expand Down
9 changes: 9 additions & 0 deletions packages/astro/src/core/app/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand All @@ -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 (
Expand Down Expand Up @@ -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,
Expand All @@ -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 {
Expand All @@ -404,6 +411,8 @@ export class App {
clientAddress,
});
}
} finally {
session?.[PERSIST_SYMBOL]();
}
}

Expand Down
3 changes: 2 additions & 1 deletion packages/astro/src/core/app/types.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -69,6 +69,7 @@ export type SSRManifest = {
i18n: SSRManifestI18n | undefined;
middleware?: () => Promise<AstroMiddlewareInstance> | AstroMiddlewareInstance;
checkOrigin: boolean;
sessionConfig?: ResolvedSessionConfig<any>
};

export type SSRManifestI18n = {
Expand Down
9 changes: 8 additions & 1 deletion packages/astro/src/core/build/plugins/plugin-manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,15 @@ 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');

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',
Expand All @@ -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');
}
},
Expand Down Expand Up @@ -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,
};
}
26 changes: 26 additions & 0 deletions packages/astro/src/core/config/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
30 changes: 30 additions & 0 deletions packages/astro/src/core/errors/errors-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 8 additions & 2 deletions packages/astro/src/core/render-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand All @@ -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,
) {}

/**
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -338,6 +342,7 @@ export class RenderContext {
get originPathname() {
return getOriginPathname(renderContext.request);
},
session,
};
}

Expand Down Expand Up @@ -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.
Expand All @@ -492,6 +497,7 @@ export class RenderContext {
routePattern: this.routeData.route,
isPrerendered: this.routeData.prerender,
cookies,
session,
get clientAddress() {
return renderContext.getClientAddress();
},
Expand Down
Loading

0 comments on commit b4fec3c

Please sign in to comment.