diff options
author | 2025-03-31 10:05:44 +0100 | |
---|---|---|
committer | 2025-03-31 10:05:44 +0100 | |
commit | a9aafec47a4d8a92c826663dca2f9850643651ec (patch) | |
tree | d77835a9b422ab431fc8fee2ec6f80efae639472 /packages/integrations | |
parent | 19bd710ad5dd993628626ebd37f75d4d339b08a9 (diff) | |
download | astro-a9aafec47a4d8a92c826663dca2f9850643651ec.tar.gz astro-a9aafec47a4d8a92c826663dca2f9850643651ec.tar.zst astro-a9aafec47a4d8a92c826663dca2f9850643651ec.zip |
feat(cloudflare): add KV session storage support (#13514)
* feat(cloudflare): add KV session storage support
* Change code block language to JSONC
* Comments
* Use user-defined binding name
* Use createCodegenDir
* Remove unused import
Diffstat (limited to '')
17 files changed, 394 insertions, 6 deletions
diff --git a/packages/integrations/cloudflare/package.json b/packages/integrations/cloudflare/package.json index 72ef4ac05..5978877cb 100644 --- a/packages/integrations/cloudflare/package.json +++ b/packages/integrations/cloudflare/package.json @@ -36,14 +36,14 @@ "dependencies": { "@astrojs/internal-helpers": "workspace:*", "@astrojs/underscore-redirects": "workspace:*", - "@cloudflare/workers-types": "^4.20250317.0", + "@cloudflare/workers-types": "^4.20250327.0", "esbuild": "^0.25.0", "estree-walker": "^3.0.3", "magic-string": "^0.30.17", - "miniflare": "^4.20250317.0", + "miniflare": "^4.20250321.1", "tinyglobby": "^0.2.12", "vite": "^6.2.3", - "wrangler": "^4.2.0" + "wrangler": "^4.5.1" }, "peerDependencies": { "astro": "^5.0.0" @@ -52,6 +52,7 @@ "astro": "workspace:*", "astro-scripts": "workspace:*", "cheerio": "1.0.0", + "devalue": "^5.1.1", "execa": "^8.0.1", "rollup": "^4.35.0", "strip-ansi": "^7.1.0" diff --git a/packages/integrations/cloudflare/src/entrypoints/server.ts b/packages/integrations/cloudflare/src/entrypoints/server.ts index 231dd87fa..b3cc6daf5 100644 --- a/packages/integrations/cloudflare/src/entrypoints/server.ts +++ b/packages/integrations/cloudflare/src/entrypoints/server.ts @@ -26,6 +26,16 @@ export interface Runtime<T extends object = object> { }; } +declare global { + // This is not a real global, but is injected using Vite define to allow us to specify the session binding name in the config. + // eslint-disable-next-line no-var + var __ASTRO_SESSION_BINDING_NAME: string; + + // Just used to pass the KV binding to unstorage. + // eslint-disable-next-line no-var + var __env__: Partial<Env>; +} + export function createExports(manifest: SSRManifest) { const app = new App(manifest); @@ -35,7 +45,12 @@ export function createExports(manifest: SSRManifest) { context: ExecutionContext, ) => { const { pathname } = new URL(request.url); - + const bindingName = globalThis.__ASTRO_SESSION_BINDING_NAME; + // Assigning the KV binding to globalThis allows unstorage to access it for session storage. + // unstorage checks in globalThis and globalThis.__env__ for the binding. + globalThis.__env__ ??= {}; + globalThis.__env__[bindingName] = env[bindingName]; + // static assets fallback, in case default _routes.json is not used if (manifest.assets.has(pathname)) { return env.ASSETS.fetch(request.url.replace(/\.html$/, '')); diff --git a/packages/integrations/cloudflare/src/index.ts b/packages/integrations/cloudflare/src/index.ts index 346cc4021..3147b31fe 100644 --- a/packages/integrations/cloudflare/src/index.ts +++ b/packages/integrations/cloudflare/src/index.ts @@ -25,6 +25,7 @@ import { import { createGetEnv } from './utils/env.js'; import { createRoutesFile, getParts } from './utils/generate-routes-json.js'; import { setImageConfig } from './utils/image-config.js'; +import { fileURLToPath } from 'node:url'; export type { Runtime } from './entrypoints/server.js'; @@ -71,6 +72,34 @@ export type Options = { * for reference on how these file types are exported */ cloudflareModules?: boolean; + + /** + * By default, Astro will be configured to use Cloudflare KV to store session data. If you want to use sessions, + * you must create a KV namespace and declare it in your wrangler config file. You can do this with the wrangler command: + * + * ```sh + * npx wrangler kv namespace create SESSION + * ``` + * + * This will log the id of the created namespace. You can then add it to your `wrangler.json` file like this: + * + * ```json + * { + * "kv_namespaces": [ + * { + * "binding": "SESSION", + * "id": "<your kv namespace id here>" + * } + * ] + * } + * ``` + * By default, the driver looks for the binding named `SESSION`, but you can override this by providing a different name here. + * + * See https://developers.cloudflare.com/kv/concepts/kv-namespaces/ for more details on using KV namespaces. + * + */ + + sessionKVBindingName?: string; }; function wrapWithSlashes(path: string): string { @@ -110,7 +139,41 @@ export default function createIntegration(args?: Options): AstroIntegration { logger, addWatchFile, addMiddleware, + createCodegenDir, }) => { + let session = config.session; + + const isBuild = command === 'build'; + + if (config.experimental.session && !session?.driver) { + const sessionDir = isBuild ? undefined : createCodegenDir(); + const bindingName = args?.sessionKVBindingName ?? 'SESSION'; + logger.info( + `Configuring experimental session support using ${isBuild ? 'Cloudflare KV' : 'filesystem storage'}. Be sure to define a KV binding named "${bindingName}".`, + ); + logger.info( + `If you see the error "Invalid binding \`${bindingName}\`" in your build output, you need to add the binding to your wrangler config file.`, + ); + session = isBuild + ? { + ...session, + driver: 'cloudflare-kv-binding', + options: { + binding: bindingName, + ...session?.options, + }, + } + : { + ...session, + driver: 'fs-lite', + options: { + base: fileURLToPath(new URL('sessions', sessionDir)), + ...session?.options, + }, + }; + } + + updateConfig({ build: { client: new URL(`.${wrapWithSlashes(config.base)}`, config.outDir), @@ -118,6 +181,7 @@ export default function createIntegration(args?: Options): AstroIntegration { serverEntry: 'index.js', redirects: false, }, + session, vite: { plugins: [ // https://developers.cloudflare.com/pages/functions/module-support/ @@ -254,6 +318,10 @@ export default function createIntegration(args?: Options): AstroIntegration { // in a global way, so we shim their access as `process.env.*`. This is not the recommended way for users to access environment variables. But we'll add this for compatibility for chosen variables. Mainly to support `@astrojs/db` vite.define = { 'process.env': 'process.env', + // Allows the request handler to know what the binding name is + 'globalThis.__ASTRO_SESSION_BINDING_NAME': JSON.stringify( + args?.sessionKVBindingName ?? 'SESSION', + ), ...vite.define, }; } diff --git a/packages/integrations/cloudflare/test/fixtures/astro-dev-platform/package.json b/packages/integrations/cloudflare/test/fixtures/astro-dev-platform/package.json index a406e7c61..a39e254fa 100644 --- a/packages/integrations/cloudflare/test/fixtures/astro-dev-platform/package.json +++ b/packages/integrations/cloudflare/test/fixtures/astro-dev-platform/package.json @@ -7,6 +7,6 @@ "astro": "workspace:*" }, "devDependencies": { - "wrangler": "^3.112.0" + "wrangler": "^4.5.1" } } diff --git a/packages/integrations/cloudflare/test/fixtures/astro-env/package.json b/packages/integrations/cloudflare/test/fixtures/astro-env/package.json index d3b910161..4a9fd822b 100644 --- a/packages/integrations/cloudflare/test/fixtures/astro-env/package.json +++ b/packages/integrations/cloudflare/test/fixtures/astro-env/package.json @@ -7,6 +7,6 @@ "astro": "workspace:*" }, "devDependencies": { - "wrangler": "^4.4.1" + "wrangler": "^4.5.1" } } diff --git a/packages/integrations/cloudflare/test/fixtures/sessions/astro.config.mjs b/packages/integrations/cloudflare/test/fixtures/sessions/astro.config.mjs new file mode 100644 index 000000000..7d37e9762 --- /dev/null +++ b/packages/integrations/cloudflare/test/fixtures/sessions/astro.config.mjs @@ -0,0 +1,15 @@ +// @ts-check +import { defineConfig } from 'astro/config'; +import cloudflare from '@astrojs/cloudflare'; +export default defineConfig({ + output: 'server', + site: `http://example.com`, + adapter: cloudflare({ + platformProxy: { + enabled: true, + }, + }), + experimental: { + session: true, + } +}); diff --git a/packages/integrations/cloudflare/test/fixtures/sessions/package.json b/packages/integrations/cloudflare/test/fixtures/sessions/package.json new file mode 100644 index 000000000..d9a6f3af8 --- /dev/null +++ b/packages/integrations/cloudflare/test/fixtures/sessions/package.json @@ -0,0 +1,17 @@ +{ + "name": "@test/astro-cloudflare-sessions", + "version": "0.0.0", + "private": true, + "dependencies": { + "@astrojs/cloudflare": "workspace:*" + }, + "devDependencies": { + "astro": "workspace:*", + "wrangler": "^4.5.1" + }, + "scripts": { + "build": "astro build", + "preview": "astro build && wrangler dev", + "start": "astro dev" + } +} diff --git a/packages/integrations/cloudflare/test/fixtures/sessions/src/actions/index.ts b/packages/integrations/cloudflare/test/fixtures/sessions/src/actions/index.ts new file mode 100644 index 000000000..856f68ba8 --- /dev/null +++ b/packages/integrations/cloudflare/test/fixtures/sessions/src/actions/index.ts @@ -0,0 +1,36 @@ +import { defineAction } from 'astro:actions'; +import { z } from 'astro:schema'; + +export const server = { + addToCart: defineAction({ + accept: 'form', + input: z.object({ productId: z.string() }), + handler: async (input, context) => { + const cart: Array<string> = (await context.session.get('cart')) || []; + cart.push(input.productId); + await context.session.set('cart', cart); + return { cart, message: 'Product added to cart at ' + new Date().toTimeString() }; + }, + }), + getCart: defineAction({ + handler: async (input, context) => { + return await context.session.get('cart'); + }, + }), + clearCart: defineAction({ + accept: 'json', + handler: async (input, context) => { + await context.session.set('cart', []); + return { cart: [], message: 'Cart cleared at ' + new Date().toTimeString() }; + }, + }), + addUrl: defineAction({ + input: z.object({ favoriteUrl: z.string().url() }), + handler: async (input, context) => { + const previousFavoriteUrl = await context.session.get<URL>('favoriteUrl'); + const url = new URL(input.favoriteUrl); + context.session.set('favoriteUrl', url); + return { message: 'Favorite URL set to ' + url.href + ' from ' + (previousFavoriteUrl?.href ?? "nothing") }; + } + }) +} diff --git a/packages/integrations/cloudflare/test/fixtures/sessions/src/middleware.ts b/packages/integrations/cloudflare/test/fixtures/sessions/src/middleware.ts new file mode 100644 index 000000000..7f56f11f3 --- /dev/null +++ b/packages/integrations/cloudflare/test/fixtures/sessions/src/middleware.ts @@ -0,0 +1,49 @@ +import { defineMiddleware } from 'astro:middleware'; +import { getActionContext } from 'astro:actions'; + +const ACTION_SESSION_KEY = 'actionResult' + +export const onRequest = defineMiddleware(async (context, next) => { + // Skip requests for prerendered pages + if (context.isPrerendered) return next(); + + const { action, setActionResult, serializeActionResult } = + getActionContext(context); + + console.log(action?.name) + + const actionPayload = await context.session.get(ACTION_SESSION_KEY); + + if (actionPayload) { + setActionResult(actionPayload.actionName, actionPayload.actionResult); + context.session.delete(ACTION_SESSION_KEY); + return next(); + } + + // If an action was called from an HTML form action, + // call the action handler and redirect to the destination page + if (action?.calledFrom === "form") { + const actionResult = await action.handler(); + + context.session.set(ACTION_SESSION_KEY, { + actionName: action.name, + actionResult: serializeActionResult(actionResult), + }); + + + // Redirect back to the previous page on error + if (actionResult.error) { + const referer = context.request.headers.get("Referer"); + if (!referer) { + throw new Error( + "Internal: Referer unexpectedly missing from Action POST request.", + ); + } + return context.redirect(referer); + } + // Redirect to the destination page on success + return context.redirect(context.originPathname); + } + + return next(); +}); diff --git a/packages/integrations/cloudflare/test/fixtures/sessions/src/pages/api.ts b/packages/integrations/cloudflare/test/fixtures/sessions/src/pages/api.ts new file mode 100644 index 000000000..21793c78a --- /dev/null +++ b/packages/integrations/cloudflare/test/fixtures/sessions/src/pages/api.ts @@ -0,0 +1,13 @@ +import type { APIRoute } from 'astro'; + +export const GET: APIRoute = async (context) => { + const url = new URL(context.url, 'http://localhost'); + let value = url.searchParams.get('set'); + if (value) { + context.session.set('value', value); + } else { + value = await context.session.get('value'); + } + const cart = await context.session.get('cart'); + return Response.json({ value, cart }); +}; diff --git a/packages/integrations/cloudflare/test/fixtures/sessions/src/pages/cart.astro b/packages/integrations/cloudflare/test/fixtures/sessions/src/pages/cart.astro new file mode 100644 index 000000000..e69a9e5e1 --- /dev/null +++ b/packages/integrations/cloudflare/test/fixtures/sessions/src/pages/cart.astro @@ -0,0 +1,24 @@ +--- +import { actions } from "astro:actions"; + +const result = Astro.getActionResult(actions.addToCart); + +const cart = result?.data?.cart ?? await Astro.session.get('cart'); +const message = result?.data?.message ?? 'Add something to your cart!'; +--- +<p>Cart: <span id="cart">{JSON.stringify(cart)}</span></p> +<p id="message">{message}</p> +<form action={actions.addToCart} method="POST"> + <input type="text" name="productId" value="shoe" /> + <button type="submit">Add to Cart</button> +</form> +<input type="button" value="Clear Cart" id="clearCart" /> +<script> + import { actions } from "astro:actions"; + async function clearCart() { + const result = await actions.clearCart({}); + document.getElementById('cart').textContent = JSON.stringify(result.data.cart); + document.getElementById('message').textContent = result.data.message; + } + document.getElementById('clearCart').addEventListener('click', clearCart); +</script> diff --git a/packages/integrations/cloudflare/test/fixtures/sessions/src/pages/destroy.ts b/packages/integrations/cloudflare/test/fixtures/sessions/src/pages/destroy.ts new file mode 100644 index 000000000..e83f6e4b6 --- /dev/null +++ b/packages/integrations/cloudflare/test/fixtures/sessions/src/pages/destroy.ts @@ -0,0 +1,6 @@ +import type { APIRoute } from 'astro'; + +export const GET: APIRoute = async (context) => { + await context.session.destroy(); + return Response.json({}); +}; diff --git a/packages/integrations/cloudflare/test/fixtures/sessions/src/pages/index.astro b/packages/integrations/cloudflare/test/fixtures/sessions/src/pages/index.astro new file mode 100644 index 000000000..30d6a1618 --- /dev/null +++ b/packages/integrations/cloudflare/test/fixtures/sessions/src/pages/index.astro @@ -0,0 +1,13 @@ +--- +const value = await Astro.session.get('value'); +--- +<html lang="en"> +<head> + <meta charset="utf-8" /> + <title>Hi</title> +</head> + +<h1>Hi</h1> +<p>{value}</p> +<a href="/cart" style="font-size: 36px">🛒</a> +</html> diff --git a/packages/integrations/cloudflare/test/fixtures/sessions/src/pages/regenerate.ts b/packages/integrations/cloudflare/test/fixtures/sessions/src/pages/regenerate.ts new file mode 100644 index 000000000..6f2240588 --- /dev/null +++ b/packages/integrations/cloudflare/test/fixtures/sessions/src/pages/regenerate.ts @@ -0,0 +1,6 @@ +import type { APIRoute } from 'astro'; + +export const GET: APIRoute = async (context) => { + await context.session.regenerate(); + return Response.json({}); +}; diff --git a/packages/integrations/cloudflare/test/fixtures/sessions/src/pages/update.ts b/packages/integrations/cloudflare/test/fixtures/sessions/src/pages/update.ts new file mode 100644 index 000000000..71b058e75 --- /dev/null +++ b/packages/integrations/cloudflare/test/fixtures/sessions/src/pages/update.ts @@ -0,0 +1,10 @@ +import type { APIRoute } from 'astro'; + +export const GET: APIRoute = async (context) => { + const previousObject = await context.session.get("key") ?? { value: "none" }; + const previousValue = previousObject.value; + const sessionData = { value: "expected" }; + context.session.set("key", sessionData); + sessionData.value = "unexpected"; + return Response.json({previousValue}); +}; diff --git a/packages/integrations/cloudflare/test/fixtures/sessions/wrangler.json b/packages/integrations/cloudflare/test/fixtures/sessions/wrangler.json new file mode 100644 index 000000000..479d56c32 --- /dev/null +++ b/packages/integrations/cloudflare/test/fixtures/sessions/wrangler.json @@ -0,0 +1,22 @@ +{ + "name": "astro-cf-session", + "compatibility_date": "2024-11-01", + "compatibility_flags": [ + "nodejs_compat" + ], + "main": "./dist/_worker.js/index.js", + "assets": { + "directory": "./dist", + "binding": "ASSETS" + }, + "observability": { + "enabled": true + }, + "kv_namespaces": [ + { + "binding": "SESSION", + "id": "<SESSION_ID>" + } + ], + "upload_source_maps": true +} diff --git a/packages/integrations/cloudflare/test/sessions.test.js b/packages/integrations/cloudflare/test/sessions.test.js new file mode 100644 index 000000000..ab0ddf466 --- /dev/null +++ b/packages/integrations/cloudflare/test/sessions.test.js @@ -0,0 +1,93 @@ +import assert from 'node:assert/strict'; +import { after, before, describe, it } from 'node:test'; +import * as devalue from 'devalue'; +import { fileURLToPath } from 'node:url'; +import { astroCli, wranglerCli } from './_test-utils.js'; + +const root = new URL('./fixtures/sessions/', import.meta.url); + +describe('astro:env', () => { + let wrangler; + + before(async () => { + await astroCli(fileURLToPath(root), 'build'); + + wrangler = wranglerCli(fileURLToPath(root)); + await new Promise((resolve) => { + wrangler.stdout.on('data', (data) => { + // console.log('[stdout]', data.toString()); + if (data.toString().includes('http://127.0.0.1:8788')) resolve(); + }); + wrangler.stderr.on('data', (_data) => { + // console.log('[stderr]', _data.toString()); + }); + }); + }); + + after(() => { + wrangler.kill(); + }); + + it('can regenerate session cookies upon request', async () => { + const firstResponse = await fetch('http://127.0.0.1:8788/regenerate', { method: 'GET' }); + const firstHeaders = firstResponse.headers.get('set-cookie').split(','); + const firstSessionId = firstHeaders[0].split(';')[0].split('=')[1]; + + const secondResponse = await fetch('http://127.0.0.1:8788/regenerate', { + method: 'GET', + headers: { + cookie: `astro-session=${firstSessionId}`, + }, + }); + const secondHeaders = secondResponse.headers.get('set-cookie').split(','); + const secondSessionId = secondHeaders[0].split(';')[0].split('=')[1]; + assert.notEqual(firstSessionId, secondSessionId); + }); + + it('can save session data by value', async () => { + const firstResponse = await fetch('http://127.0.0.1:8788/update', { method: 'GET' }); + const firstValue = await firstResponse.json(); + assert.equal(firstValue.previousValue, 'none'); + + const firstHeaders = firstResponse.headers.get('set-cookie').split(','); + const firstSessionId = firstHeaders[0].split(';')[0].split('=')[1]; + const secondResponse = await fetch('http://127.0.0.1:8788/update', { + method: 'GET', + headers: { + cookie: `astro-session=${firstSessionId}`, + }, + }); + const secondValue = await secondResponse.json(); + assert.equal(secondValue.previousValue, 'expected'); + }); + + it('can save and restore URLs in session data', async () => { + const firstResponse = await fetch('http://127.0.0.1:8788/_actions/addUrl', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ favoriteUrl: 'https://domain.invalid' }), + }); + + assert.equal(firstResponse.ok, true); + const firstHeaders = firstResponse.headers.get('set-cookie').split(','); + const firstSessionId = firstHeaders[0].split(';')[0].split('=')[1]; + + const data = devalue.parse(await firstResponse.text()); + assert.equal(data.message, 'Favorite URL set to https://domain.invalid/ from nothing'); + const secondResponse = await fetch('http://127.0.0.1:8788/_actions/addUrl', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + cookie: `astro-session=${firstSessionId}`, + }, + body: JSON.stringify({ favoriteUrl: 'https://example.com' }), + }); + const secondData = devalue.parse(await secondResponse.text()); + assert.equal( + secondData.message, + 'Favorite URL set to https://example.com/ from https://domain.invalid/', + ); + }); +}); |