Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/session-false-cloudflare.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/session-false-netlify.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/session-false-node.md
Original file line number Diff line number Diff line change
@@ -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.
21 changes: 21 additions & 0 deletions .changeset/session-false-opt-out.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 2 additions & 1 deletion packages/astro/src/core/create-vite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -231,6 +231,7 @@ export async function createVite(
vitePluginActions({ fs, settings }),
vitePluginServerIslands({ settings, logger, serverIslandsState }),
vitePluginSessionDriver({ settings }),
vitePluginSessionProvider({ settings }),
vitePluginCacheProvider({ settings }),
vitePluginLogger({ settings }),
astroContainer(),
Expand Down
2 changes: 1 addition & 1 deletion packages/astro/src/core/errors/default-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
2 changes: 1 addition & 1 deletion packages/astro/src/core/fetch/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Pipeline> {
Expand Down
2 changes: 1 addition & 1 deletion packages/astro/src/core/routing/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
8 changes: 7 additions & 1 deletion packages/astro/src/core/session/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand All @@ -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]);
16 changes: 16 additions & 0 deletions packages/astro/src/core/session/provider-disabled.ts
Original file line number Diff line number Diff line change
@@ -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;
}
8 changes: 8 additions & 0 deletions packages/astro/src/core/session/provider.ts
Original file line number Diff line number Diff line change
@@ -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';
3 changes: 3 additions & 0 deletions packages/astro/src/core/session/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
73 changes: 68 additions & 5 deletions packages/astro/src/core/session/vite-plugin.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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) {
Expand All @@ -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;
},
};
}
12 changes: 11 additions & 1 deletion packages/astro/src/types/public/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<TDriver>;
session?: SessionConfig<TDriver> | false;

/**
* @docs
Expand Down
4 changes: 4 additions & 0 deletions packages/astro/test/fixtures/session-false/astro.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
// @ts-check
import { defineConfig } from 'astro/config';

export default defineConfig({});
8 changes: 8 additions & 0 deletions packages/astro/test/fixtures/session-false/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"name": "@test/session-false",
"version": "0.0.0",
"private": true,
"dependencies": {
"astro": "workspace:*"
}
}
7 changes: 7 additions & 0 deletions packages/astro/test/fixtures/session-false/src/pages/api.ts
Original file line number Diff line number Diff line change
@@ -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 });
};
Original file line number Diff line number Diff line change
@@ -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 });
Original file line number Diff line number Diff line change
@@ -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 });
Original file line number Diff line number Diff line change
@@ -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';
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
// @ts-check
import { defineConfig } from 'astro/config';

export default defineConfig({});
8 changes: 8 additions & 0 deletions packages/astro/test/fixtures/session-tree-shake/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"name": "@test/session-tree-shake",
"version": "0.0.0",
"private": true,
"dependencies": {
"astro": "workspace:*"
}
}
Original file line number Diff line number Diff line change
@@ -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 });
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import type { APIRoute } from 'astro';

// Route that never accesses the session.
export const GET: APIRoute = () => Response.json({ ok: true });
62 changes: 62 additions & 0 deletions packages/astro/test/session-false.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
Loading
Loading