diff --git a/.changeset/cool-deers-join.md b/.changeset/cool-deers-join.md new file mode 100644 index 000000000000..113efe5565b8 --- /dev/null +++ b/.changeset/cool-deers-join.md @@ -0,0 +1,9 @@ +--- +'@astrojs/node': minor +--- + +Automatically configures filesystem storage when experimental session enabled + +If the `experimental.session` flag is enabled when using the Node adapter, Astro will automatically configure session storage using the filesystem driver. You can still manually configure session storage if you need to use a different driver or want to customize the session storage configuration. + +See [the experimental session docs](https://docs.astro.build/en/reference/experimental-flags/sessions/) for more information on configuring session storage. diff --git a/.changeset/tame-games-enjoy.md b/.changeset/tame-games-enjoy.md new file mode 100644 index 000000000000..c041923e7889 --- /dev/null +++ b/.changeset/tame-games-enjoy.md @@ -0,0 +1,7 @@ +--- +'astro': minor +--- + +Adds support for adapters auto-configuring experimental session storage drivers. + +Adapters can now configure a default session storage driver when the `experimental.session` flag is enabled. If a hosting platform has a storage primitive that can be used for session storage, the adapter can automatically configure the session storage using that driver. This allows Astro to provide a more seamless experience for users who want to use sessions without needing to manually configure the session storage. diff --git a/.changeset/thin-cobras-glow.md b/.changeset/thin-cobras-glow.md new file mode 100644 index 000000000000..af633a0695ac --- /dev/null +++ b/.changeset/thin-cobras-glow.md @@ -0,0 +1,9 @@ +--- +'@astrojs/netlify': minor +--- + +Automatically configures Netlify Blobs storage when experimental session enabled + +If the `experimental.session` flag is enabled when using the Netlify adapter, Astro will automatically configure the session storage using the Netlify Blobs driver. You can still manually configure the session storage if you need to use a different driver or want to customize the session storage configuration. + +See [the experimental session docs](https://docs.astro.build/en/reference/experimental-flags/sessions/) for more information on configuring session storage. diff --git a/.changeset/tricky-insects-argue.md b/.changeset/tricky-insects-argue.md new file mode 100644 index 000000000000..6b72528177dc --- /dev/null +++ b/.changeset/tricky-insects-argue.md @@ -0,0 +1,44 @@ +--- +'astro': patch +--- + +:warning: **BREAKING CHANGE FOR EXPERIMENTAL SESSIONS ONLY** :warning: + +Changes the `experimental.session` option to a boolean flag and moves session config to a top-level value. This change is to allow the new automatic session driver support. You now need to separately enable the `experimental.session` flag, and then configure the session driver using the top-level `session` key if providing manual configuration. + +```diff +defineConfig({ + // ... + experimental: { +- session: { +- driver: 'upstash', +- }, ++ session: true, + }, ++ session: { ++ driver: 'upstash', ++ }, +}); +``` + +You no longer need to configure a session driver if you are using an adapter that supports automatic session driver configuration and wish to use its default settings. + +```diff +defineConfig({ + adapter: node({ + mode: "standalone", + }), + experimental: { +- session: { +- driver: 'fs', +- cookie: 'astro-cookie', +- }, ++ session: true, + }, ++ session: { ++ cookie: 'astro-cookie', ++ }, +}); +``` + +However, you can still manually configure additional driver options or choose a non-default driver to use with your adapter with the new top-level `session` config option. For more information, see the [experimental session docs](https://docs.astro.build/en/reference/experimental-flags/sessions/). diff --git a/packages/astro/src/core/build/plugins/plugin-manifest.ts b/packages/astro/src/core/build/plugins/plugin-manifest.ts index de9645e0a2d0..f051b128771d 100644 --- a/packages/astro/src/core/build/plugins/plugin-manifest.ts +++ b/packages/astro/src/core/build/plugins/plugin-manifest.ts @@ -54,9 +54,7 @@ function vitePluginManifest(options: StaticBuildOptions, internals: BuildInterna `import { _privateSetManifestDontUseThis } from 'astro:ssr-manifest'`, ]; - const resolvedDriver = await resolveSessionDriver( - options.settings.config.experimental?.session?.driver, - ); + const resolvedDriver = await resolveSessionDriver(options.settings.config.session?.driver); const contents = [ `const manifest = _deserializeManifest('${manifestReplace}');`, @@ -304,6 +302,6 @@ function buildManifest( (settings.config.security?.checkOrigin && settings.buildOutput === 'server') ?? false, serverIslandNameMap: Array.from(settings.serverIslandNameMap), key: encodedKey, - sessionConfig: settings.config.experimental.session, + sessionConfig: settings.config.session, }; } diff --git a/packages/astro/src/core/config/schema.ts b/packages/astro/src/core/config/schema.ts index 777e8df2665e..83546aeb9a9e 100644 --- a/packages/astro/src/core/config/schema.ts +++ b/packages/astro/src/core/config/schema.ts @@ -92,12 +92,14 @@ export const ASTRO_CONFIG_DEFAULTS = { schema: {}, validateSecrets: false, }, + session: undefined, experimental: { clientPrerender: false, contentIntellisense: false, responsiveImages: false, svg: false, serializeConfig: false, + session: false, }, } satisfies AstroUserConfig & { server: { open: boolean } }; @@ -522,6 +524,30 @@ export const AstroConfigSchema = z.object({ .strict() .optional() .default(ASTRO_CONFIG_DEFAULTS.env), + session: z + .object({ + driver: z.string(), + options: z.record(z.any()).optional(), + cookie: 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(), + }) + .or(z.string()) + .transform((val) => { + if (typeof val === 'string') { + return { name: val }; + } + return val; + }) + .optional(), + ttl: z.number().optional(), + }) + .optional(), experimental: z .object({ clientPrerender: z @@ -536,32 +562,7 @@ 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(), + session: z.boolean().optional(), svg: z .union([ z.boolean(), diff --git a/packages/astro/src/core/errors/errors-data.ts b/packages/astro/src/core/errors/errors-data.ts index 9605373a8ff3..96cd0d53e2f0 100644 --- a/packages/astro/src/core/errors/errors-data.ts +++ b/packages/astro/src/core/errors/errors-data.ts @@ -881,38 +881,6 @@ export const AstroResponseHeadersReassigned = { hint: 'Consider using `Astro.response.headers.add()`, and `Astro.response.headers.delete()`.', } satisfies ErrorData; -/** - * @docs - * @message Error when initializing session storage with driver `DRIVER`. `ERROR` - * @see - * - [experimental.session](https://docs.astro.build/en/reference/experimental-flags/sessions/) - * @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://docs.astro.build/en/reference/experimental-flags/sessions/', -} satisfies ErrorData; - -/** - * @docs - * @message Error when saving session data with driver `DRIVER`. `ERROR` - * @see - * - [experimental.session](https://docs.astro.build/en/reference/experimental-flags/sessions/) - * @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://docs.astro.build/en/reference/experimental-flags/sessions/', -} satisfies ErrorData; - /** * @docs * @description @@ -1838,6 +1806,90 @@ export const ActionCalledFromServerError = { // Generic catch-all - Only use this in extreme cases, like if there was a cosmic ray bit flip. export const UnknownError = { name: 'UnknownError', title: 'Unknown Error.' } satisfies ErrorData; +/** + * @docs + * @kind heading + * @name Session Errors + */ +// Session Errors +/** + * @docs + * @see + * - [On-demand rendering](https://docs.astro.build/en/guides/on-demand-rendering/) + * @description + * Your project must have a server output to use sessions. + */ +export const SessionWithoutServerOutputError = { + name: 'SessionWithoutServerOutputError', + title: 'Sessions must be used with server output.', + message: + 'A server is required to use sessions. To deploy routes to a server, add an adapter to your Astro config and configure your route for on-demand rendering', + hint: 'Add an adapter and enable on-demand rendering: https://docs.astro.build/en/guides/on-demand-rendering/', +} satisfies ErrorData; + +/** + * @docs + * @message Error when initializing session storage with driver `DRIVER`. `ERROR` + * @see + * - [experimental.session](https://docs.astro.build/en/reference/experimental-flags/sessions/) + * @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://docs.astro.build/en/reference/experimental-flags/sessions/', +} satisfies ErrorData; + +/** + * @docs + * @message Error when saving session data with driver `DRIVER`. `ERROR` + * @see + * - [experimental.session](https://docs.astro.build/en/reference/experimental-flags/sessions/) + * @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://docs.astro.build/en/reference/experimental-flags/sessions/', +} satisfies ErrorData; + +/** + * @docs + * @message The `experimental.session` flag was set to `true`, but no storage was configured. Either configure the storage manually or use an adapter that provides session storage + * @see + * - [experimental.session](https://docs.astro.build/en/reference/experimental-flags/sessions/) + * @description + * Thrown when session storage is enabled but not configured. + */ +export const SessionConfigMissingError = { + name: 'SessionConfigMissingError', + title: 'Session storage was enabled but not configured.', + message: + 'The `experimental.session` flag was set to `true`, but no storage was configured. Either configure the storage manually or use an adapter that provides session storage', + hint: 'See https://docs.astro.build/en/reference/experimental-flags/sessions/', +} satisfies ErrorData; + +/** + * @docs + * @message Session config was provided without enabling the `experimental.session` flag + * @see + * - [experimental.session](https://docs.astro.build/en/reference/experimental-flags/sessions/) + * @description + * Thrown when session storage is configured but the `experimental.session` flag is not enabled. + */ +export const SessionConfigWithoutFlagError = { + name: 'SessionConfigWithoutFlagError', + title: 'Session flag not set', + message: 'Session config was provided without enabling the `experimental.session` flag', + hint: 'See https://docs.astro.build/en/reference/experimental-flags/sessions/', +} satisfies ErrorData; + /* * Adding an error? Follow these steps: * 1. Determine in which category it belongs (Astro, Vite, CSS, Content Collections etc.) diff --git a/packages/astro/src/core/session.ts b/packages/astro/src/core/session.ts index ccb01ef6117d..f05d2914c2ac 100644 --- a/packages/astro/src/core/session.ts +++ b/packages/astro/src/core/session.ts @@ -6,6 +6,7 @@ import { builtinDrivers, createStorage, } from 'unstorage'; +import type { AstroSettings } from '../types/astro.js'; import type { ResolvedSessionConfig, SessionConfig, @@ -13,7 +14,13 @@ import type { } 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 { + SessionConfigMissingError, + SessionConfigWithoutFlagError, + SessionStorageInitError, + SessionStorageSaveError, + SessionWithoutServerOutputError, +} from './errors/errors-data.js'; import { AstroError } from './errors/index.js'; export const PERSIST_SYMBOL = Symbol(); @@ -462,15 +469,39 @@ export class AstroSession { } } // TODO: make this sync when we drop support for Node < 18.19.0 -export function resolveSessionDriver(driver: string | undefined): Promise | string | null { +export async function resolveSessionDriver(driver: string | undefined): Promise { 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]); + try { + if (driver === 'fs') { + return await import.meta.resolve(builtinDrivers.fsLite); + } + if (driver in builtinDrivers) { + return await import.meta.resolve(builtinDrivers[driver as keyof typeof builtinDrivers]); + } + } catch { + return null; } + return driver; } + +export function validateSessionConfig(settings: AstroSettings): void { + const { experimental, session } = settings.config; + const { buildOutput } = settings; + let error: AstroError | undefined; + if (experimental.session) { + if (!session?.driver) { + error = new AstroError(SessionConfigMissingError); + } else if (buildOutput === 'static') { + error = new AstroError(SessionWithoutServerOutputError); + } + } else if (session?.driver) { + error = new AstroError(SessionConfigWithoutFlagError); + } + if (error) { + error.stack = undefined; + throw error; + } +} diff --git a/packages/astro/src/integrations/hooks.ts b/packages/astro/src/integrations/hooks.ts index 5a4b723b2000..17a5a7dbda31 100644 --- a/packages/astro/src/integrations/hooks.ts +++ b/packages/astro/src/integrations/hooks.ts @@ -14,6 +14,7 @@ import { buildClientDirectiveEntrypoint } from '../core/client-directive/index.j import { mergeConfig } from '../core/config/index.js'; import { validateSetAdapter } from '../core/dev/adapter-validation.js'; import type { AstroIntegrationLogger, Logger } from '../core/logger/core.js'; +import { validateSessionConfig } from '../core/session.js'; import type { AstroSettings } from '../types/astro.js'; import type { AstroConfig } from '../types/public/config.js'; import type { @@ -369,6 +370,11 @@ export async function runHookConfigDone({ }); } } + // Session config is validated after all integrations have had a chance to + // register a default session driver, and we know the output type. + // This can't happen in the Zod schema because it that happens before adapters run + // and also doesn't know whether it's a server build or static build. + validateSessionConfig(settings); } export async function runHookServerSetup({ diff --git a/packages/astro/src/types/public/config.ts b/packages/astro/src/types/public/config.ts index a2b8ab8999e5..7f5920f85ab5 100644 --- a/packages/astro/src/types/public/config.ts +++ b/packages/astro/src/types/public/config.ts @@ -1724,6 +1724,36 @@ export interface ViteUserConfig extends OriginalViteUserConfig { validateSecrets?: boolean; }; + /** + * @docs + * @name session + * @type {SessionConfig} + * @version 5.3.0 + * @description + * + * Configures experimental session support by specifying a storage `driver` as well as any associated `options`. + * You must enable the `experimental.session` flag to use this feature. + * Some adapters may provide a default session driver, but you can override it with your own configuration. + * + * You can specify [any driver from Unstorage](https://unstorage.unjs.io/drivers) or provide a custom config which will override your adapter's default. + * + * See [the experimental session guide](https://docs.astro.build/en/reference/experimental-flags/sessions/) for more information. + * + * ```js title="astro.config.mjs" + * { + * session: { + * // Required: the name of the Unstorage driver + * driver: 'redis', + * // The required options depend on the driver + * options: { + * url: process.env.REDIS_URL, + * }, + * } + * } + * ``` + */ + session?: SessionConfig; + /** * * @kind heading @@ -1966,7 +1996,8 @@ export interface ViteUserConfig extends OriginalViteUserConfig { /** * * @name experimental.session - * @type {SessionConfig} + * @type {boolean} + * @default `false` * @version 5.0.0 * @description * @@ -1983,30 +2014,12 @@ export interface ViteUserConfig extends OriginalViteUserConfig { * 🛒 {cart?.length ?? 0} items * * ``` - * The object configures session management for your Astro site by specifying a `driver` as well as any `options` for your data storage. - * - * 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). + * For more details, see [the experimental session guide](https://docs.astro.build/en/reference/experimental-flags/sessions/). * */ - session?: SessionConfig; + session?: boolean; /** * * @name experimental.svg diff --git a/packages/astro/src/vite-plugin-astro-server/plugin.ts b/packages/astro/src/vite-plugin-astro-server/plugin.ts index 113a85804352..42146d856a5c 100644 --- a/packages/astro/src/vite-plugin-astro-server/plugin.ts +++ b/packages/astro/src/vite-plugin-astro-server/plugin.ts @@ -203,6 +203,6 @@ export function createDevelopmentManifest(settings: AstroSettings): SSRManifest onRequest: NOOP_MIDDLEWARE_FN, }; }, - sessionConfig: settings.config.experimental.session, + sessionConfig: settings.config.experimental.session ? settings.config.session : undefined, }; } diff --git a/packages/astro/test/fixtures/sessions/astro.config.mjs b/packages/astro/test/fixtures/sessions/astro.config.mjs index 7eb32d0cfb21..bcd1aed24e57 100644 --- a/packages/astro/test/fixtures/sessions/astro.config.mjs +++ b/packages/astro/test/fixtures/sessions/astro.config.mjs @@ -1,14 +1,6 @@ // @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, - }, - }, + }); diff --git a/packages/astro/test/fixtures/sessions/package.json b/packages/astro/test/fixtures/sessions/package.json index 9e73d9e21f58..453f09a96d4b 100644 --- a/packages/astro/test/fixtures/sessions/package.json +++ b/packages/astro/test/fixtures/sessions/package.json @@ -3,7 +3,6 @@ "version": "0.0.0", "private": true, "dependencies": { - "@netlify/blobs": "^8.1.0", "astro": "workspace:*" } } diff --git a/packages/astro/test/fixtures/sessions/src/pages/api.ts b/packages/astro/test/fixtures/sessions/src/pages/api.ts index 77d50625aab1..21793c78a74f 100644 --- a/packages/astro/test/fixtures/sessions/src/pages/api.ts +++ b/packages/astro/test/fixtures/sessions/src/pages/api.ts @@ -1,7 +1,5 @@ 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'); diff --git a/packages/astro/test/fixtures/sessions/tsconfig.json b/packages/astro/test/fixtures/sessions/tsconfig.json deleted file mode 100644 index c193287fccd6..000000000000 --- a/packages/astro/test/fixtures/sessions/tsconfig.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "extends": "astro/tsconfigs/base", - "compilerOptions": { - "baseUrl": ".", - "paths": { - "~/assets/*": ["src/assets/*"] - }, - }, - "include": [".astro/types.d.ts", "**/*"], - "exclude": ["dist"] -} diff --git a/packages/astro/test/sessions.test.js b/packages/astro/test/sessions.test.js index 1dbc304bd295..1cbfae6e84e3 100644 --- a/packages/astro/test/sessions.test.js +++ b/packages/astro/test/sessions.test.js @@ -1,25 +1,33 @@ import assert from 'node:assert/strict'; -import { before, describe, it } from 'node:test'; +import { after, before, describe, it } from 'node:test'; import * as devalue from 'devalue'; import testAdapter from './test-adapter.js'; import { loadFixture } from './test-utils.js'; describe('Astro.session', () => { - /** @type {import('./test-utils').Fixture} */ - let fixture; - - before(async () => { - fixture = await loadFixture({ - root: './fixtures/sessions/', - output: 'server', - adapter: testAdapter(), + describe('Production', () => { + /** @type {import('./test-utils').Fixture} */ + let fixture; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/sessions/', + output: 'server', + adapter: testAdapter(), + session: { + driver: 'fs', + ttl: 20, + }, + experimental: { + session: true, + }, + }); }); - }); - describe('Production', () => { + /** @type {import('../src/core/app/index').App} response */ let app; before(async () => { - await fixture.build(); + await fixture.build({}); app = await fixture.loadTestAdapterApp(); }); @@ -92,4 +100,149 @@ describe('Astro.session', () => { ); }); }); + + describe('Development', () => { + /** @type {import('./test-utils').Fixture} */ + let fixture; + let devServer; + before(async () => { + fixture = await loadFixture({ + root: './fixtures/sessions/', + output: 'server', + adapter: testAdapter(), + session: { + driver: 'fs', + ttl: 20, + }, + experimental: { + session: true, + }, + }); + devServer = await fixture.startDevServer(); + }); + + after(async () => { + await devServer.stop(); + }); + + it('can regenerate session cookies upon request', async () => { + const firstResponse = await fixture.fetch('/regenerate'); + // @ts-ignore + const firstHeaders = firstResponse.headers.get('set-cookie').split(','); + const firstSessionId = firstHeaders[0].split(';')[0].split('=')[1]; + + const secondResponse = await fixture.fetch('/regenerate', { + method: 'GET', + headers: { + cookie: `astro-session=${firstSessionId}`, + }, + }); + // @ts-ignore + const secondHeaders = secondResponse.headers.get('set-cookie').split(','); + const secondSessionId = secondHeaders[0].split(';')[0].split('=')[1]; + assert.notEqual(firstSessionId, secondSessionId); + }); + + it('can save session data by value', async () => { + const firstResponse = await fixture.fetch('/update'); + const firstValue = await firstResponse.json(); + assert.equal(firstValue.previousValue, 'none'); + + // @ts-ignore + const firstHeaders = firstResponse.headers.get('set-cookie').split(','); + const firstSessionId = firstHeaders[0].split(';')[0].split('=')[1]; + const secondResponse = await fixture.fetch('/update', { + method: 'GET', + headers: { + cookie: `astro-session=${firstSessionId}`, + }, + }); + const secondValue = await secondResponse.json(); + assert.equal(secondValue.previousValue, 'expected'); + }); + + it('can save and restore URLs in session data', async () => { + const firstResponse = await fixture.fetch('/_actions/addUrl', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ favoriteUrl: 'https://domain.invalid' }), + }); + + assert.equal(firstResponse.ok, true); + // @ts-ignore + const firstHeaders = firstResponse.headers.get('set-cookie').split(','); + const firstSessionId = firstHeaders[0].split(';')[0].split('=')[1]; + + const data = devalue.parse(await firstResponse.text()); + assert.equal(data.message, 'Favorite URL set to https://domain.invalid/ from nothing'); + const secondResponse = await fixture.fetch('/_actions/addUrl', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + cookie: `astro-session=${firstSessionId}`, + }, + body: JSON.stringify({ favoriteUrl: 'https://example.com' }), + }); + const secondData = devalue.parse(await secondResponse.text()); + assert.equal( + secondData.message, + 'Favorite URL set to https://example.com/ from https://domain.invalid/', + ); + }); + }); + + describe('Configuration', () => { + it('throws if flag is enabled but driver is not set', async () => { + const fixture = await loadFixture({ + root: './fixtures/sessions/', + output: 'server', + adapter: testAdapter(), + experimental: { + session: true, + }, + }); + await assert.rejects( + fixture.build({}), + /Error: The `experimental.session` flag was set to `true`, but no storage was configured/, + ); + }); + + it('throws if session is configured but flag is not enabled', async () => { + const fixture = await loadFixture({ + root: './fixtures/sessions/', + output: 'server', + adapter: testAdapter(), + session: { + driver: 'fs', + }, + experimental: { + session: false, + }, + }); + await assert.rejects( + fixture.build({}), + /Error: Session config was provided without enabling the `experimental.session` flag/, + ); + }); + + it('throws if output is static', async () => { + const fixture = await loadFixture({ + root: './fixtures/sessions/', + output: 'static', + session: { + driver: 'fs', + ttl: 20, + }, + experimental: { + session: true, + }, + }); + // Disable actions so we can do a static build + await fixture.editFile('src/actions/index.ts', () => ''); + await assert.rejects(fixture.build({}), /Error: A server is required to use session/); + await fixture.resetAllFiles(); + }); + }); }); diff --git a/packages/astro/test/test-utils.js b/packages/astro/test/test-utils.js index c33e43ca36b0..5b540a78f575 100644 --- a/packages/astro/test/test-utils.js +++ b/packages/astro/test/test-utils.js @@ -49,6 +49,7 @@ process.env.ASTRO_TELEMETRY_DISABLED = true; * @property {typeof check} check * @property {typeof sync} sync * @property {AstroConfig} config + * @property {() => void} resetAllFiles * * This function returns an instance of the Check * diff --git a/packages/integrations/netlify/package.json b/packages/integrations/netlify/package.json index 4386771b6352..1c0bd6a2e3ae 100644 --- a/packages/integrations/netlify/package.json +++ b/packages/integrations/netlify/package.json @@ -36,15 +36,16 @@ "test:hosted": "astro-scripts test \"test/hosted/*.test.js\"" }, "dependencies": { - "@astrojs/internal-helpers": "0.5.1", - "@astrojs/underscore-redirects": "^0.6.0", + "@astrojs/internal-helpers": "workspace:*", + "@astrojs/underscore-redirects": "workspace:*", + "@netlify/blobs": "^8.1.0", "@netlify/functions": "^2.8.0", "@vercel/nft": "^0.29.0", "esbuild": "^0.24.0", "vite": "^6.0.7" }, "peerDependencies": { - "astro": "^5.0.0" + "astro": "^5.3.0" }, "devDependencies": { "@netlify/edge-functions": "^2.11.1", @@ -53,6 +54,7 @@ "astro": "workspace:*", "astro-scripts": "workspace:*", "cheerio": "1.0.0", + "devalue": "^5.1.1", "execa": "^8.0.1", "fast-glob": "^3.3.3", "strip-ansi": "^7.1.0", diff --git a/packages/integrations/netlify/src/index.ts b/packages/integrations/netlify/src/index.ts index d7c1b14f1e5c..3f016a5e1a55 100644 --- a/packages/integrations/netlify/src/index.ts +++ b/packages/integrations/netlify/src/index.ts @@ -510,7 +510,7 @@ export default function netlifyIntegration( return { name: '@astrojs/netlify', hooks: { - 'astro:config:setup': async ({ config, updateConfig }) => { + 'astro:config:setup': async ({ config, updateConfig, logger }) => { rootDir = config.root; await cleanFunctions(); @@ -518,6 +518,32 @@ export default function netlifyIntegration( const enableImageCDN = isRunningInNetlify && (integrationConfig?.imageCDN ?? true); + let session = config.session; + + if (config.experimental.session && !session?.driver) { + logger.info( + `Configuring experimental session support using ${isRunningInNetlify ? 'Netlify Blobs' : 'filesystem storage'}`, + ); + session = isRunningInNetlify + ? { + ...session, + driver: 'netlify-blobs', + options: { + name: 'astro-sessions', + consistency: 'strong', + ...session?.options, + }, + } + : { + ...session, + driver: 'fs-lite', + options: { + base: fileURLToPath(new URL('sessions', config.cacheDir)), + ...session?.options, + }, + }; + } + updateConfig({ outDir, build: { @@ -525,6 +551,7 @@ export default function netlifyIntegration( client: outDir, server: ssrBuildDir(), }, + session, vite: { server: { watch: { diff --git a/packages/integrations/netlify/test/functions/fixtures/sessions/astro.config.mjs b/packages/integrations/netlify/test/functions/fixtures/sessions/astro.config.mjs new file mode 100644 index 000000000000..23d03b44fab3 --- /dev/null +++ b/packages/integrations/netlify/test/functions/fixtures/sessions/astro.config.mjs @@ -0,0 +1,11 @@ +import netlify from '@astrojs/netlify'; +import { defineConfig } from 'astro/config'; + +export default defineConfig({ + output: 'server', + adapter: netlify(), + site: `http://example.com`, + experimental: { + session: true, + } +}); diff --git a/packages/integrations/netlify/test/functions/fixtures/sessions/package.json b/packages/integrations/netlify/test/functions/fixtures/sessions/package.json new file mode 100644 index 000000000000..ed3de61f73ac --- /dev/null +++ b/packages/integrations/netlify/test/functions/fixtures/sessions/package.json @@ -0,0 +1,15 @@ +{ + "name": "@test/netlify-session", + "version": "0.0.0", + "private": true, + "dependencies": { + "@astrojs/netlify": "workspace:*" + }, + "devDependencies": { + "astro": "workspace:*" + }, + "scripts": { + "build": "astro build", + "start": "astro dev" + } +} diff --git a/packages/integrations/netlify/test/functions/fixtures/sessions/src/actions/index.ts b/packages/integrations/netlify/test/functions/fixtures/sessions/src/actions/index.ts new file mode 100644 index 000000000000..856f68ba8fb1 --- /dev/null +++ b/packages/integrations/netlify/test/functions/fixtures/sessions/src/actions/index.ts @@ -0,0 +1,36 @@ +import { defineAction } from 'astro:actions'; +import { z } from 'astro:schema'; + +export const server = { + addToCart: defineAction({ + accept: 'form', + input: z.object({ productId: z.string() }), + handler: async (input, context) => { + const cart: Array = (await context.session.get('cart')) || []; + cart.push(input.productId); + await context.session.set('cart', cart); + return { cart, message: 'Product added to cart at ' + new Date().toTimeString() }; + }, + }), + getCart: defineAction({ + handler: async (input, context) => { + return await context.session.get('cart'); + }, + }), + clearCart: defineAction({ + accept: 'json', + handler: async (input, context) => { + await context.session.set('cart', []); + return { cart: [], message: 'Cart cleared at ' + new Date().toTimeString() }; + }, + }), + addUrl: defineAction({ + input: z.object({ favoriteUrl: z.string().url() }), + handler: async (input, context) => { + const previousFavoriteUrl = await context.session.get('favoriteUrl'); + const url = new URL(input.favoriteUrl); + context.session.set('favoriteUrl', url); + return { message: 'Favorite URL set to ' + url.href + ' from ' + (previousFavoriteUrl?.href ?? "nothing") }; + } + }) +} diff --git a/packages/integrations/netlify/test/functions/fixtures/sessions/src/middleware.ts b/packages/integrations/netlify/test/functions/fixtures/sessions/src/middleware.ts new file mode 100644 index 000000000000..7f56f11f364f --- /dev/null +++ b/packages/integrations/netlify/test/functions/fixtures/sessions/src/middleware.ts @@ -0,0 +1,49 @@ +import { defineMiddleware } from 'astro:middleware'; +import { getActionContext } from 'astro:actions'; + +const ACTION_SESSION_KEY = 'actionResult' + +export const onRequest = defineMiddleware(async (context, next) => { + // Skip requests for prerendered pages + if (context.isPrerendered) return next(); + + const { action, setActionResult, serializeActionResult } = + getActionContext(context); + + console.log(action?.name) + + const actionPayload = await context.session.get(ACTION_SESSION_KEY); + + if (actionPayload) { + setActionResult(actionPayload.actionName, actionPayload.actionResult); + context.session.delete(ACTION_SESSION_KEY); + return next(); + } + + // If an action was called from an HTML form action, + // call the action handler and redirect to the destination page + if (action?.calledFrom === "form") { + const actionResult = await action.handler(); + + context.session.set(ACTION_SESSION_KEY, { + actionName: action.name, + actionResult: serializeActionResult(actionResult), + }); + + + // Redirect back to the previous page on error + if (actionResult.error) { + const referer = context.request.headers.get("Referer"); + if (!referer) { + throw new Error( + "Internal: Referer unexpectedly missing from Action POST request.", + ); + } + return context.redirect(referer); + } + // Redirect to the destination page on success + return context.redirect(context.originPathname); + } + + return next(); +}); diff --git a/packages/integrations/netlify/test/functions/fixtures/sessions/src/pages/api.ts b/packages/integrations/netlify/test/functions/fixtures/sessions/src/pages/api.ts new file mode 100644 index 000000000000..21793c78a74f --- /dev/null +++ b/packages/integrations/netlify/test/functions/fixtures/sessions/src/pages/api.ts @@ -0,0 +1,13 @@ +import type { APIRoute } from 'astro'; + +export const GET: APIRoute = async (context) => { + const url = new URL(context.url, 'http://localhost'); + let value = url.searchParams.get('set'); + if (value) { + context.session.set('value', value); + } else { + value = await context.session.get('value'); + } + const cart = await context.session.get('cart'); + return Response.json({ value, cart }); +}; diff --git a/packages/integrations/netlify/test/functions/fixtures/sessions/src/pages/cart.astro b/packages/integrations/netlify/test/functions/fixtures/sessions/src/pages/cart.astro new file mode 100644 index 000000000000..e69a9e5e15b1 --- /dev/null +++ b/packages/integrations/netlify/test/functions/fixtures/sessions/src/pages/cart.astro @@ -0,0 +1,24 @@ +--- +import { actions } from "astro:actions"; + +const result = Astro.getActionResult(actions.addToCart); + +const cart = result?.data?.cart ?? await Astro.session.get('cart'); +const message = result?.data?.message ?? 'Add something to your cart!'; +--- +

Cart: {JSON.stringify(cart)}

+

{message}

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

Hi

+

{value}

+🛒 + diff --git a/packages/integrations/netlify/test/functions/fixtures/sessions/src/pages/regenerate.ts b/packages/integrations/netlify/test/functions/fixtures/sessions/src/pages/regenerate.ts new file mode 100644 index 000000000000..6f2240588e4f --- /dev/null +++ b/packages/integrations/netlify/test/functions/fixtures/sessions/src/pages/regenerate.ts @@ -0,0 +1,6 @@ +import type { APIRoute } from 'astro'; + +export const GET: APIRoute = async (context) => { + await context.session.regenerate(); + return Response.json({}); +}; diff --git a/packages/integrations/netlify/test/functions/fixtures/sessions/src/pages/update.ts b/packages/integrations/netlify/test/functions/fixtures/sessions/src/pages/update.ts new file mode 100644 index 000000000000..71b058e75321 --- /dev/null +++ b/packages/integrations/netlify/test/functions/fixtures/sessions/src/pages/update.ts @@ -0,0 +1,10 @@ +import type { APIRoute } from 'astro'; + +export const GET: APIRoute = async (context) => { + const previousObject = await context.session.get("key") ?? { value: "none" }; + const previousValue = previousObject.value; + const sessionData = { value: "expected" }; + context.session.set("key", sessionData); + sessionData.value = "unexpected"; + return Response.json({previousValue}); +}; diff --git a/packages/integrations/netlify/test/functions/sessions.test.js b/packages/integrations/netlify/test/functions/sessions.test.js new file mode 100644 index 000000000000..6c6be8cb8691 --- /dev/null +++ b/packages/integrations/netlify/test/functions/sessions.test.js @@ -0,0 +1,129 @@ +// @ts-check +import assert from 'node:assert/strict'; +import { after, before, describe, it } from 'node:test'; +import * as devalue from 'devalue'; +import netlify from '../../dist/index.js'; +import { loadFixture } from '../../../../astro/test/test-utils.js'; +import { BlobsServer } from '@netlify/blobs/server'; +import { rm, mkdir } from 'node:fs/promises'; +const token = 'mock'; +const siteID = '1'; +const dataDir = '.netlify/sessions'; +const options = { + name: 'test', + uncachedEdgeURL: `http://localhost:8971`, + edgeURL: `http://localhost:8971`, + token, + siteID, + region: 'us-east-1', +}; + +describe('Astro.session', () => { + describe('Production', () => { + /** @type {import('../../../../astro/test/test-utils.js').Fixture} */ + let fixture; + + /** @type {BlobsServer} */ + let blobServer; + before(async () => { + process.env.NETLIFY = '1'; + await rm(dataDir, { recursive: true, force: true }).catch(() => {}); + await mkdir(dataDir, { recursive: true }); + blobServer = new BlobsServer({ + directory: dataDir, + token, + port: 8971, + }); + await blobServer.start(); + fixture = await loadFixture({ + // @ts-ignore + root: new URL('./fixtures/sessions/', import.meta.url), + output: 'server', + adapter: netlify(), + experimental: { + session: true, + }, + // @ts-ignore + session: { driver: '', options }, + }); + await fixture.build({}); + const entryURL = new URL( + './fixtures/sessions/.netlify/v1/functions/ssr/ssr.mjs', + import.meta.url, + ); + const mod = await import(entryURL.href); + handler = mod.default; + }); + let handler; + after(async () => { + await blobServer.stop(); + delete process.env.NETLIFY; + }); + async function fetchResponse(path, requestInit) { + return handler(new Request(new URL(path, 'http://example.com'), requestInit), {}); + } + + it('can regenerate session cookies upon request', async () => { + const firstResponse = await fetchResponse('/regenerate', { method: 'GET' }); + const firstHeaders = firstResponse.headers.get('set-cookie').split(','); + const firstSessionId = firstHeaders[0].split(';')[0].split('=')[1]; + + const secondResponse = await fetchResponse('/regenerate', { + method: 'GET', + headers: { + cookie: `astro-session=${firstSessionId}`, + }, + }); + const secondHeaders = secondResponse.headers.get('set-cookie').split(','); + const secondSessionId = secondHeaders[0].split(';')[0].split('=')[1]; + assert.notEqual(firstSessionId, secondSessionId); + }); + + it('can save session data by value', async () => { + const firstResponse = await fetchResponse('/update', { method: 'GET' }); + const firstValue = await firstResponse.json(); + assert.equal(firstValue.previousValue, 'none'); + + const firstHeaders = firstResponse.headers.get('set-cookie').split(','); + const firstSessionId = firstHeaders[0].split(';')[0].split('=')[1]; + const secondResponse = await fetchResponse('/update', { + method: 'GET', + headers: { + cookie: `astro-session=${firstSessionId}`, + }, + }); + const secondValue = await secondResponse.json(); + assert.equal(secondValue.previousValue, 'expected'); + }); + + it('can save and restore URLs in session data', async () => { + const firstResponse = await fetchResponse('/_actions/addUrl', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ favoriteUrl: 'https://domain.invalid' }), + }); + + assert.equal(firstResponse.ok, true); + const firstHeaders = firstResponse.headers.get('set-cookie').split(','); + const firstSessionId = firstHeaders[0].split(';')[0].split('=')[1]; + + const data = devalue.parse(await firstResponse.text()); + assert.equal(data.message, 'Favorite URL set to https://domain.invalid/ from nothing'); + const secondResponse = await fetchResponse('/_actions/addUrl', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + cookie: `astro-session=${firstSessionId}`, + }, + body: JSON.stringify({ favoriteUrl: 'https://example.com' }), + }); + const secondData = devalue.parse(await secondResponse.text()); + assert.equal( + secondData.message, + 'Favorite URL set to https://example.com/ from https://domain.invalid/', + ); + }); + }); +}); diff --git a/packages/integrations/node/package.json b/packages/integrations/node/package.json index ed7cb70d1d16..883a1002b8e0 100644 --- a/packages/integrations/node/package.json +++ b/packages/integrations/node/package.json @@ -36,7 +36,7 @@ "server-destroy": "^1.0.1" }, "peerDependencies": { - "astro": "^5.0.0" + "astro": "^5.3.0" }, "devDependencies": { "@types/node": "^22.10.6", @@ -45,6 +45,7 @@ "astro": "workspace:*", "astro-scripts": "workspace:*", "cheerio": "1.0.0", + "devalue": "^5.1.1", "express": "^4.21.2", "node-mocks-http": "^1.16.2" }, diff --git a/packages/integrations/node/src/index.ts b/packages/integrations/node/src/index.ts index e91ed171bc87..a5dccc0c3ced 100644 --- a/packages/integrations/node/src/index.ts +++ b/packages/integrations/node/src/index.ts @@ -1,3 +1,4 @@ +import { fileURLToPath } from 'node:url'; import type { AstroAdapter, AstroIntegration } from 'astro'; import { AstroError } from 'astro/errors'; import type { Options, UserOptions } from './types.js'; @@ -33,11 +34,25 @@ export default function createIntegration(userOptions: UserOptions): AstroIntegr return { name: '@astrojs/node', hooks: { - 'astro:config:setup': async ({ updateConfig, config }) => { + 'astro:config:setup': async ({ updateConfig, config, logger }) => { + let session = config.session; + + if (config.experimental.session && !session?.driver) { + logger.info('Configuring experimental session support using filesystem storage'); + session = { + ...session, + driver: 'fs-lite', + options: { + base: fileURLToPath(new URL('sessions', config.cacheDir)), + }, + }; + } + updateConfig({ image: { endpoint: config.image.endpoint ?? 'astro/assets/endpoint/node', }, + session, vite: { ssr: { noExternal: ['@astrojs/node'], diff --git a/packages/integrations/node/test/fixtures/sessions/astro.config.mjs b/packages/integrations/node/test/fixtures/sessions/astro.config.mjs new file mode 100644 index 000000000000..bcd1aed24e57 --- /dev/null +++ b/packages/integrations/node/test/fixtures/sessions/astro.config.mjs @@ -0,0 +1,6 @@ +// @ts-check +import { defineConfig } from 'astro/config'; + +export default defineConfig({ + +}); diff --git a/packages/integrations/node/test/fixtures/sessions/package.json b/packages/integrations/node/test/fixtures/sessions/package.json new file mode 100644 index 000000000000..e6fc29588431 --- /dev/null +++ b/packages/integrations/node/test/fixtures/sessions/package.json @@ -0,0 +1,9 @@ +{ + "name": "@test/node-sessions", + "version": "0.0.0", + "private": true, + "dependencies": { + "@astrojs/node": "workspace:*", + "astro": "workspace:*" + } +} diff --git a/packages/integrations/node/test/fixtures/sessions/src/actions/index.ts b/packages/integrations/node/test/fixtures/sessions/src/actions/index.ts new file mode 100644 index 000000000000..856f68ba8fb1 --- /dev/null +++ b/packages/integrations/node/test/fixtures/sessions/src/actions/index.ts @@ -0,0 +1,36 @@ +import { defineAction } from 'astro:actions'; +import { z } from 'astro:schema'; + +export const server = { + addToCart: defineAction({ + accept: 'form', + input: z.object({ productId: z.string() }), + handler: async (input, context) => { + const cart: Array = (await context.session.get('cart')) || []; + cart.push(input.productId); + await context.session.set('cart', cart); + return { cart, message: 'Product added to cart at ' + new Date().toTimeString() }; + }, + }), + getCart: defineAction({ + handler: async (input, context) => { + return await context.session.get('cart'); + }, + }), + clearCart: defineAction({ + accept: 'json', + handler: async (input, context) => { + await context.session.set('cart', []); + return { cart: [], message: 'Cart cleared at ' + new Date().toTimeString() }; + }, + }), + addUrl: defineAction({ + input: z.object({ favoriteUrl: z.string().url() }), + handler: async (input, context) => { + const previousFavoriteUrl = await context.session.get('favoriteUrl'); + const url = new URL(input.favoriteUrl); + context.session.set('favoriteUrl', url); + return { message: 'Favorite URL set to ' + url.href + ' from ' + (previousFavoriteUrl?.href ?? "nothing") }; + } + }) +} diff --git a/packages/integrations/node/test/fixtures/sessions/src/middleware.ts b/packages/integrations/node/test/fixtures/sessions/src/middleware.ts new file mode 100644 index 000000000000..7f56f11f364f --- /dev/null +++ b/packages/integrations/node/test/fixtures/sessions/src/middleware.ts @@ -0,0 +1,49 @@ +import { defineMiddleware } from 'astro:middleware'; +import { getActionContext } from 'astro:actions'; + +const ACTION_SESSION_KEY = 'actionResult' + +export const onRequest = defineMiddleware(async (context, next) => { + // Skip requests for prerendered pages + if (context.isPrerendered) return next(); + + const { action, setActionResult, serializeActionResult } = + getActionContext(context); + + console.log(action?.name) + + const actionPayload = await context.session.get(ACTION_SESSION_KEY); + + if (actionPayload) { + setActionResult(actionPayload.actionName, actionPayload.actionResult); + context.session.delete(ACTION_SESSION_KEY); + return next(); + } + + // If an action was called from an HTML form action, + // call the action handler and redirect to the destination page + if (action?.calledFrom === "form") { + const actionResult = await action.handler(); + + context.session.set(ACTION_SESSION_KEY, { + actionName: action.name, + actionResult: serializeActionResult(actionResult), + }); + + + // Redirect back to the previous page on error + if (actionResult.error) { + const referer = context.request.headers.get("Referer"); + if (!referer) { + throw new Error( + "Internal: Referer unexpectedly missing from Action POST request.", + ); + } + return context.redirect(referer); + } + // Redirect to the destination page on success + return context.redirect(context.originPathname); + } + + return next(); +}); diff --git a/packages/integrations/node/test/fixtures/sessions/src/pages/api.ts b/packages/integrations/node/test/fixtures/sessions/src/pages/api.ts new file mode 100644 index 000000000000..21793c78a74f --- /dev/null +++ b/packages/integrations/node/test/fixtures/sessions/src/pages/api.ts @@ -0,0 +1,13 @@ +import type { APIRoute } from 'astro'; + +export const GET: APIRoute = async (context) => { + const url = new URL(context.url, 'http://localhost'); + let value = url.searchParams.get('set'); + if (value) { + context.session.set('value', value); + } else { + value = await context.session.get('value'); + } + const cart = await context.session.get('cart'); + return Response.json({ value, cart }); +}; diff --git a/packages/integrations/node/test/fixtures/sessions/src/pages/cart.astro b/packages/integrations/node/test/fixtures/sessions/src/pages/cart.astro new file mode 100644 index 000000000000..e69a9e5e15b1 --- /dev/null +++ b/packages/integrations/node/test/fixtures/sessions/src/pages/cart.astro @@ -0,0 +1,24 @@ +--- +import { actions } from "astro:actions"; + +const result = Astro.getActionResult(actions.addToCart); + +const cart = result?.data?.cart ?? await Astro.session.get('cart'); +const message = result?.data?.message ?? 'Add something to your cart!'; +--- +

Cart: {JSON.stringify(cart)}

+

{message}

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

Hi

+

{value}

+🛒 + diff --git a/packages/integrations/node/test/fixtures/sessions/src/pages/regenerate.ts b/packages/integrations/node/test/fixtures/sessions/src/pages/regenerate.ts new file mode 100644 index 000000000000..6f2240588e4f --- /dev/null +++ b/packages/integrations/node/test/fixtures/sessions/src/pages/regenerate.ts @@ -0,0 +1,6 @@ +import type { APIRoute } from 'astro'; + +export const GET: APIRoute = async (context) => { + await context.session.regenerate(); + return Response.json({}); +}; diff --git a/packages/integrations/node/test/fixtures/sessions/src/pages/update.ts b/packages/integrations/node/test/fixtures/sessions/src/pages/update.ts new file mode 100644 index 000000000000..71b058e75321 --- /dev/null +++ b/packages/integrations/node/test/fixtures/sessions/src/pages/update.ts @@ -0,0 +1,10 @@ +import type { APIRoute } from 'astro'; + +export const GET: APIRoute = async (context) => { + const previousObject = await context.session.get("key") ?? { value: "none" }; + const previousValue = previousObject.value; + const sessionData = { value: "expected" }; + context.session.set("key", sessionData); + sessionData.value = "unexpected"; + return Response.json({previousValue}); +}; diff --git a/packages/integrations/node/test/sessions.test.js b/packages/integrations/node/test/sessions.test.js new file mode 100644 index 000000000000..8d82f63b6554 --- /dev/null +++ b/packages/integrations/node/test/sessions.test.js @@ -0,0 +1,191 @@ +// @ts-check +import assert from 'node:assert/strict'; +import { after, before, describe, it } from 'node:test'; +import * as devalue from 'devalue'; +import nodejs from '../dist/index.js'; +import { loadFixture } from './test-utils.js'; + +describe('Astro.session', () => { + describe('Production', () => { + /** @type {import('./test-utils').Fixture} */ + let fixture; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/sessions/', + output: 'server', + adapter: nodejs({ mode: 'middleware' }), + experimental: { + session: true, + }, + }); + }); + + /** @type {import('../../../astro/src/types/public/preview.js').PreviewServer} */ + let app; + before(async () => { + await fixture.build({}); + app = await fixture.preview({}); + }); + + after(async () => { + await app.stop(); + }); + + it('can regenerate session cookies upon request', async () => { + const firstResponse = await fixture.fetch('/regenerate'); + // @ts-ignore + const firstHeaders = firstResponse.headers.get('set-cookie').split(','); + const firstSessionId = firstHeaders[0].split(';')[0].split('=')[1]; + + const secondResponse = await fixture.fetch('/regenerate', { + method: 'GET', + headers: { + cookie: `astro-session=${firstSessionId}`, + }, + }); + // @ts-ignore + const secondHeaders = secondResponse.headers.get('set-cookie').split(','); + const secondSessionId = secondHeaders[0].split(';')[0].split('=')[1]; + assert.notEqual(firstSessionId, secondSessionId); + }); + + it('can save session data by value', async () => { + const firstResponse = await fixture.fetch('/update'); + const firstValue = await firstResponse.json(); + assert.equal(firstValue.previousValue, 'none'); + + // @ts-ignore + const firstHeaders = firstResponse.headers.get('set-cookie').split(','); + const firstSessionId = firstHeaders[0].split(';')[0].split('=')[1]; + const secondResponse = await fixture.fetch('/update', { + method: 'GET', + headers: { + cookie: `astro-session=${firstSessionId}`, + }, + }); + const secondValue = await secondResponse.json(); + assert.equal(secondValue.previousValue, 'expected'); + }); + + it('can save and restore URLs in session data', async () => { + const firstResponse = await fixture.fetch('/_actions/addUrl', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ favoriteUrl: 'https://domain.invalid' }), + }); + + assert.equal(firstResponse.ok, true); + // @ts-ignore + const firstHeaders = firstResponse.headers.get('set-cookie').split(','); + const firstSessionId = firstHeaders[0].split(';')[0].split('=')[1]; + + const data = devalue.parse(await firstResponse.text()); + assert.equal(data.message, 'Favorite URL set to https://domain.invalid/ from nothing'); + const secondResponse = await fixture.fetch('/_actions/addUrl', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + cookie: `astro-session=${firstSessionId}`, + }, + body: JSON.stringify({ favoriteUrl: 'https://example.com' }), + }); + const secondData = devalue.parse(await secondResponse.text()); + assert.equal( + secondData.message, + 'Favorite URL set to https://example.com/ from https://domain.invalid/', + ); + }); + }); + + describe('Development', () => { + /** @type {import('./test-utils').Fixture} */ + let fixture; + let devServer; + before(async () => { + fixture = await loadFixture({ + root: './fixtures/sessions/', + output: 'server', + adapter: nodejs({ mode: 'middleware' }), + experimental: { + session: true, + }, + + }); + devServer = await fixture.startDevServer(); + }); + + after(async () => { + await devServer.stop(); + }); + + it('can regenerate session cookies upon request', async () => { + const firstResponse = await fixture.fetch('/regenerate'); + // @ts-ignore + const firstHeaders = firstResponse.headers.get('set-cookie').split(','); + const firstSessionId = firstHeaders[0].split(';')[0].split('=')[1]; + + const secondResponse = await fixture.fetch('/regenerate', { + method: 'GET', + headers: { + cookie: `astro-session=${firstSessionId}`, + }, + }); + // @ts-ignore + const secondHeaders = secondResponse.headers.get('set-cookie').split(','); + const secondSessionId = secondHeaders[0].split(';')[0].split('=')[1]; + assert.notEqual(firstSessionId, secondSessionId); + }); + + it('can save session data by value', async () => { + const firstResponse = await fixture.fetch('/update'); + const firstValue = await firstResponse.json(); + assert.equal(firstValue.previousValue, 'none'); + + // @ts-ignore + const firstHeaders = firstResponse.headers.get('set-cookie').split(','); + const firstSessionId = firstHeaders[0].split(';')[0].split('=')[1]; + const secondResponse = await fixture.fetch('/update', { + method: 'GET', + headers: { + cookie: `astro-session=${firstSessionId}`, + }, + }); + const secondValue = await secondResponse.json(); + assert.equal(secondValue.previousValue, 'expected'); + }); + + it('can save and restore URLs in session data', async () => { + const firstResponse = await fixture.fetch('/_actions/addUrl', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ favoriteUrl: 'https://domain.invalid' }), + }); + + assert.equal(firstResponse.ok, true); + // @ts-ignore + const firstHeaders = firstResponse.headers.get('set-cookie').split(','); + const firstSessionId = firstHeaders[0].split(';')[0].split('=')[1]; + + const data = devalue.parse(await firstResponse.text()); + assert.equal(data.message, 'Favorite URL set to https://domain.invalid/ from nothing'); + const secondResponse = await fixture.fetch('/_actions/addUrl', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + cookie: `astro-session=${firstSessionId}`, + }, + body: JSON.stringify({ favoriteUrl: 'https://example.com' }), + }); + const secondData = devalue.parse(await secondResponse.text()); + assert.equal( + secondData.message, + 'Favorite URL set to https://example.com/ from https://domain.invalid/', + ); + }); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index db8357fd18d9..afc02e0b359f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3707,9 +3707,6 @@ importers: packages/astro/test/fixtures/sessions: dependencies: - '@netlify/blobs': - specifier: ^8.1.0 - version: 8.1.0 astro: specifier: workspace:* version: link:../../.. @@ -5176,11 +5173,14 @@ importers: packages/integrations/netlify: dependencies: '@astrojs/internal-helpers': - specifier: 0.5.1 + specifier: workspace:* version: link:../../internal-helpers '@astrojs/underscore-redirects': - specifier: ^0.6.0 + specifier: workspace:* version: link:../../underscore-redirects + '@netlify/blobs': + specifier: ^8.1.0 + version: 8.1.0 '@netlify/functions': specifier: ^2.8.0 version: 2.8.2 @@ -5212,6 +5212,9 @@ importers: cheerio: specifier: 1.0.0 version: 1.0.0 + devalue: + specifier: ^5.1.1 + version: 5.1.1 execa: specifier: ^8.0.1 version: 8.0.1 @@ -5255,6 +5258,16 @@ importers: specifier: 'workspace:' version: link:../../../.. + packages/integrations/netlify/test/functions/fixtures/sessions: + dependencies: + '@astrojs/netlify': + specifier: workspace:* + version: link:../../../.. + devDependencies: + astro: + specifier: workspace:* + version: link:../../../../../../astro + packages/integrations/netlify/test/hosted/hosted-astro-project: dependencies: '@astrojs/netlify': @@ -5300,6 +5313,9 @@ importers: cheerio: specifier: 1.0.0 version: 1.0.0 + devalue: + specifier: ^5.1.1 + version: 5.1.1 express: specifier: ^4.21.2 version: 4.21.2 @@ -5406,6 +5422,15 @@ importers: specifier: workspace:* version: link:../../../../../astro + packages/integrations/node/test/fixtures/sessions: + dependencies: + '@astrojs/node': + specifier: workspace:* + version: link:../../.. + astro: + specifier: workspace:* + version: link:../../../../../astro + packages/integrations/node/test/fixtures/trailing-slash: dependencies: '@astrojs/node':