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/cloudflare/test | |
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 '')
14 files changed, 306 insertions, 2 deletions
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/', + ); + }); +}); |