From 832840ab1e9dd5d991b07d4445e5ea631e8f7d14 Mon Sep 17 00:00:00 2001 From: Adam Chalemian Date: Tue, 14 Jul 2026 13:40:12 -0400 Subject: [PATCH 1/4] feat(session): add `session: false` to opt out of session support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a `session: false` config option that opts a project out of session support entirely, and tree-shakes the session runtime (`AstroSession` + `unstorage`) out of the SSR bundle for any project where no session driver is wired. - `session: false` is accepted by `SessionSchema` and threaded through so adapters skip auto-wiring their default driver (`@astrojs/cloudflare`, `@astrojs/netlify`, `@astrojs/node`). - When no driver will be present at request time — `session: false`, no `session` config at all, or a `session` object without a driver — `Astro.session` (and `context.session`) is `undefined`, matching its existing `AstroSession | undefined` type. This keeps the established `if (Astro.session)` feature-detection contract instead of throwing. - A new `astro:session-provider` Vite plugin swaps Astro's own `core/session/provider.js` for a runtime-free `provider-disabled.js` stub whenever no driver is wired, so Rollup drops `runtime.js` and `unstorage` from the bundle. Adapters wire their default driver during `astro:config:setup` (before `createVite`), so `config.session?.driver` reflects the final decision by resolve time — the same signal the driver virtual module uses. The swap is behavior-preserving: the real provider already resolves the session to `undefined` when no driver factory exists, so this only drops now-dead code. ```js title="astro.config.mjs" import { defineConfig } from 'astro/config'; export default defineConfig({ session: false, }); ``` Projects that already wire a session driver see no behavior change; the runtime is retained whenever a driver is configured. --- .changeset/session-false-cloudflare.md | 5 ++ .changeset/session-false-netlify.md | 5 ++ .changeset/session-false-node.md | 5 ++ .changeset/session-false-opt-out.md | 21 +++++ packages/astro/src/core/create-vite.ts | 3 +- .../astro/src/core/errors/default-handler.ts | 2 +- packages/astro/src/core/fetch/index.ts | 2 +- packages/astro/src/core/routing/handler.ts | 2 +- packages/astro/src/core/session/config.ts | 8 +- .../src/core/session/provider-disabled.ts | 16 ++++ packages/astro/src/core/session/provider.ts | 8 ++ packages/astro/src/core/session/utils.ts | 3 + .../astro/src/core/session/vite-plugin.ts | 63 +++++++++++-- packages/astro/src/types/public/config.ts | 12 ++- .../fixtures/session-false/astro.config.mjs | 4 + .../test/fixtures/session-false/package.json | 8 ++ .../fixtures/session-false/src/pages/api.ts | 7 ++ .../session-false/src/pages/no-session.ts | 5 ++ .../session-false/src/pages/user-provider.ts | 9 ++ .../session-false/src/session/provider.ts | 5 ++ .../session-tree-shake/astro.config.mjs | 4 + .../fixtures/session-tree-shake/package.json | 8 ++ .../session-tree-shake/src/pages/api.ts | 8 ++ .../src/pages/no-session.ts | 4 + packages/astro/test/session-false.test.ts | 62 +++++++++++++ .../astro/test/session-tree-shake.test.ts | 90 +++++++++++++++++++ .../test/units/config/config-validate.test.ts | 6 +- .../test/units/sessions/session-false.test.ts | 49 ++++++++++ packages/integrations/cloudflare/src/index.ts | 5 +- packages/integrations/netlify/src/index.ts | 2 +- packages/integrations/node/src/index.ts | 2 +- pnpm-lock.yaml | 12 +++ 32 files changed, 429 insertions(+), 16 deletions(-) create mode 100644 .changeset/session-false-cloudflare.md create mode 100644 .changeset/session-false-netlify.md create mode 100644 .changeset/session-false-node.md create mode 100644 .changeset/session-false-opt-out.md create mode 100644 packages/astro/src/core/session/provider-disabled.ts create mode 100644 packages/astro/src/core/session/provider.ts create mode 100644 packages/astro/test/fixtures/session-false/astro.config.mjs create mode 100644 packages/astro/test/fixtures/session-false/package.json create mode 100644 packages/astro/test/fixtures/session-false/src/pages/api.ts create mode 100644 packages/astro/test/fixtures/session-false/src/pages/no-session.ts create mode 100644 packages/astro/test/fixtures/session-false/src/pages/user-provider.ts create mode 100644 packages/astro/test/fixtures/session-false/src/session/provider.ts create mode 100644 packages/astro/test/fixtures/session-tree-shake/astro.config.mjs create mode 100644 packages/astro/test/fixtures/session-tree-shake/package.json create mode 100644 packages/astro/test/fixtures/session-tree-shake/src/pages/api.ts create mode 100644 packages/astro/test/fixtures/session-tree-shake/src/pages/no-session.ts create mode 100644 packages/astro/test/session-false.test.ts create mode 100644 packages/astro/test/session-tree-shake.test.ts create mode 100644 packages/astro/test/units/sessions/session-false.test.ts 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 49b3e11fb5f4..8fb9081fabd1 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 { isObject } from './util-runtime.js'; import { vitePluginEnvironment } from '../vite-plugin-environment/index.js'; import { ASTRO_VITE_ENVIRONMENT_NAMES } from './constants.js'; @@ -230,6 +230,7 @@ export async function createVite( vitePluginActions({ fs, settings }), vitePluginServerIslands({ settings, logger, serverIslandsState }), vitePluginSessionDriver({ settings }), + vitePluginSessionProvider({ settings }), vitePluginCacheProvider({ settings }), astroContainer(), astroHmrReloadPlugin(), diff --git a/packages/astro/src/core/errors/default-handler.ts b/packages/astro/src/core/errors/default-handler.ts index 84f0be42fb8b..54aec9d1c3ff 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 84f3d4aa3c5e..584d4e8ffbe6 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..9d89b7d2cc3e 100644 --- a/packages/astro/src/core/session/vite-plugin.ts +++ b/packages/astro/src/core/session/vite-plugin.ts @@ -3,6 +3,7 @@ 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 +28,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 +53,57 @@ 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'; + +export function vitePluginSessionProvider({ + settings, +}: { settings: AstroSettings }): VitePlugin { + // Paths are normalized to forward slashes: Vite/Rollup resolved ids are + // posix-style even on Windows, while fileURLToPath returns backslashes. + const providerPath = normalizePath( + fileURLToPath(new URL(`./${PROVIDER_FILENAME}`, import.meta.url)), + ); + const disabledProviderPath = normalizePath( + 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; + const normalizedId = normalizePath(id); + // Fast path: caller already passed Astro's absolute provider path. + if (normalizedId === providerPath) return disabledProviderPath; + // 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 (!importer || !normalizedId.endsWith(`/session/${PROVIDER_FILENAME}`)) return null; + const resolved = await this.resolve(id, importer, { skipSelf: true }); + if (resolved && normalizePath(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 70353a45a35d..a3408c50a7cb 100644 --- a/packages/astro/src/types/public/config.ts +++ b/packages/astro/src/types/public/config.ts @@ -1526,9 +1526,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 9223b796fc0b..04a9a1d49b86 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..1f7a2a2181cd --- /dev/null +++ b/packages/astro/test/units/sessions/session-false.test.ts @@ -0,0 +1,49 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +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'; + +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'); + }); + }); +}); diff --git a/packages/integrations/cloudflare/src/index.ts b/packages/integrations/cloudflare/src/index.ts index 4536ef017aec..b7d284ac96e9 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/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/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/pnpm-lock.yaml b/pnpm-lock.yaml index 02ca0ff7aba6..24b0c81b065c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3933,6 +3933,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: From 90316b5ecb60f6bb5dc6f853ad38ecfd1ea81b26 Mon Sep 17 00:00:00 2001 From: Adam Chalemian Date: Tue, 28 Jul 2026 16:40:33 -0400 Subject: [PATCH 2/4] fix(session): canonicalize paths when matching Astro's session provider The `astro:session-provider` plugin compared Vite's resolved id against a path derived from `import.meta.url`. Node's ESM loader canonicalizes `import.meta.url`, but Vite only resolves symlinks when `resolve.preserveSymlinks` is false (the default). Under `preserveSymlinks: true`, a pnpm or workspace link makes the two strings differ even though they name the same file, so the redirect to `provider-disabled.js` silently never happened and the session runtime stayed in the SSR bundle. Canonicalize both sides via `realpathSync` before comparing, falling back to a plain compare for ids that are not real files (bare specifiers, virtual modules). The cheap suffix prefilter now runs first so the added `realpathSync` never touches the filesystem for unrelated imports. --- .../astro/src/core/session/vite-plugin.ts | 34 ++++--- .../test/units/sessions/session-false.test.ts | 95 ++++++++++++++++++- 2 files changed, 116 insertions(+), 13 deletions(-) diff --git a/packages/astro/src/core/session/vite-plugin.ts b/packages/astro/src/core/session/vite-plugin.ts index 9d89b7d2cc3e..ee13808256be 100644 --- a/packages/astro/src/core/session/vite-plugin.ts +++ b/packages/astro/src/core/session/vite-plugin.ts @@ -1,3 +1,4 @@ +import { realpathSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import type { Plugin as VitePlugin } from 'vite'; import type { AstroSettings } from '../../types/astro.js'; @@ -73,15 +74,24 @@ export function vitePluginSessionDriver({ settings }: { settings: AstroSettings const PROVIDER_FILENAME = 'provider.js'; const DISABLED_PROVIDER_FILENAME = 'provider-disabled.js'; -export function vitePluginSessionProvider({ - settings, -}: { settings: AstroSettings }): VitePlugin { - // Paths are normalized to forward slashes: Vite/Rollup resolved ids are - // posix-style even on Windows, while fileURLToPath returns backslashes. - const providerPath = normalizePath( +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 = normalizePath( + const disabledProviderPath = canonicalizePath( fileURLToPath(new URL(`./${DISABLED_PROVIDER_FILENAME}`, import.meta.url)), ); return { @@ -92,15 +102,15 @@ export function vitePluginSessionProvider({ const session = settings.config.session; const hasSessionDriver = session !== false && !!session?.driver; if (hasSessionDriver) return null; - const normalizedId = normalizePath(id); - // Fast path: caller already passed Astro's absolute provider path. - if (normalizedId === providerPath) return disabledProviderPath; // 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 (!importer || !normalizedId.endsWith(`/session/${PROVIDER_FILENAME}`)) return null; + 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 && normalizePath(resolved.id) === providerPath) { + if (resolved && canonicalizePath(resolved.id) === providerPath) { return disabledProviderPath; } return null; diff --git a/packages/astro/test/units/sessions/session-false.test.ts b/packages/astro/test/units/sessions/session-false.test.ts index 1f7a2a2181cd..2d3c692a649c 100644 --- a/packages/astro/test/units/sessions/session-false.test.ts +++ b/packages/astro/test/units/sessions/session-false.test.ts @@ -1,8 +1,13 @@ import assert from 'node:assert/strict'; -import { describe, it } from 'node:test'; +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', () => { @@ -46,4 +51,92 @@ describe('session: false', () => { 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); + }); + }); }); From 93e1072c91b968a87d8cceef2eef4fd875280c70 Mon Sep 17 00:00:00 2001 From: Adam Chalemian Date: Tue, 28 Jul 2026 16:40:33 -0400 Subject: [PATCH 3/4] test(session): cover adapter `session: false` opt-out The `session: false` tests used the test adapter, which never supplies a default session driver, so they passed whether or not the Cloudflare, Netlify, and Node guards were present. Drive each adapter's `astro:config:setup` hook with a mock context and assert that `session: false` leaves the config untouched and wires no driver, that a user-supplied driver is still respected, and that the adapter default is still wired when sessions are left unconfigured. For Cloudflare, also assert the config customizer provisions no session KV namespace when the driver is not needed. --- .../cloudflare/test/session-false.test.ts | 102 ++++++++++++++++++ .../test/functions/session-false.test.ts | 81 ++++++++++++++ .../node/test/session-false.test.ts | 67 ++++++++++++ 3 files changed, 250 insertions(+) create mode 100644 packages/integrations/cloudflare/test/session-false.test.ts create mode 100644 packages/integrations/netlify/test/functions/session-false.test.ts create mode 100644 packages/integrations/node/test/session-false.test.ts 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/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/test/session-false.test.ts b/packages/integrations/node/test/session-false.test.ts new file mode 100644 index 000000000000..d7fe10eb9cb5 --- /dev/null +++ b/packages/integrations/node/test/session-false.test.ts @@ -0,0 +1,67 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import nodejs from '../dist/index.js'; + +async function runConfigSetup(session: unknown) { + const root = new URL('file:///project/'); + 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, + ); + }); +}); From de80530336c40ce953de4f6f52b7b2fe1a432b95 Mon Sep 17 00:00:00 2001 From: Adam Chalemian Date: Tue, 28 Jul 2026 17:08:28 -0400 Subject: [PATCH 4/4] fix(test): use a platform-absolute root in the Node session tests The `session: false` tests for `@astrojs/node` built their mock project root from a hardcoded `file:///project/`. That is only absolute on POSIX, so on Windows the adapter's `fileURLToPath(new URL('sessions', config.cacheDir))` threw `ERR_INVALID_FILE_URL_PATH`, failing the default-driver test. The other two cases short-circuit before that call, which is why only one test broke. Derive the root with `resolve` plus `pathToFileURL` so it carries a drive letter on Windows. Unlike the Cloudflare and Netlify tests, this hook only reads strings off the root, so it does not need a real directory on disk. --- packages/integrations/node/test/session-false.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/integrations/node/test/session-false.test.ts b/packages/integrations/node/test/session-false.test.ts index d7fe10eb9cb5..f36124963bea 100644 --- a/packages/integrations/node/test/session-false.test.ts +++ b/packages/integrations/node/test/session-false.test.ts @@ -1,9 +1,11 @@ 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 = new URL('file:///project/'); + const root = pathToFileURL(resolve('/project') + sep); const logs: string[] = []; let updated: any;