diff --git a/.changeset/session-false-cloudflare.md b/.changeset/session-false-cloudflare.md new file mode 100644 index 000000000000..65ea3345d12d --- /dev/null +++ b/.changeset/session-false-cloudflare.md @@ -0,0 +1,5 @@ +--- +'@astrojs/cloudflare': minor +--- + +When `session: false` is set in `astro.config`, the adapter no longer auto-wires the Cloudflare KV session driver. Combined with the matching `astro` change, this lets the session runtime tree-shake out of the Worker bundle. diff --git a/.changeset/session-false-netlify.md b/.changeset/session-false-netlify.md new file mode 100644 index 000000000000..bdfc5870733c --- /dev/null +++ b/.changeset/session-false-netlify.md @@ -0,0 +1,5 @@ +--- +'@astrojs/netlify': minor +--- + +When `session: false` is set in `astro.config`, the adapter no longer auto-wires the Netlify Blobs session driver. Combined with the matching `astro` change, this lets the session runtime tree-shake out of the function bundle. diff --git a/.changeset/session-false-node.md b/.changeset/session-false-node.md new file mode 100644 index 000000000000..37fd41edbc40 --- /dev/null +++ b/.changeset/session-false-node.md @@ -0,0 +1,5 @@ +--- +'@astrojs/node': minor +--- + +When `session: false` is set in `astro.config`, the adapter no longer auto-wires the filesystem session driver. Combined with the matching `astro` change, this lets the session runtime tree-shake out of the server bundle. diff --git a/.changeset/session-false-opt-out.md b/.changeset/session-false-opt-out.md new file mode 100644 index 000000000000..123a7922a399 --- /dev/null +++ b/.changeset/session-false-opt-out.md @@ -0,0 +1,21 @@ +--- +'astro': minor +--- + +Adds `session: false` in `astro.config` to opt out of session support. Projects that do not set `session: false` see no behavior change. + +```js title="astro.config.mjs" +import { defineConfig } from 'astro/config'; + +export default defineConfig({ + session: false, +}); +``` + +The session runtime and dependencies (`unstorage`) are now tree-shaken out of the SSR bundle for any project where no session driver is wired via: + +* `session: false` +* no `session` config at all +* a `session` config without a driver + +Useful for serverless/edge runtimes where cold-start parse time is sensitive. diff --git a/packages/astro/src/core/create-vite.ts b/packages/astro/src/core/create-vite.ts index c90acbb3386e..ec6160a00cd1 100644 --- a/packages/astro/src/core/create-vite.ts +++ b/packages/astro/src/core/create-vite.ts @@ -50,7 +50,7 @@ import { joinPaths } from './path.js'; import { ServerIslandsState } from './server-islands/shared-state.js'; import { vitePluginServerIslands } from './server-islands/vite-plugin-server-islands.js'; import { vitePluginCacheProvider } from './cache/vite-plugin.js'; -import { vitePluginSessionDriver } from './session/vite-plugin.js'; +import { vitePluginSessionDriver, vitePluginSessionProvider } from './session/vite-plugin.js'; import { vitePluginLogger } from './logger/vite-plugin.js'; import { isObject } from './util-runtime.js'; import { vitePluginEnvironment } from '../vite-plugin-environment/index.js'; @@ -231,6 +231,7 @@ export async function createVite( vitePluginActions({ fs, settings }), vitePluginServerIslands({ settings, logger, serverIslandsState }), vitePluginSessionDriver({ settings }), + vitePluginSessionProvider({ settings }), vitePluginCacheProvider({ settings }), vitePluginLogger({ settings }), astroContainer(), diff --git a/packages/astro/src/core/errors/default-handler.ts b/packages/astro/src/core/errors/default-handler.ts index 3d29345e96e9..4dfba06ccaff 100644 --- a/packages/astro/src/core/errors/default-handler.ts +++ b/packages/astro/src/core/errors/default-handler.ts @@ -7,7 +7,7 @@ import { getCookiesFromResponse } from '../cookies/response.js'; import { AstroMiddleware } from '../middleware/astro-middleware.js'; import { PagesHandler } from '../pages/handler.js'; import { matchRoute } from '../routing/match.js'; -import { provideSession } from '../session/handler.js'; +import { provideSession } from '../session/provider.js'; import { validateHost } from '../app/validate-headers.js'; import { getErrorRoutePath } from '../../i18n/error-routes.js'; import { getOutputFilename } from '../output-filename.js'; diff --git a/packages/astro/src/core/fetch/index.ts b/packages/astro/src/core/fetch/index.ts index 35aba67df38f..ff9658c7225f 100644 --- a/packages/astro/src/core/fetch/index.ts +++ b/packages/astro/src/core/fetch/index.ts @@ -19,7 +19,7 @@ import { AstroMiddleware } from '../middleware/astro-middleware.js'; import { PagesHandler } from '../pages/handler.js'; import { renderRedirect } from '../redirects/render.js'; import { AstroHandler } from '../routing/handler.js'; -import { provideSession } from '../session/handler.js'; +import { provideSession } from '../session/provider.js'; import { TrailingSlashHandler } from '../routing/trailing-slash-handler.js'; function getApp(request: Request): BaseApp { diff --git a/packages/astro/src/core/routing/handler.ts b/packages/astro/src/core/routing/handler.ts index 0818bf2c5944..004b7b50359f 100644 --- a/packages/astro/src/core/routing/handler.ts +++ b/packages/astro/src/core/routing/handler.ts @@ -7,7 +7,7 @@ import { I18n } from '../i18n/handler.js'; import { AstroMiddleware } from '../middleware/astro-middleware.js'; import { PagesHandler } from '../pages/handler.js'; import { renderRedirect } from '../redirects/render.js'; -import { provideSession } from '../session/handler.js'; +import { provideSession } from '../session/provider.js'; import type { FetchState } from '../fetch/fetch-state.js'; import { prepareResponse } from '../app/prepare-response.js'; import type { BaseApp } from '../app/base.js'; diff --git a/packages/astro/src/core/session/config.ts b/packages/astro/src/core/session/config.ts index 09d1c1d29fc0..eb2095ae5393 100644 --- a/packages/astro/src/core/session/config.ts +++ b/packages/astro/src/core/session/config.ts @@ -5,7 +5,7 @@ export const SessionDriverConfigSchema = z.object({ entrypoint: z.union([z.string(), z.instanceof(URL)]), }); -export const SessionSchema = z.object({ +const SessionObjectSchema = z.object({ driver: z .union([ z.string().superRefine(() => { @@ -32,3 +32,9 @@ export const SessionSchema = z.object({ .optional(), ttl: z.number().optional(), }); + +// `session: false` opts out of session support entirely so the session +// runtime and any adapter-provided driver can be tree-shaken from the +// SSR bundle. +// See packages/astro/src/core/session/provider-disabled.ts. +export const SessionSchema = z.union([z.literal(false), SessionObjectSchema]); diff --git a/packages/astro/src/core/session/provider-disabled.ts b/packages/astro/src/core/session/provider-disabled.ts new file mode 100644 index 000000000000..bb42df4457d9 --- /dev/null +++ b/packages/astro/src/core/session/provider-disabled.ts @@ -0,0 +1,16 @@ +import { PipelineFeatures } from '../base-pipeline.js'; +import type { FetchState } from '../fetch/fetch-state.js'; + +// Drop-in for `provideSession` substituted in for `./provider.js` by the +// `astro:session-provider` Vite plugin when `session: false` is set. +// Imports nothing from `./runtime.js`, so the session runtime tree-shakes +// out of the SSR bundle. +// +// It registers no session provider, so `Astro.session` (and +// `context.session`) is `undefined` — the same behavior as a project +// without sessions configured, matching its `AstroSession | undefined` +// type. We still mark the feature as used so the missing-feature warning +// in `BaseApp` never fires. +export function provideSession(state: FetchState): void { + state.pipeline.usedFeatures |= PipelineFeatures.sessions; +} diff --git a/packages/astro/src/core/session/provider.ts b/packages/astro/src/core/session/provider.ts new file mode 100644 index 000000000000..a7da3ae46766 --- /dev/null +++ b/packages/astro/src/core/session/provider.ts @@ -0,0 +1,8 @@ +// Re-export shim. Importing `provideSession` from `./provider.js` (rather +// than `./handler.js` directly) lets the Vite plugin swap this file for +// `./provider-disabled.js` when `session: false` is configured, so Rollup +// can tree-shake `./runtime.js` out of the SSR bundle. +// +// At runtime in environments without Vite (Node-loaded `dist/`, library +// tooling), this file resolves normally and keeps the real provider. +export { provideSession } from './handler.js'; diff --git a/packages/astro/src/core/session/utils.ts b/packages/astro/src/core/session/utils.ts index f770f105b222..672266d99201 100644 --- a/packages/astro/src/core/session/utils.ts +++ b/packages/astro/src/core/session/utils.ts @@ -48,6 +48,9 @@ export function normalizeSessionDriverConfig( export function sessionConfigToManifest( config: AstroConfig['session'], ): SSRManifestSession | undefined { + if (config === false) { + return undefined; + } const sessionDriver = config?.driver; if (!config || !sessionDriver) { return undefined; diff --git a/packages/astro/src/core/session/vite-plugin.ts b/packages/astro/src/core/session/vite-plugin.ts index 0047bffbe160..ee13808256be 100644 --- a/packages/astro/src/core/session/vite-plugin.ts +++ b/packages/astro/src/core/session/vite-plugin.ts @@ -1,8 +1,10 @@ +import { realpathSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import type { Plugin as VitePlugin } from 'vite'; import type { AstroSettings } from '../../types/astro.js'; import { SessionStorageInitError } from '../errors/errors-data.js'; import { AstroError } from '../errors/index.js'; +import { normalizePath } from '../viteUtils.js'; import { normalizeSessionDriverConfig } from './utils.js'; export const VIRTUAL_SESSION_DRIVER_ID = 'virtual:astro:session-driver'; @@ -27,14 +29,12 @@ export function vitePluginSessionDriver({ settings }: { settings: AstroSettings id: new RegExp(`^${RESOLVED_VIRTUAL_SESSION_DRIVER_ID}$`), }, async handler() { - if (!settings.config.session?.driver) { + const session = settings.config.session; + if (session === false || !session?.driver) { return { code: 'export default null;' }; } - const driver = normalizeSessionDriverConfig( - settings.config.session.driver, - settings.config.session.options, - ); + const driver = normalizeSessionDriverConfig(session.driver, session.options); const importerPath = fileURLToPath(import.meta.url); const resolved = await this.resolve(driver.entrypoint, importerPath); if (!resolved) { @@ -54,3 +54,66 @@ export function vitePluginSessionDriver({ settings }: { settings: AstroSettings }, }; } + +// When no session driver will be present at request time, swap Astro's own +// `core/session/provider.js` for `core/session/provider-disabled.js` so +// Rollup tree-shakes `runtime.js` (and `unstorage`) out of the SSR bundle. +// +// This covers `session: false`, `session: undefined`, and a `session` +// object without a driver. By the time this plugin resolves, adapters have +// already wired their default driver into `config.session.driver` (during +// `astro:config:setup`), so `session?.driver` reflects the final decision — +// the same signal `vitePluginSessionDriver` uses to emit `default null`. +// Swapping in the no-op provider is behavior-preserving: the real provider +// already resolves `Astro.session` to `undefined` when no driver factory +// exists, so the only difference is the dead runtime is dropped. +// +// To avoid hijacking unrelated `./session/provider.js` paths in user code +// or third-party deps, we resolve each candidate specifier through Vite +// and only redirect when it resolves to Astro's own provider file. +const PROVIDER_FILENAME = 'provider.js'; +const DISABLED_PROVIDER_FILENAME = 'provider-disabled.js'; + +function canonicalizePath(filePath: string): string { + try { + // Resolve symlinks, which Node does for `import.meta.url` but Vite only + // does when `resolve.preserveSymlinks` is false, and normalize to forward + // slashes so the two sides compare equal on Windows. + return normalizePath(realpathSync(filePath)); + } catch { + // Not a file on disk (bare specifier, virtual module), so there is no + // symlink to resolve. + return normalizePath(filePath); + } +} + +export function vitePluginSessionProvider({ settings }: { settings: AstroSettings }): VitePlugin { + const providerPath = canonicalizePath( + fileURLToPath(new URL(`./${PROVIDER_FILENAME}`, import.meta.url)), + ); + const disabledProviderPath = canonicalizePath( + fileURLToPath(new URL(`./${DISABLED_PROVIDER_FILENAME}`, import.meta.url)), + ); + return { + name: 'astro:session-provider', + enforce: 'pre', + async resolveId(id, importer) { + // Keep the real provider only when a driver will be present. + const session = settings.config.session; + const hasSessionDriver = session !== false && !!session?.driver; + if (hasSessionDriver) return null; + // Cheap prefilter to avoid resolving every import in the graph. + // Only specifiers that *could* point at Astro's provider file + // proceed to the (expensive) full resolution + identity check. + if (!normalizePath(id).endsWith(`/session/${PROVIDER_FILENAME}`)) return null; + // Fast path: caller already passed Astro's absolute provider path. + if (canonicalizePath(id) === providerPath) return disabledProviderPath; + if (!importer) return null; + const resolved = await this.resolve(id, importer, { skipSelf: true }); + if (resolved && canonicalizePath(resolved.id) === providerPath) { + return disabledProviderPath; + } + return null; + }, + }; +} diff --git a/packages/astro/src/types/public/config.ts b/packages/astro/src/types/public/config.ts index 74486fa1c749..8e9b8098d46f 100644 --- a/packages/astro/src/types/public/config.ts +++ b/packages/astro/src/types/public/config.ts @@ -1685,9 +1685,19 @@ export interface AstroUserConfig< * * Session drivers are configured at build time. This means environment variables used in the driver configuration are inlined. You must create your own driver entrypoint to [override the configuration at runtime](https://docs.astro.build/en/guides/sessions/#overriding-the-configuration-at-runtime). * + * Set to `false` to opt out of session support entirely. With `session: false`, the session runtime is excluded from the SSR bundle, adapters skip wiring their default session driver, and `Astro.session` (and `context.session`) is `undefined` — the same as a project without sessions configured. Useful for serverless and edge runtimes where bundle parse time is sensitive. + * + * ```js title="astro.config.mjs" + * import { defineConfig } from 'astro/config'; + * + * export default defineConfig({ + * session: false, + * }); + * ``` + * * See [the sessions guide](https://docs.astro.build/en/guides/sessions/) for more information. */ - session?: SessionConfig; + session?: SessionConfig | false; /** * @docs diff --git a/packages/astro/test/fixtures/session-false/astro.config.mjs b/packages/astro/test/fixtures/session-false/astro.config.mjs new file mode 100644 index 000000000000..043480a93269 --- /dev/null +++ b/packages/astro/test/fixtures/session-false/astro.config.mjs @@ -0,0 +1,4 @@ +// @ts-check +import { defineConfig } from 'astro/config'; + +export default defineConfig({}); diff --git a/packages/astro/test/fixtures/session-false/package.json b/packages/astro/test/fixtures/session-false/package.json new file mode 100644 index 000000000000..11212ac43039 --- /dev/null +++ b/packages/astro/test/fixtures/session-false/package.json @@ -0,0 +1,8 @@ +{ + "name": "@test/session-false", + "version": "0.0.0", + "private": true, + "dependencies": { + "astro": "workspace:*" + } +} diff --git a/packages/astro/test/fixtures/session-false/src/pages/api.ts b/packages/astro/test/fixtures/session-false/src/pages/api.ts new file mode 100644 index 000000000000..108ad1c6f3c3 --- /dev/null +++ b/packages/astro/test/fixtures/session-false/src/pages/api.ts @@ -0,0 +1,7 @@ +import type { APIRoute } from 'astro'; + +// With `session: false`, `context.session` is `undefined` (matching its +// `AstroSession | undefined` type), so user code can feature-detect it. +export const GET: APIRoute = (context) => { + return Response.json({ hasSession: context.session != null }); +}; diff --git a/packages/astro/test/fixtures/session-false/src/pages/no-session.ts b/packages/astro/test/fixtures/session-false/src/pages/no-session.ts new file mode 100644 index 000000000000..4cc72bcfa657 --- /dev/null +++ b/packages/astro/test/fixtures/session-false/src/pages/no-session.ts @@ -0,0 +1,5 @@ +import type { APIRoute } from 'astro'; + +// Route that never accesses session — used to verify that disabling +// sessions does not affect unrelated routes. +export const GET: APIRoute = () => Response.json({ ok: true }); diff --git a/packages/astro/test/fixtures/session-false/src/pages/user-provider.ts b/packages/astro/test/fixtures/session-false/src/pages/user-provider.ts new file mode 100644 index 000000000000..ca6372d8074a --- /dev/null +++ b/packages/astro/test/fixtures/session-false/src/pages/user-provider.ts @@ -0,0 +1,9 @@ +import type { APIRoute } from 'astro'; +// `.js` extension is intentional — this is the specifier shape that +// enters the `astro:session-provider` plugin's prefilter. The plugin +// must resolve the import to the user's own file (not Astro's provider) +// and leave it untouched. Imported with `.ts` instead, the prefilter +// would skip and this test would pass trivially. +import { USER_PROVIDER_SENTINEL } from '../session/provider.js'; + +export const GET: APIRoute = () => Response.json({ value: USER_PROVIDER_SENTINEL }); diff --git a/packages/astro/test/fixtures/session-false/src/session/provider.ts b/packages/astro/test/fixtures/session-false/src/session/provider.ts new file mode 100644 index 000000000000..007dbbb185fc --- /dev/null +++ b/packages/astro/test/fixtures/session-false/src/session/provider.ts @@ -0,0 +1,5 @@ +// Decoy: user code shaped exactly like the path the +// astro:session-provider plugin intercepts, to prove the plugin does +// not hijack unrelated `./session/provider.js` imports under +// `session: false`. +export const USER_PROVIDER_SENTINEL = 'user-provider-was-not-hijacked'; diff --git a/packages/astro/test/fixtures/session-tree-shake/astro.config.mjs b/packages/astro/test/fixtures/session-tree-shake/astro.config.mjs new file mode 100644 index 000000000000..043480a93269 --- /dev/null +++ b/packages/astro/test/fixtures/session-tree-shake/astro.config.mjs @@ -0,0 +1,4 @@ +// @ts-check +import { defineConfig } from 'astro/config'; + +export default defineConfig({}); diff --git a/packages/astro/test/fixtures/session-tree-shake/package.json b/packages/astro/test/fixtures/session-tree-shake/package.json new file mode 100644 index 000000000000..c95907bd9c5a --- /dev/null +++ b/packages/astro/test/fixtures/session-tree-shake/package.json @@ -0,0 +1,8 @@ +{ + "name": "@test/session-tree-shake", + "version": "0.0.0", + "private": true, + "dependencies": { + "astro": "workspace:*" + } +} diff --git a/packages/astro/test/fixtures/session-tree-shake/src/pages/api.ts b/packages/astro/test/fixtures/session-tree-shake/src/pages/api.ts new file mode 100644 index 000000000000..d2c08cd12031 --- /dev/null +++ b/packages/astro/test/fixtures/session-tree-shake/src/pages/api.ts @@ -0,0 +1,8 @@ +import type { APIRoute } from 'astro'; + +// Reports whether `context.session` is defined. When no driver is wired +// (no adapter default, no user config), it is `undefined`; when a driver +// is configured, it is an `AstroSession`. +export const GET: APIRoute = (context) => { + return Response.json({ hasSession: context.session != null }); +}; diff --git a/packages/astro/test/fixtures/session-tree-shake/src/pages/no-session.ts b/packages/astro/test/fixtures/session-tree-shake/src/pages/no-session.ts new file mode 100644 index 000000000000..6e02744b825d --- /dev/null +++ b/packages/astro/test/fixtures/session-tree-shake/src/pages/no-session.ts @@ -0,0 +1,4 @@ +import type { APIRoute } from 'astro'; + +// Route that never accesses the session. +export const GET: APIRoute = () => Response.json({ ok: true }); diff --git a/packages/astro/test/session-false.test.ts b/packages/astro/test/session-false.test.ts new file mode 100644 index 000000000000..a02db26598ff --- /dev/null +++ b/packages/astro/test/session-false.test.ts @@ -0,0 +1,62 @@ +import assert from 'node:assert/strict'; +import { before, describe, it } from 'node:test'; +import testAdapter from './test-adapter.ts'; +import { type App, type Fixture, loadFixture } from './test-utils.ts'; + +describe('session: false', () => { + let fixture: Fixture; + let app: App; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/session-false/', + output: 'server', + adapter: testAdapter(), + session: false, + outDir: './dist/session-false/', + }); + await fixture.build({}); + app = await fixture.loadTestAdapterApp(); + }); + + async function fetchResponse(routePath: string) { + const request = new Request('http://example.com' + routePath); + return app.render(request); + } + + it('does not affect routes that never touch the session', async () => { + const response = await fetchResponse('/no-session'); + assert.equal(response.status, 200); + const body = await response.json(); + assert.deepEqual(body, { ok: true }); + }); + + it('leaves Astro.session undefined when a route reads it', async () => { + const response = await fetchResponse('/api'); + assert.equal(response.status, 200); + const body = (await response.json()) as { hasSession?: boolean }; + assert.equal(body.hasSession, false, 'expected context.session to be undefined'); + }); + + it('does not hijack a user `./session/provider.js` import', async () => { + const response = await fetchResponse('/user-provider'); + assert.equal(response.status, 200); + const body = (await response.json()) as { value?: string }; + assert.equal(body.value, 'user-provider-was-not-hijacked'); + }); + + it('excludes the session runtime and unstorage from the SSR bundle', async () => { + const entries = await fixture.glob('**/*.{mjs,js,cjs}'); + let hasUnstorage = false; + let hasSessionRuntime = false; + for (const entry of entries) { + const body = await fixture.readFile(entry); + // `createStorage` is `unstorage`'s top-level export — present iff + // `unstorage` is bundled. The runtime class is `AstroSession`. + if (/\bcreateStorage\b/.test(body)) hasUnstorage = true; + if (/class AstroSession\b/.test(body)) hasSessionRuntime = true; + } + assert.equal(hasUnstorage, false, 'unstorage should not appear in the SSR bundle'); + assert.equal(hasSessionRuntime, false, 'AstroSession class should not appear in the SSR bundle'); + }); +}); diff --git a/packages/astro/test/session-tree-shake.test.ts b/packages/astro/test/session-tree-shake.test.ts new file mode 100644 index 000000000000..4948e3c1eee4 --- /dev/null +++ b/packages/astro/test/session-tree-shake.test.ts @@ -0,0 +1,90 @@ +import assert from 'node:assert/strict'; +import { before, describe, it } from 'node:test'; +import testAdapter from './test-adapter.ts'; +import { type App, type Fixture, loadFixture } from './test-utils.ts'; + +// `createStorage` is `unstorage`'s top-level export — present iff `unstorage` +// is bundled. The runtime class is `AstroSession`. +async function bundleHasSessionRuntime(fixture: Fixture) { + const entries = await fixture.glob('**/*.{mjs,js,cjs}'); + let hasUnstorage = false; + let hasSessionRuntime = false; + for (const entry of entries) { + const body = await fixture.readFile(entry); + if (/\bcreateStorage\b/.test(body)) hasUnstorage = true; + if (/class AstroSession\b/.test(body)) hasSessionRuntime = true; + } + return { hasUnstorage, hasSessionRuntime }; +} + +describe('session tree-shaking when no driver is wired', () => { + let fixture: Fixture; + let app: App; + + before(async () => { + // No `session` config and an adapter that does not wire a default + // driver — the same "no sessions" state as before this feature. + fixture = await loadFixture({ + root: './fixtures/session-tree-shake/', + output: 'server', + adapter: testAdapter(), + outDir: './dist/session-tree-shake-no-driver/', + }); + await fixture.build({}); + app = await fixture.loadTestAdapterApp(); + }); + + it('leaves Astro.session undefined', async () => { + const response = await app.render(new Request('http://example.com/api')); + assert.equal(response.status, 200); + const body = (await response.json()) as { hasSession?: boolean }; + assert.equal(body.hasSession, false, 'expected context.session to be undefined'); + }); + + it('does not affect routes that never touch the session', async () => { + const response = await app.render(new Request('http://example.com/no-session')); + assert.equal(response.status, 200); + assert.deepEqual(await response.json(), { ok: true }); + }); + + it('excludes the session runtime and unstorage from the SSR bundle', async () => { + const { hasUnstorage, hasSessionRuntime } = await bundleHasSessionRuntime(fixture); + assert.equal(hasUnstorage, false, 'unstorage should not appear in the SSR bundle'); + assert.equal(hasSessionRuntime, false, 'AstroSession class should not appear in the SSR bundle'); + }); +}); + +describe('session runtime is retained when a driver is configured', () => { + let fixture: Fixture; + let app: App; + + before(async () => { + // A configured driver must keep the runtime in the bundle — guards + // against the provider swap over-shaking real session setups. + fixture = await loadFixture({ + root: './fixtures/session-tree-shake/', + output: 'server', + adapter: testAdapter(), + session: { + // @ts-expect-error: the default type of the TDriver in AstroUserConfig must be changed so that this can pass + driver: 'fs', + }, + outDir: './dist/session-tree-shake-with-driver/', + }); + await fixture.build({}); + app = await fixture.loadTestAdapterApp(); + }); + + it('makes Astro.session available', async () => { + const response = await app.render(new Request('http://example.com/api')); + assert.equal(response.status, 200); + const body = (await response.json()) as { hasSession?: boolean }; + assert.equal(body.hasSession, true, 'expected context.session to be defined'); + }); + + it('keeps the session runtime and unstorage in the SSR bundle', async () => { + const { hasUnstorage, hasSessionRuntime } = await bundleHasSessionRuntime(fixture); + assert.equal(hasUnstorage, true, 'unstorage should appear in the SSR bundle'); + assert.equal(hasSessionRuntime, true, 'AstroSession class should appear in the SSR bundle'); + }); +}); diff --git a/packages/astro/test/units/config/config-validate.test.ts b/packages/astro/test/units/config/config-validate.test.ts index a31de472ee44..e9410b621b87 100644 --- a/packages/astro/test/units/config/config-validate.test.ts +++ b/packages/astro/test/units/config/config-validate.test.ts @@ -549,8 +549,10 @@ describe('Config Validation', () => { ttl: 60 * 60, // 1 hour }, }); - assert.equal(result.session?.ttl, 60 * 60); - assert.equal(result.session?.driver, undefined); + assert.notEqual(result.session, false); + const session = result.session as Exclude; + assert.equal(session?.ttl, 60 * 60); + assert.equal(session?.driver, undefined); }); }); diff --git a/packages/astro/test/units/sessions/session-false.test.ts b/packages/astro/test/units/sessions/session-false.test.ts new file mode 100644 index 000000000000..2d3c692a649c --- /dev/null +++ b/packages/astro/test/units/sessions/session-false.test.ts @@ -0,0 +1,142 @@ +import assert from 'node:assert/strict'; +import { mkdtempSync, realpathSync, rmSync, symlinkSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { after, before, describe, it } from 'node:test'; +import { fileURLToPath } from 'node:url'; +import { SessionSchema } from '../../../dist/core/session/config.js'; +import { sessionConfigToManifest } from '../../../dist/core/session/utils.js'; +import { provideSession } from '../../../dist/core/session/provider-disabled.js'; +import { vitePluginSessionProvider } from '../../../dist/core/session/vite-plugin.js'; + +describe('session: false', () => { + describe('schema', () => { + it('accepts `session: false`', () => { + const result = SessionSchema.safeParse(false); + assert.equal(result.success, true); + if (result.success) { + assert.equal(result.data, false); + } + }); + + it('still accepts a session object', () => { + const result = SessionSchema.safeParse({ ttl: 60 }); + assert.equal(result.success, true); + }); + + it('rejects other falsy values', () => { + assert.equal(SessionSchema.safeParse(0).success, false); + assert.equal(SessionSchema.safeParse('').success, false); + assert.equal(SessionSchema.safeParse(null).success, false); + }); + }); + + describe('manifest helpers', () => { + it('sessionConfigToManifest(false) returns undefined', () => { + assert.equal(sessionConfigToManifest(false), undefined); + }); + }); + + describe('disabled provider', () => { + it('registers no session provider, leaving Astro.session undefined', () => { + let provideCalled = false; + const fakeState = { + pipeline: { usedFeatures: 0 }, + provide() { + provideCalled = true; + }, + }; + provideSession(fakeState as never); + assert.equal(provideCalled, false, 'disabled provider should not register a session'); + assert.notEqual(fakeState.pipeline.usedFeatures, 0, 'sessions feature should be marked used'); + }); + }); + + describe('provider plugin', () => { + const sessionDir = fileURLToPath(new URL('../../../dist/core/session/', import.meta.url)); + // `canonicalizePath` in the plugin normalizes to forward slashes. + const toPosix = (filePath: string) => realpathSync(filePath).replaceAll('\\', '/'); + const expectedDisabledPath = toPosix(join(sessionDir, 'provider-disabled.js')); + + let tempDir: string; + // Reaches the real provider through a symlink, as Vite does under + // `resolve.preserveSymlinks: true`. + let symlinkedProviderPath: string; + + before(() => { + tempDir = mkdtempSync(join(realpathSync(tmpdir()), 'astro-session-symlink-')); + // `junction` works on Windows without elevated privileges; the type + // argument is ignored elsewhere. + symlinkSync(sessionDir, join(tempDir, 'session'), 'junction'); + symlinkedProviderPath = join(tempDir, 'session', 'provider.js'); + }); + + after(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + function createPlugin(session: unknown) { + return vitePluginSessionProvider({ settings: { config: { session } } } as never); + } + + /** Calls the plugin's `resolveId` with a stubbed Rollup context. */ + function resolveId( + plugin: ReturnType, + id: string, + importer: string | undefined, + resolvesTo?: string, + ) { + const context = { + resolve: async () => (resolvesTo ? { id: resolvesTo } : null), + }; + return (plugin.resolveId as any).call(context, id, importer); + } + + it('redirects a symlinked provider path reached through resolution', async () => { + const result = await resolveId( + createPlugin(false), + '../session/provider.js', + join(sessionDir, '..', 'fetch', 'index.js'), + symlinkedProviderPath, + ); + assert.equal( + result, + expectedDisabledPath, + "a symlinked resolution of Astro's provider should still be redirected", + ); + }); + + it('redirects a symlinked provider path passed directly', async () => { + const result = await resolveId(createPlugin(false), symlinkedProviderPath, undefined); + assert.equal(result, expectedDisabledPath); + }); + + it('redirects the canonical provider path', async () => { + const result = await resolveId( + createPlugin(false), + join(sessionDir, 'provider.js'), + undefined, + ); + assert.equal(result, expectedDisabledPath); + }); + + it('leaves an unrelated `session/provider.js` alone', async () => { + const result = await resolveId( + createPlugin(false), + './session/provider.js', + '/project/src/pages/index.astro', + '/project/src/session/provider.js', + ); + assert.equal(result, null, 'user code should not be hijacked'); + }); + + it('keeps the real provider when a driver is configured', async () => { + const result = await resolveId( + createPlugin({ driver: 'fs-lite' }), + symlinkedProviderPath, + undefined, + ); + assert.equal(result, null); + }); + }); +}); diff --git a/packages/integrations/cloudflare/src/index.ts b/packages/integrations/cloudflare/src/index.ts index 22705b7c223d..188501d56a57 100644 --- a/packages/integrations/cloudflare/src/index.ts +++ b/packages/integrations/cloudflare/src/index.ts @@ -35,6 +35,9 @@ import { loadWranglerEnv } from './utils/wrangler-config.js'; const CLOUDFLARE_KV_SESSION_DRIVER_ENTRYPOINT = sessionDrivers.cloudflareKVBinding().entrypoint; function usesCloudflareKVSessionDriver(session: AstroConfig['session']): boolean { + if (session === false) { + return false; + } const driver = session?.driver; if (!driver) { @@ -167,7 +170,7 @@ export default function createIntegration({ ); } - if (!session?.driver) { + if (session !== false && !session?.driver) { logger.info( `Enabling sessions with Cloudflare KV with the "${sessionKVBindingName}" KV binding.`, ); diff --git a/packages/integrations/cloudflare/test/session-false.test.ts b/packages/integrations/cloudflare/test/session-false.test.ts new file mode 100644 index 000000000000..6d9aaf5d694a --- /dev/null +++ b/packages/integrations/cloudflare/test/session-false.test.ts @@ -0,0 +1,102 @@ +import assert from 'node:assert/strict'; +import { mkdtempSync, realpathSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { after, before, describe, it } from 'node:test'; +import { pathToFileURL } from 'node:url'; +import cloudflare from '../dist/index.js'; +import { cloudflareConfigCustomizer, DEFAULT_SESSION_KV_BINDING_NAME } from '../dist/wrangler.js'; + +let tempRoot: string; + +before(() => { + tempRoot = mkdtempSync(join(realpathSync(tmpdir()), 'astro-cloudflare-session-')); +}); + +after(() => { + rmSync(tempRoot, { recursive: true, force: true }); +}); + +async function runConfigSetup(session: unknown) { + const root = pathToFileURL(`${tempRoot}/`); + const logs: string[] = []; + let updated: any; + + const integration = cloudflare(); + await (integration.hooks['astro:config:setup'] as any)({ + command: 'build', + config: { + root, + session, + srcDir: new URL('./src/', root), + outDir: new URL('./dist/', root), + cacheDir: new URL('./.astro/', root), + build: { client: new URL('./dist/client/', root), server: new URL('./dist/server/', root) }, + vite: {}, + }, + logger: { + info: (message: string) => logs.push(message), + warn: () => {}, + error: () => {}, + debug: () => {}, + }, + updateConfig: (config: any) => { + updated = config; + return config; + }, + addWatchFile: () => {}, + }); + + return { session: updated?.session, logs }; +} + +describe('@astrojs/cloudflare session: false', () => { + it('wires no session driver when sessions are disabled', async () => { + const { session, logs } = await runConfigSetup(false); + assert.equal(session, false, 'the adapter should leave `session: false` untouched'); + assert.equal( + logs.some((message) => message.includes('Enabling sessions')), + false, + 'the adapter should not announce that it enabled sessions', + ); + }); + + it('does not override a driver the user configured', async () => { + const { session, logs } = await runConfigSetup({ driver: { entrypoint: 'custom-driver' } }); + assert.deepEqual(session?.driver, { entrypoint: 'custom-driver' }); + assert.equal( + logs.some((message) => message.includes('Enabling sessions')), + false, + ); + }); + + it('still wires Cloudflare KV by default', async () => { + const { session, logs } = await runConfigSetup(undefined); + assert.ok(session?.driver, 'expected the default KV driver to be wired'); + assert.equal( + logs.some((message) => message.includes('Enabling sessions with Cloudflare KV')), + true, + ); + }); + + describe('KV binding resource', () => { + const sessionBindings = (config: Record) => + [...(config.kv_namespaces ?? []), ...(config.previews?.kv_namespaces ?? [])].filter( + (kv) => kv.binding === DEFAULT_SESSION_KV_BINDING_NAME, + ); + + it('provisions no session KV namespace when the driver is not needed', () => { + const customize = cloudflareConfigCustomizer({ needsSessionKVBinding: false }); + assert.deepEqual(sessionBindings(customize({})), []); + }); + + it('provisions the session KV namespace when the driver is needed', () => { + const customize = cloudflareConfigCustomizer({ needsSessionKVBinding: true }); + assert.notEqual( + sessionBindings(customize({})).length, + 0, + 'expected the session KV binding to be provisioned', + ); + }); + }); +}); diff --git a/packages/integrations/netlify/src/index.ts b/packages/integrations/netlify/src/index.ts index e42f52f6eb79..90060a9c8673 100644 --- a/packages/integrations/netlify/src/index.ts +++ b/packages/integrations/netlify/src/index.ts @@ -624,7 +624,7 @@ export default function netlifyIntegration( let session = config.session; - if (!session?.driver) { + if (session !== false && !session?.driver) { logger.info('Enabling sessions with Netlify Blobs'); session = { diff --git a/packages/integrations/netlify/test/functions/session-false.test.ts b/packages/integrations/netlify/test/functions/session-false.test.ts new file mode 100644 index 000000000000..a9cfc728aa3c --- /dev/null +++ b/packages/integrations/netlify/test/functions/session-false.test.ts @@ -0,0 +1,81 @@ +import assert from 'node:assert/strict'; +import { mkdtempSync, realpathSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { after, before, describe, it } from 'node:test'; +import { pathToFileURL } from 'node:url'; +import netlifyIntegration from '../../dist/index.js'; + +let tempRoot: string; + +before(() => { + // The hook empties its function output directories, so it needs a real root. + tempRoot = mkdtempSync(join(realpathSync(tmpdir()), 'astro-netlify-session-')); +}); + +after(() => { + rmSync(tempRoot, { recursive: true, force: true }); +}); + +async function runConfigSetup(session: unknown) { + const root = pathToFileURL(`${tempRoot}/`); + const logs: string[] = []; + let updated: any; + + const integration = netlifyIntegration(); + await (integration.hooks['astro:config:setup'] as any)({ + command: 'build', + config: { + root, + session, + outDir: new URL('./dist/', root), + srcDir: new URL('./src/', root), + cacheDir: new URL('./.astro/', root), + image: { domains: [], remotePatterns: [] }, + build: { client: new URL('./dist/client/', root), server: new URL('./dist/server/', root) }, + }, + logger: { + info: (message: string) => logs.push(message), + warn: () => {}, + error: () => {}, + debug: () => {}, + }, + updateConfig: (config: any) => { + updated = config; + return config; + }, + addWatchFile: () => {}, + }); + + return { session: updated?.session, logs }; +} + +describe('@astrojs/netlify session: false', () => { + it('wires no session driver when sessions are disabled', async () => { + const { session, logs } = await runConfigSetup(false); + assert.equal(session, false, 'the adapter should leave `session: false` untouched'); + assert.equal( + logs.some((message) => message.includes('Enabling sessions')), + false, + 'the adapter should not announce that it enabled sessions', + ); + }); + + it('does not override a driver the user configured', async () => { + const { session, logs } = await runConfigSetup({ driver: { entrypoint: 'custom-driver' } }); + assert.deepEqual(session?.driver, { entrypoint: 'custom-driver' }); + assert.equal( + logs.some((message) => message.includes('Enabling sessions')), + false, + ); + }); + + it('still wires Netlify Blobs by default', async () => { + const { session, logs } = await runConfigSetup(undefined); + assert.ok(session?.driver, 'expected the default Netlify Blobs driver to be wired'); + assert.equal( + logs.some((message) => message.includes('Enabling sessions with Netlify Blobs')), + true, + ); + }); +}); diff --git a/packages/integrations/node/src/index.ts b/packages/integrations/node/src/index.ts index 349ece144668..780da838cb9b 100644 --- a/packages/integrations/node/src/index.ts +++ b/packages/integrations/node/src/index.ts @@ -42,7 +42,7 @@ export default function createIntegration(userOptions: UserOptions): AstroIntegr 'astro:config:setup': async ({ updateConfig, config, logger, command }) => { let session = config.session; _config = config; - if (!session?.driver) { + if (session !== false && !session?.driver) { logger.info('Enabling sessions with filesystem storage'); session = { driver: sessionDrivers.fsLite({ diff --git a/packages/integrations/node/test/session-false.test.ts b/packages/integrations/node/test/session-false.test.ts new file mode 100644 index 000000000000..f36124963bea --- /dev/null +++ b/packages/integrations/node/test/session-false.test.ts @@ -0,0 +1,69 @@ +import assert from 'node:assert/strict'; +import { resolve, sep } from 'node:path'; +import { describe, it } from 'node:test'; +import { pathToFileURL } from 'node:url'; +import nodejs from '../dist/index.js'; + +async function runConfigSetup(session: unknown) { + const root = pathToFileURL(resolve('/project') + sep); + const logs: string[] = []; + let updated: any; + + const integration = nodejs({ mode: 'standalone' }); + await (integration.hooks['astro:config:setup'] as any)({ + command: 'build', + config: { + root, + session, + cacheDir: new URL('./.astro/', root), + outDir: new URL('./dist/', root), + srcDir: new URL('./src/', root), + image: { endpoint: { route: undefined, entrypoint: undefined } }, + build: { client: new URL('./dist/client/', root), server: new URL('./dist/server/', root) }, + server: { host: false, port: 4321 }, + }, + logger: { + info: (message: string) => logs.push(message), + warn: () => {}, + error: () => {}, + debug: () => {}, + }, + updateConfig: (config: any) => { + updated = config; + return config; + }, + addWatchFile: () => {}, + }); + + return { session: updated?.session, logs }; +} + +describe('@astrojs/node session: false', () => { + it('wires no session driver when sessions are disabled', async () => { + const { session, logs } = await runConfigSetup(false); + assert.equal(session, false, 'the adapter should leave `session: false` untouched'); + assert.equal( + logs.some((message) => message.includes('Enabling sessions')), + false, + 'the adapter should not announce that it enabled sessions', + ); + }); + + it('does not override a driver the user configured', async () => { + const { session, logs } = await runConfigSetup({ driver: { entrypoint: 'custom-driver' } }); + assert.deepEqual(session?.driver, { entrypoint: 'custom-driver' }); + assert.equal( + logs.some((message) => message.includes('Enabling sessions')), + false, + ); + }); + + it('still wires filesystem storage by default', async () => { + const { session, logs } = await runConfigSetup(undefined); + assert.ok(session?.driver, 'expected the default filesystem driver to be wired'); + assert.equal( + logs.some((message) => message.includes('Enabling sessions with filesystem storage')), + true, + ); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 63b6e91e7a3e..ba34205bbc3a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3952,6 +3952,18 @@ importers: specifier: ^5.54.0 version: 5.55.3 + packages/astro/test/fixtures/session-false: + dependencies: + astro: + specifier: workspace:* + version: link:../../.. + + packages/astro/test/fixtures/session-tree-shake: + dependencies: + astro: + specifier: workspace:* + version: link:../../.. + packages/astro/test/fixtures/sessions: dependencies: astro: