diff --git a/.changeset/stupid-snails-build.md b/.changeset/stupid-snails-build.md new file mode 100644 index 000000000000..ce35c9374765 --- /dev/null +++ b/.changeset/stupid-snails-build.md @@ -0,0 +1,6 @@ +--- +'@astrojs/node': minor +'astro': minor +--- + +Add support for running middleware on prerendered (static) pages in standalone mode diff --git a/examples/static-with-middleware/.codesandbox/Dockerfile b/examples/static-with-middleware/.codesandbox/Dockerfile new file mode 100644 index 000000000000..c3b5c81a121d --- /dev/null +++ b/examples/static-with-middleware/.codesandbox/Dockerfile @@ -0,0 +1 @@ +FROM node:18-bullseye diff --git a/examples/static-with-middleware/.gitignore b/examples/static-with-middleware/.gitignore new file mode 100644 index 000000000000..16d54bb13c8a --- /dev/null +++ b/examples/static-with-middleware/.gitignore @@ -0,0 +1,24 @@ +# build output +dist/ +# generated types +.astro/ + +# dependencies +node_modules/ + +# logs +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* + + +# environment variables +.env +.env.production + +# macOS-specific files +.DS_Store + +# jetbrains setting folder +.idea/ diff --git a/examples/static-with-middleware/.vscode/extensions.json b/examples/static-with-middleware/.vscode/extensions.json new file mode 100644 index 000000000000..22a15055d638 --- /dev/null +++ b/examples/static-with-middleware/.vscode/extensions.json @@ -0,0 +1,4 @@ +{ + "recommendations": ["astro-build.astro-vscode"], + "unwantedRecommendations": [] +} diff --git a/examples/static-with-middleware/.vscode/launch.json b/examples/static-with-middleware/.vscode/launch.json new file mode 100644 index 000000000000..d6422097621f --- /dev/null +++ b/examples/static-with-middleware/.vscode/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "command": "./node_modules/.bin/astro dev", + "name": "Development server", + "request": "launch", + "type": "node-terminal" + } + ] +} diff --git a/examples/static-with-middleware/README.md b/examples/static-with-middleware/README.md new file mode 100644 index 000000000000..7386f98d54e3 --- /dev/null +++ b/examples/static-with-middleware/README.md @@ -0,0 +1,66 @@ +# Astro Starter Kit: Static Pages with Runtime Middleware + +```sh +npm create astro@latest -- --template static-with-middleware +``` + +[![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/github/withastro/astro/tree/latest/examples/static-with-middleware) +[![Open with CodeSandbox](https://assets.codesandbox.io/github/button-edit-lime.svg)](https://codesandbox.io/p/sandbox/github/withastro/astro/tree/latest/examples/static-with-middleware) +[![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/withastro/astro?devcontainer_path=.devcontainer/static-with-middleware/devcontainer.json) + +> 🧑‍🚀 **Seasoned astronaut?** Delete this file. Have fun! + +This example demonstrates how to use middleware with statically generated pages using the Node adapter's `runMiddlewareOnRequest` option. + +Features: + +- ✅ 🔐 Authentication on static pages - Protect statically generated pages with cookie-based authentication +- ✅ 🚀 Best of both worlds - Get the performance of static pages with the security of runtime checks +- ✅ 🎯 No edge functions required - Runs on your Node.js server, not in a separate edge runtime + +## 🚀 Project Structure + +Inside of your Astro project, you'll see the following folders and files: + +```text +/ +├── public/ +├── src/ +│ ├── content/ +│ ├── layouts/ +│ ├── pages/ +│ ├── content.config.ts +│ └── middleware.ts +├── astro.config.mjs +├── README.md +├── package.json +└── tsconfig.json +``` + +This example includes a complete authentication flow: + +- **Login page** (`/login`) - Form-based login with demo credentials +- **Protected pages** (`/dashboard`, `/entries/secret-post`) - Require authentication +- **Auth API** (`/api/auth/login`, `/api/auth/logout`) - Handle login/logout with cookies +- **Middleware** (`src/middleware.ts`) - Checks `auth-token` cookie before serving protected pages + +The Node adapter automatically skips middleware during prerendering, then runs it at request time when serving static files with full cookie/header access. + +## 🧞 Commands + +All commands are run from the root of the project, from a terminal: + +| Command | Action | +| :------------------------ | :----------------------------------------------- | +| `npm install` | Installs dependencies | +| `npm run dev` | Starts local dev server at `localhost:4321` | +| `npm run build` | Build your production site to `./dist/` | +| `npm run preview` | Preview your build locally, before deploying | +| `npm run astro ...` | Run CLI commands like `astro add`, `astro check` | +| `npm run astro -- --help` | Get help using the Astro CLI | + +After building, start the server with `node dist/server/entry.mjs` to test the authentication flow. + +## 👀 Want to learn more? + +Feel free to check [our documentation](https://docs.astro.build) or jump into our [Discord server](https://astro.build/chat). diff --git a/examples/static-with-middleware/astro.config.mjs b/examples/static-with-middleware/astro.config.mjs new file mode 100644 index 000000000000..5acec6890467 --- /dev/null +++ b/examples/static-with-middleware/astro.config.mjs @@ -0,0 +1,13 @@ +// @ts-check +import node from '@astrojs/node'; +import { defineConfig } from 'astro/config'; + +// https://astro.build/config +export default defineConfig({ + output: 'static', + adapter: node({ + mode: 'standalone', + runMiddlewareOnRequest: true, + }), + integrations: [], +}); diff --git a/examples/static-with-middleware/package.json b/examples/static-with-middleware/package.json new file mode 100644 index 000000000000..709a69364161 --- /dev/null +++ b/examples/static-with-middleware/package.json @@ -0,0 +1,17 @@ +{ + "name": "@example/static-with-middleware", + "type": "module", + "version": "0.0.1", + "private": true, + "scripts": { + "dev": "astro dev", + "build": "astro build", + "preview": "astro preview", + "astro": "astro", + "server": "node dist/server/entry.mjs" + }, + "dependencies": { + "@astrojs/node": "^9.4.6", + "astro": "^5.14.4" + } +} \ No newline at end of file diff --git a/examples/static-with-middleware/public/favicon.svg b/examples/static-with-middleware/public/favicon.svg new file mode 100644 index 000000000000..f157bd1c5e28 --- /dev/null +++ b/examples/static-with-middleware/public/favicon.svg @@ -0,0 +1,9 @@ + + + + diff --git a/examples/static-with-middleware/src/content.config.ts b/examples/static-with-middleware/src/content.config.ts new file mode 100644 index 000000000000..438f3abd1065 --- /dev/null +++ b/examples/static-with-middleware/src/content.config.ts @@ -0,0 +1,16 @@ +import { defineCollection, z } from 'astro:content'; +import { glob } from 'astro/loaders'; + +const entries = defineCollection({ + // Load Markdown and MDX files in the `src/content/entries/` directory. + loader: glob({ base: './src/content/entries', pattern: '**/*.{md,mdx}' }), + // Type-check frontmatter using a schema + schema: () => + z.object({ + title: z.string(), + // Transform string to Date object + pubDate: z.coerce.date(), + }), +}); + +export const collections = { entries }; diff --git a/examples/static-with-middleware/src/content/entries/first-post.md b/examples/static-with-middleware/src/content/entries/first-post.md new file mode 100644 index 000000000000..97fb3b8b612b --- /dev/null +++ b/examples/static-with-middleware/src/content/entries/first-post.md @@ -0,0 +1,6 @@ +--- +title: 'First post' +pubDate: 'Jul 08 2022' +--- + +This is my first post! diff --git a/examples/static-with-middleware/src/content/entries/second-post.md b/examples/static-with-middleware/src/content/entries/second-post.md new file mode 100644 index 000000000000..35f88b6b5ded --- /dev/null +++ b/examples/static-with-middleware/src/content/entries/second-post.md @@ -0,0 +1,6 @@ +--- +title: 'Second post' +pubDate: 'Jul 15 2022' +--- + +Another post has been published. diff --git a/examples/static-with-middleware/src/content/entries/secret-post.md b/examples/static-with-middleware/src/content/entries/secret-post.md new file mode 100644 index 000000000000..7bfa2bc55b4c --- /dev/null +++ b/examples/static-with-middleware/src/content/entries/secret-post.md @@ -0,0 +1,6 @@ +--- +title: 'Secret post' +pubDate: 'Jul 22 2022' +--- + +This post is only visible for users that are logged in. diff --git a/examples/static-with-middleware/src/layouts/Layout.astro b/examples/static-with-middleware/src/layouts/Layout.astro new file mode 100644 index 000000000000..88d69ccb1da1 --- /dev/null +++ b/examples/static-with-middleware/src/layouts/Layout.astro @@ -0,0 +1,127 @@ +--- +const { title } = Astro.props; +--- + + + + + + {title} + + +
+ +

{title}

+
+
+ +
+ + + + diff --git a/examples/static-with-middleware/src/middleware.ts b/examples/static-with-middleware/src/middleware.ts new file mode 100644 index 000000000000..366ceb86397e --- /dev/null +++ b/examples/static-with-middleware/src/middleware.ts @@ -0,0 +1,45 @@ +import { defineMiddleware } from 'astro:middleware'; + +const AUTH_COOKIE_NAME = 'auth-token'; +const REDIRECT_COOKIE_NAME = 'redirect-after-login'; + +/** + * Middleware runs only during requests, not during build. + * + * IMPORTANT: While this middleware has access to cookies, headers, and request context, + * the Astro page components themselves do NOT have access to this runtime data because + * they are prerendered during the build step. This means: + * - `Astro.cookies.get()` in page components will always return undefined + * - `context.locals` set here won't be accessible in page components + * - Only this middleware can read/write cookies and make decisions based on request data + * + * This is perfect for authentication (redirect to login), but won't work for + * personalizing page content based on cookies/headers. + */ +export const onRequest = defineMiddleware(async (context, next) => { + // Protected paths - require authentication + const protectedPaths = ['/secret', '/dashboard']; + const isProtected = protectedPaths.some((path) => context.originPathname.includes(path)); + + if (!isProtected) { + return next(); + } + + // Dummy auth validation + const authToken = context.cookies.get(AUTH_COOKIE_NAME); + const isAuthed = authToken?.value === 'valid-token'; + + if (!isAuthed) { + const response = context.redirect('/login'); + context.cookies.set(REDIRECT_COOKIE_NAME, context.originPathname, { + path: '/', + httpOnly: true, + maxAge: 60 * 5, // 5 minutes + }); + + return response; + } + + // User is authenticated, allow access to the page + return next(); +}); diff --git a/examples/static-with-middleware/src/pages/404.astro b/examples/static-with-middleware/src/pages/404.astro new file mode 100644 index 000000000000..721f91fbbde4 --- /dev/null +++ b/examples/static-with-middleware/src/pages/404.astro @@ -0,0 +1,7 @@ +--- +import Layout from '../layouts/Layout.astro'; +--- + + +

Sorry, we couldn't find the page you were looking for.

+
diff --git a/examples/static-with-middleware/src/pages/api/auth/[...all].ts b/examples/static-with-middleware/src/pages/api/auth/[...all].ts new file mode 100644 index 000000000000..5cebb59dc41b --- /dev/null +++ b/examples/static-with-middleware/src/pages/api/auth/[...all].ts @@ -0,0 +1,47 @@ +import type { APIRoute } from "astro"; + +export const prerender = false; + +// Simple demo auth - in production, use a real auth library +const DEMO_USERNAME = 'demo'; +const DEMO_PASSWORD = 'password'; +const AUTH_COOKIE_NAME = 'auth-token'; +const REDIRECT_COOKIE_NAME = 'redirect-after-login'; + +export const POST: APIRoute = async ({ request, cookies, redirect }) => { + const url = new URL(request.url); + const action = url.pathname.split('/').pop(); + + if (action === 'login') { + const formData = await request.formData(); + const username = formData.get('username')?.toString(); + const password = formData.get('password')?.toString(); + + if (username === DEMO_USERNAME && password === DEMO_PASSWORD) { + // Set auth cookie (in production, use a secure session token) + cookies.set(AUTH_COOKIE_NAME, 'valid-token', { + path: '/', + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + maxAge: 60 * 60 * 24, // 24 hours + }); + + // Redirect to original URL or home + const redirectTo = cookies.get(REDIRECT_COOKIE_NAME)?.value || '/'; + cookies.delete(REDIRECT_COOKIE_NAME, { path: '/' }); + + return redirect(redirectTo); + } + + // Invalid credentials + return redirect('/login?error=invalid'); + } + + if (action === 'logout') { + cookies.delete(AUTH_COOKIE_NAME, { path: '/' }); + return redirect('/'); + } + + return new Response('Not found', { status: 404 }); +}; diff --git a/examples/static-with-middleware/src/pages/dashboard.astro b/examples/static-with-middleware/src/pages/dashboard.astro new file mode 100644 index 000000000000..85561fe4a643 --- /dev/null +++ b/examples/static-with-middleware/src/pages/dashboard.astro @@ -0,0 +1,15 @@ +--- +import Layout from '../layouts/Layout.astro'; + +export const prerender = true; // This is a static page! + +--- + + +
+

+ This is a protected static page that only authenticated users can access. If you see this, + you're successfully signed in! +

+
+
diff --git a/examples/static-with-middleware/src/pages/entries/[entry].astro b/examples/static-with-middleware/src/pages/entries/[entry].astro new file mode 100644 index 000000000000..19e81bffd957 --- /dev/null +++ b/examples/static-with-middleware/src/pages/entries/[entry].astro @@ -0,0 +1,24 @@ +--- +import { type CollectionEntry, getCollection, getEntry, render } from 'astro:content'; +import Layout from '../../layouts/Layout.astro'; + +export async function getStaticPaths() { + const posts = await getCollection('entries'); + return posts.map((post) => ({ + params: { entry: post.id }, + props: post, + })); +} +type Props = CollectionEntry<'entries'>; + +const { entry } = Astro.params; +const post = await getEntry('entries', entry); +if (post === undefined) { + return new Response(null, { status: 404 }); +} +const { Content } = await render(post); +--- + + + + diff --git a/examples/static-with-middleware/src/pages/index.astro b/examples/static-with-middleware/src/pages/index.astro new file mode 100644 index 000000000000..46674b29c488 --- /dev/null +++ b/examples/static-with-middleware/src/pages/index.astro @@ -0,0 +1,122 @@ +--- +import { getCollection } from 'astro:content'; +import Layout from '../layouts/Layout.astro'; + +export const entries = await getCollection('entries'); +--- + + +

Static Pages with Middleware Demo

+

This project demonstrates how to protect static pages with runtime middleware using cookies and authentication.

+ +
+

Try the Auth Flow

+
    +
  1. Click on "Protected Dashboard" below (you'll be redirected to login)
  2. +
  3. Use credentials: demo / password
  4. +
  5. You'll be redirected back to the dashboard
  6. +
  7. The dashboard is a static page protected by middleware!
  8. +
+ + +
+ +
+

Blog Entries

+ +
+ +
+

How It Works

+ +
+
+ + diff --git a/examples/static-with-middleware/src/pages/login.astro b/examples/static-with-middleware/src/pages/login.astro new file mode 100644 index 000000000000..b49899ddccf6 --- /dev/null +++ b/examples/static-with-middleware/src/pages/login.astro @@ -0,0 +1,43 @@ +--- +import Layout from '../layouts/Layout.astro'; + +const error = Astro.url.searchParams.get('error'); +--- + + +
+

You must be logged in to view protected pages.

+ + { + error === 'invalid' && ( +
Invalid username or password. Please try again.
+ ) + } + +
+

Demo Credentials (use these below)

+

+ Username: demo
+ Password: password +

+ +
+ + + +
+
+
+
diff --git a/examples/static-with-middleware/src/pages/logout.astro b/examples/static-with-middleware/src/pages/logout.astro new file mode 100644 index 000000000000..04640b0c40bd --- /dev/null +++ b/examples/static-with-middleware/src/pages/logout.astro @@ -0,0 +1,10 @@ +--- +import Layout from '../layouts/Layout.astro'; +--- + + +

Use the button below to sign out of your account.

+
+ +
+
diff --git a/examples/static-with-middleware/tsconfig.json b/examples/static-with-middleware/tsconfig.json new file mode 100644 index 000000000000..8bf91d3bb997 --- /dev/null +++ b/examples/static-with-middleware/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "astro/tsconfigs/strict", + "include": [".astro/types.d.ts", "**/*"], + "exclude": ["dist"] +} diff --git a/packages/astro/src/core/app/index.ts b/packages/astro/src/core/app/index.ts index 47592ffd5e2e..9cfb7ec526bf 100644 --- a/packages/astro/src/core/app/index.ts +++ b/packages/astro/src/core/app/index.ts @@ -6,6 +6,8 @@ import { import { matchPattern, type RemotePattern } from '../../assets/utils/remotePattern.js'; import { normalizeTheLocale } from '../../i18n/index.js'; import type { RoutesList } from '../../types/astro.js'; +import type { MiddlewareHandler } from '../../types/public/common.js'; +import type { APIContext } from '../../types/public/context.js'; import type { RouteData, SSRManifest } from '../../types/public/internal.js'; import { clientAddressSymbol, @@ -18,7 +20,9 @@ import { getSetCookiesFromResponse } from '../cookies/index.js'; import { AstroError, AstroErrorData } from '../errors/index.js'; import { consoleLogDestination } from '../logger/console.js'; import { AstroIntegrationLogger, Logger } from '../logger/core.js'; +import { createContext } from '../middleware/index.js'; import { NOOP_MIDDLEWARE_FN } from '../middleware/noop-middleware.js'; +import { sequence } from '../middleware/sequence.js'; import { appendForwardSlash, joinPaths, @@ -142,6 +146,168 @@ export class App { return this.#manifest.allowedDomains; } + /** + * Get the middleware handler from the pipeline. + * This includes origin checking and user-defined middleware, but NOT i18n middleware. + */ + async getMiddleware(): Promise { + return await this.#pipeline.getMiddleware(); + } + + /** + * Get the complete middleware chain including i18n, origin checking, and user-defined middleware. + * This is the same middleware sequence used by RenderContext for SSR routes. + * Use this method when you need to execute middleware for prerendered/static routes. + */ + async getAllMiddleware(): Promise { + const pipelineMiddleware = await this.#pipeline.getMiddleware(); + return sequence(...this.#pipeline.internalMiddleware, pipelineMiddleware); + } + + /** + * Execute middleware for a request without performing a full render. + * This is the centralized middleware execution logic used by both SSR and static routes. + * + * @param request - The incoming request + * @param routeData - Optional route data for the matched route + * @param middleware - The middleware chain to execute + * @param locals - Optional locals object to pass to middleware + * @returns Object with: + * - handled: true if middleware handled the response (didn't call next()) + * - response: The response from middleware, or null if next() was called without returning + * + * **Security Note:** For prerendered routes, the context is marked as `isPrerendered: true`, + * which causes the origin checking middleware to skip CSRF validation. This is intentional + * to allow static file serving, but you should implement your own security checks in custom + * middleware if needed for prerendered pages. + */ + async executeMiddleware( + request: Request, + routeData: RouteData | undefined, + middleware: MiddlewareHandler, + locals?: object, + ): Promise<{ handled: boolean; response: Response | null }> { + // Create the API context with full capabilities + const ctx = this.#createAPIContext(request, routeData, locals); + + // Track whether next() was called + let nextCalled = false; + + // Create a next function - if called, we'll continue with normal flow + const middlewareNext = async (): Promise => { + nextCalled = true; + // Return a dummy response - the caller will handle continuing + return new Response(null); + }; + + // Execute middleware + const response = await middleware(ctx, middlewareNext); + + // If middleware returned a response and didn't call next(), it handled the request + if (response && !nextCalled) { + // Append cookies from context to response headers + for (const setCookieHeaderValue of ctx.cookies.headers()) { + response.headers.append('set-cookie', setCookieHeaderValue); + } + + return { handled: true, response }; + } + + // Middleware called next() - continue with normal flow + // But return the response so headers can be copied if needed + if (response) { + // Append cookies from context to response headers + for (const setCookieHeaderValue of ctx.cookies.headers()) { + response.headers.append('set-cookie', setCookieHeaderValue); + } + return { handled: false, response }; + } + + return { handled: false, response: null }; + } + + /** + * Creates a full-featured APIContext for middleware execution. + * This provides the same context capabilities as SSR routes, including rewrites, sessions, CSP, etc. + * @private + */ + #createAPIContext( + request: Request, + routeData: RouteData | undefined, + locals?: object, + ): APIContext { + // Extract params from routeData if available + const params = + routeData && typeof routeData.params === 'object' && !Array.isArray(routeData.params) + ? routeData.params + : {}; + + // Get user-defined locales from manifest + const userDefinedLocales = this.#getUserDefinedLocales(); + const defaultLocale = this.#manifest.i18n?.defaultLocale || ''; + + // Create base context using the standard createContext function + const baseCtx = createContext({ + request, + params, + locals: locals ?? {}, + defaultLocale, + userDefinedLocales, + }); + + // Enhance with additional properties (site, routePattern, isPrerendered) + if (this.#manifest.site) { + baseCtx.site = new URL(this.#manifest.site); + } + + if (routeData) { + baseCtx.routePattern = routeData.route; + // Mark as prerendered if this is a static route + if (routeData.prerender) { + baseCtx.isPrerendered = true; + } + } + + // Return the enhanced context + // Note: For static routes, rewrite() will return a dummy response since we can't actually rewrite + // Session access will be undefined for prerendered routes (same as RenderContext behavior) + return baseCtx; + } + + /** + * Get user-defined locales from the manifest in a typed, safe way. + * Handles various manifest structures for i18n locales. + */ + #getUserDefinedLocales(): string[] { + if (!this.#manifest.i18n?.locales) { + return []; + } + + const locales = this.#manifest.i18n.locales; + + // Handle array of strings or locale objects + if (Array.isArray(locales)) { + return locales.filter((l): l is string => typeof l === 'string'); + } + + // Handle single string + if (typeof locales === 'string') { + return [locales]; + } + + // Handle object with codes property + if ( + typeof locales === 'object' && + locales !== null && + 'codes' in locales && + Array.isArray((locales as any).codes) + ) { + return (locales as any).codes; + } + + return []; + } + protected get manifest(): SSRManifest { return this.#manifest; } diff --git a/packages/astro/src/core/build/generate.ts b/packages/astro/src/core/build/generate.ts index 36197100f0ff..64ac890e2e94 100644 --- a/packages/astro/src/core/build/generate.ts +++ b/packages/astro/src/core/build/generate.ts @@ -76,7 +76,10 @@ export async function generatePages(options: StaticBuildOptions, internals: Buil const baseDirectory = getServerOutputDirectory(options.settings); const renderersEntryUrl = new URL('renderers.mjs', baseDirectory); const renderers = await import(renderersEntryUrl.toString()); - const middleware: MiddlewareHandler = internals.middlewareEntryPoint + + const skipMiddleware = options.settings.adapter?.adapterFeatures?.skipMiddlewareOnPrerender === true; + + const middleware: MiddlewareHandler = internals.middlewareEntryPoint && !skipMiddleware ? await import(internals.middlewareEntryPoint.toString()).then((mod) => mod.onRequest) : NOOP_MIDDLEWARE_FN; diff --git a/packages/astro/src/core/build/pipeline.ts b/packages/astro/src/core/build/pipeline.ts index e355d8f41007..79af090c5ae9 100644 --- a/packages/astro/src/core/build/pipeline.ts +++ b/packages/astro/src/core/build/pipeline.ts @@ -130,13 +130,16 @@ export class BuildPipeline extends Pipeline { const renderersEntryUrl = new URL(`renderers.mjs?time=${Date.now()}`, baseDirectory); const renderers = await import(renderersEntryUrl.toString()); - const middleware = internals.middlewareEntryPoint - ? async function () { - // @ts-expect-error: the compiler can't understand the previous check - const mod = await import(internals.middlewareEntryPoint.toString()); - return { onRequest: mod.onRequest }; - } - : manifest.middleware; + let middleware = undefined; + if (!settings.adapter?.adapterFeatures?.skipMiddlewareOnPrerender) { + middleware = internals.middlewareEntryPoint + ? async function () { + // @ts-expect-error: the compiler can't understand the previous check + const mod = await import(internals.middlewareEntryPoint.toString()); + return { onRequest: mod.onRequest }; + } + : manifest.middleware; + } if (!renderers) { throw new Error( diff --git a/packages/astro/src/core/request.ts b/packages/astro/src/core/request.ts index e2c79211e99a..33a77f18c193 100644 --- a/packages/astro/src/core/request.ts +++ b/packages/astro/src/core/request.ts @@ -13,12 +13,17 @@ interface CreateRequestOptions { locals?: object | undefined; /** * Whether the request is being created for a static build or for a prerendered page within a hybrid/SSR build, or for emulating one of those in dev mode. - * + * * + * @default false + */ + isPrerendered?: boolean; + /** * When `true`, the request will not include search parameters or body, and warn when headers are accessed. + * This is the default for prerendered pages unless `skipMiddlewareOnPrerender` is set to `true` in the adapter config. * * @default false */ - isPrerendered?: boolean; + useStaticContext?: boolean; routePattern: string; @@ -39,11 +44,12 @@ export function createRequest({ body = undefined, logger, isPrerendered = false, + useStaticContext = false, routePattern, init, }: CreateRequestOptions): Request { // headers are made available on the created request only if the request is for a page that will be on-demand rendered - const headersObj = isPrerendered + const headersObj = useStaticContext ? undefined : headers instanceof Headers ? headers @@ -57,8 +63,8 @@ export function createRequest({ if (typeof url === 'string') url = new URL(url); - // Remove search parameters if the request is for a page that will be on-demand rendered - if (isPrerendered) { + // Remove search parameters + if (useStaticContext) { url.search = ''; } @@ -70,7 +76,7 @@ export function createRequest({ ...init, }); - if (isPrerendered) { + if (useStaticContext) { // Warn when accessing headers in SSG mode let _headers = request.headers; diff --git a/packages/astro/src/types/public/integrations.ts b/packages/astro/src/types/public/integrations.ts index d96c07a77596..1f00d549d2a0 100644 --- a/packages/astro/src/types/public/integrations.ts +++ b/packages/astro/src/types/public/integrations.ts @@ -100,6 +100,12 @@ export interface AstroAdapterFeatures { * is out of experimental */ experimentalStaticHeaders?: boolean; + + /** + * If enabled by the adapter, middleware will be skipped during the prerendering phase. + * This is used by adapters that handle middleware at request time for static pages. + */ + skipMiddlewareOnPrerender?: boolean; } /** diff --git a/packages/astro/src/vite-plugin-astro-server/route.ts b/packages/astro/src/vite-plugin-astro-server/route.ts index 142b00cbb6ac..483713973105 100644 --- a/packages/astro/src/vite-plugin-astro-server/route.ts +++ b/packages/astro/src/vite-plugin-astro-server/route.ts @@ -176,6 +176,8 @@ export async function handleRoute({ method: incomingRequest.method, body, logger, + useStaticContext: + route.prerender && !pipeline.settings.adapter?.adapterFeatures?.skipMiddlewareOnPrerender, isPrerendered: route.prerender, routePattern: route.component, }); diff --git a/packages/integrations/node/src/index.ts b/packages/integrations/node/src/index.ts index 2a2a0e535992..21bfb681ff01 100644 --- a/packages/integrations/node/src/index.ts +++ b/packages/integrations/node/src/index.ts @@ -22,6 +22,7 @@ export function getAdapter(options: Options): AstroAdapter { buildOutput: 'server', edgeMiddleware: false, experimentalStaticHeaders: options.experimentalStaticHeaders, + skipMiddlewareOnPrerender: options.runMiddlewareOnRequest, }, supportedAstroFeatures: { hybridOutput: 'stable', @@ -94,6 +95,7 @@ export default function createIntegration(userOptions: UserOptions): AstroIntegr 'astro:build:generated': ({ experimentalRouteToHeaders }) => { _routeToHeaders = experimentalRouteToHeaders; }, + 'astro:config:done': ({ setAdapter, config }) => { _options = { ...userOptions, @@ -103,6 +105,7 @@ export default function createIntegration(userOptions: UserOptions): AstroIntegr port: config.server.port, assets: config.build.assets, experimentalStaticHeaders: userOptions.experimentalStaticHeaders ?? false, + runMiddlewareOnRequest: userOptions.runMiddlewareOnRequest ?? false, experimentalErrorPageHost, }; setAdapter(getAdapter(_options)); diff --git a/packages/integrations/node/src/serve-app.ts b/packages/integrations/node/src/serve-app.ts index 3d22e9c5f334..a62de336bb75 100644 --- a/packages/integrations/node/src/serve-app.ts +++ b/packages/integrations/node/src/serve-app.ts @@ -1,5 +1,6 @@ -import { AsyncLocalStorage } from 'node:async_hooks'; import { NodeApp } from 'astro/app/node'; +import { createRequestSafely, handleRequestCreationError } from './serve-utils.js'; +import { requestAls } from './standalone.js'; import type { Options, RequestHandler } from './types.js'; /** @@ -8,18 +9,6 @@ import type { Options, RequestHandler } from './types.js'; * Intended to be used in both standalone and middleware mode. */ export function createAppHandler(app: NodeApp, options: Options): RequestHandler { - /** - * Keep track of the current request path using AsyncLocalStorage. - * Used to log unhandled rejections with a helpful message. - */ - const als = new AsyncLocalStorage(); - const logger = app.getAdapterLogger(); - process.on('unhandledRejection', (reason) => { - const requestUrl = als.getStore(); - logger.error(`Unhandled rejection while rendering ${requestUrl}`); - console.error(reason); - }); - const originUrl = options.experimentalErrorPageHost ? new URL(options.experimentalErrorPageHost) : undefined; @@ -34,16 +23,10 @@ export function createAppHandler(app: NodeApp, options: Options): RequestHandler : undefined; return async (req, res, next, locals) => { - let request: Request; - try { - request = NodeApp.createRequest(req, { - allowedDomains: app.getAllowedDomains?.() ?? [], - }); - } catch (err) { - logger.error(`Could not render ${req.url}`); - console.error(err); - res.statusCode = 500; - res.end('Internal Server Error'); + // Create Request object with proper error handling + const { request, error: requestError } = createRequestSafely(req, app); + if (!request) { + handleRequestCreationError(req, res, requestError, app); return; } @@ -51,7 +34,7 @@ export function createAppHandler(app: NodeApp, options: Options): RequestHandler // handle them dynamically, so prerendered routes are included here. const routeData = app.match(request, true); if (routeData) { - const response = await als.run(request.url, () => + const response = await requestAls.run(request.url, () => app.render(request, { addCookieHeader: true, locals, @@ -63,7 +46,10 @@ export function createAppHandler(app: NodeApp, options: Options): RequestHandler } else if (next) { return next(); } else { - const response = await app.render(req, { addCookieHeader: true, prerenderedErrorPageFetch }); + const response = await app.render(request, { + addCookieHeader: true, + prerenderedErrorPageFetch, + }); await NodeApp.writeResponse(response, res); } }; diff --git a/packages/integrations/node/src/serve-middleware.ts b/packages/integrations/node/src/serve-middleware.ts new file mode 100644 index 000000000000..f0c1463a7b6b --- /dev/null +++ b/packages/integrations/node/src/serve-middleware.ts @@ -0,0 +1,30 @@ +import type { RouteData } from 'astro'; + +/** + * Check if a URL path is for a prerendered HTML page (not an asset). + * Middleware should only run for HTML pages, not static assets. + * + * Static assets are identified by: + * - Paths starting with `/_astro/` (Astro's built assets directory) + * - Files with common asset extensions: css, js, json, xml, txt, ico, png, jpg, jpeg, + * gif, svg, woff, woff2, ttf, eot, webp, avif, map + * + * All other paths are considered HTML pages and will have middleware executed. + */ +function isPrerenderedHTMLPage(urlPath: string): boolean { + // Middleware should run for HTML pages, not asset files + return !urlPath.startsWith('/_astro/') && !/\.(?:css|js|json|xml|txt|ico|png|jpg|jpeg|gif|svg|woff|woff2|ttf|eot|webp|avif|map)$/i.test(urlPath); +} + +/** + * Determine if middleware should be executed for this request. + * Only runs when the adapter has opted in via runMiddlewareOnRequest option. + */ +export function shouldRunMiddleware( + urlPath: string | undefined, + _routeData: RouteData | undefined, + runMiddlewareOnRequest: boolean, +): boolean { + // Only run middleware for prerendered HTML pages when enabled + return !!(urlPath && runMiddlewareOnRequest && isPrerenderedHTMLPage(urlPath)); +} diff --git a/packages/integrations/node/src/serve-static.ts b/packages/integrations/node/src/serve-static.ts index c427e21a7e01..83d885022420 100644 --- a/packages/integrations/node/src/serve-static.ts +++ b/packages/integrations/node/src/serve-static.ts @@ -3,8 +3,16 @@ import type { IncomingMessage, ServerResponse } from 'node:http'; import path from 'node:path'; import url from 'node:url'; import { hasFileExtension, isInternalPath } from '@astrojs/internal-helpers/path'; -import type { NodeApp } from 'astro/app/node'; +import { NodeApp } from 'astro/app/node'; import send from 'send'; +import { shouldRunMiddleware } from './serve-middleware.js'; +import { + createRequestSafely, + handleRequestCreationError, + send500Response, + setRouteHeaders, + tryServe500ErrorPage, +} from './serve-utils.js'; import type { Options } from './types.js'; /** @@ -15,104 +23,166 @@ import type { Options } from './types.js'; */ export function createStaticHandler(app: NodeApp, options: Options) { const client = resolveClientDir(options); + /** * @param ssr The SSR handler to be called if the static handler does not find a matching file. + * @param locals Optional locals object that will be passed to middleware. */ - return (req: IncomingMessage, res: ServerResponse, ssr: () => unknown) => { - if (req.url) { - const [urlPath, urlQuery] = req.url.split('?'); - const filePath = path.join(client, app.removeBase(urlPath)); + return async ( + req: IncomingMessage, + res: ServerResponse, + ssr: () => unknown, + locals?: object, + ) => { + if (!req.url) { + return ssr(); + } + + const [urlPath, urlQuery] = req.url.split('?'); + + // 1. Create Request object safely (early) + const { request, error: requestError } = createRequestSafely(req, app); + if (!request) { + handleRequestCreationError(req, res, requestError, app); + return; + } - let isDirectory = false; + // 2. Match route once + const routeData = app.match(request, true); + + // 3. Check if middleware should run for this request + if (shouldRunMiddleware(urlPath, routeData, options.runMiddlewareOnRequest ?? false)) { try { - isDirectory = fs.lstatSync(filePath).isDirectory(); - } catch {} - - const { trailingSlash = 'ignore' } = options; - - const hasSlash = urlPath.endsWith('/'); - let pathname = urlPath; - - if (app.headersMap && app.headersMap.length > 0) { - const routeData = app.match(req, true); - if (routeData && routeData.prerender) { - const matchedRoute = app.headersMap.find((header) => header.pathname.includes(pathname)); - if (matchedRoute) { - for (const header of matchedRoute.headers) { - res.setHeader(header.key, header.value); - } + // Get middleware (already cached by the pipeline) + const middleware = await app.getAllMiddleware(); + + // Execute middleware using the core method + const result = await app.executeMiddleware( + request, + routeData, + middleware, + locals, + ); + + if (result.handled && result.response) { + // Middleware handled the response completely (didn't call next()) + // Set route headers and write the middleware's response + setRouteHeaders(res, app, routeData, urlPath); + await NodeApp.writeResponse(result.response, res); + return; + } else if (result.response) { + // Middleware called next() but may have set headers + // Apply middleware headers to the response before serving static file + for (const [key, value] of result.response.headers.entries()) { + res.setHeader(key, value); } } + // Otherwise, fall through to static file serving + } catch (err) { + const error = err instanceof Error ? err : new Error(String(err)); + + // Try to serve prerendered 500 error page + if (tryServe500ErrorPage(req, res, client, app, error)) { + return; + } + + // No error page available, return generic 500 + send500Response(req, res, error, app); + return; } + } - switch (trailingSlash) { - case 'never': { - if (isDirectory && urlPath !== '/' && hasSlash) { - pathname = urlPath.slice(0, -1) + (urlQuery ? '?' + urlQuery : ''); - res.statusCode = 301; - res.setHeader('Location', pathname); - return res.end(); - } - if (isDirectory && !hasSlash) { - pathname = `${urlPath}/index.html`; - } - break; + // 4. Set headers for static file serving (if not already handled by middleware) + setRouteHeaders(res, app, routeData, urlPath); + + // 5. Handle trailing slash and directory logic + const filePath = path.join(client, app.removeBase(urlPath)); + let isDirectory = false; + try { + isDirectory = fs.lstatSync(filePath).isDirectory(); + } catch {} + + const { trailingSlash = 'ignore' } = options; + const hasSlash = urlPath.endsWith('/'); + let pathname = urlPath; + + switch (trailingSlash) { + case 'never': { + if (isDirectory && urlPath !== '/' && hasSlash) { + pathname = urlPath.slice(0, -1) + (urlQuery ? '?' + urlQuery : ''); + res.statusCode = 301; + res.setHeader('Location', pathname); + return res.end(); } - case 'ignore': { - if (isDirectory && !hasSlash) { - pathname = `${urlPath}/index.html`; - } - break; + if (isDirectory && !hasSlash) { + pathname = `${urlPath}/index.html`; } - case 'always': { - // trailing slash is not added to "subresources" - // We check if `urlPath` doesn't contain possible internal paths. This should prevent - // redirects to unwanted paths - if (!hasSlash && !hasFileExtension(urlPath) && !isInternalPath(urlPath)) { - pathname = urlPath + '/' + (urlQuery ? '?' + urlQuery : ''); - res.statusCode = 301; - res.setHeader('Location', pathname); - return res.end(); - } - break; + break; + } + case 'ignore': { + if (isDirectory && !hasSlash) { + pathname = `${urlPath}/index.html`; } + break; } - // app.removeBase sometimes returns a path without a leading slash - pathname = prependForwardSlash(app.removeBase(pathname)); + case 'always': { + // trailing slash is not added to "subresources" + // We check if `urlPath` doesn't contain possible internal paths. This should prevent + // redirects to unwanted paths + if (!hasSlash && !hasFileExtension(urlPath) && !isInternalPath(urlPath)) { + pathname = urlPath + '/' + (urlQuery ? '?' + urlQuery : ''); + res.statusCode = 301; + res.setHeader('Location', pathname); + return res.end(); + } + break; + } + } - const stream = send(req, pathname, { - root: client, - dotfiles: pathname.startsWith('/.well-known/') ? 'allow' : 'deny', - }); + // 6. Serve the static file + // app.removeBase sometimes returns a path without a leading slash + pathname = prependForwardSlash(app.removeBase(pathname)); - let forwardError = false; + const stream = send(req, pathname, { + root: client, + dotfiles: pathname.startsWith('/.well-known/') ? 'allow' : 'deny', + }); - stream.on('error', (err) => { - if (forwardError) { - console.error(err.toString()); - res.writeHead(500); - res.end('Internal server error'); - return; - } - // File not found, forward to the SSR handler - ssr(); - }); - stream.on('headers', (_res: ServerResponse) => { - // assets in dist/_astro are hashed and should get the immutable header - if (pathname.startsWith(`/${options.assets}/`)) { - // This is the "far future" cache header, used for static files whose name includes their digest hash. - // 1 year (31,536,000 seconds) is convention. - // Taken from https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control#immutable - _res.setHeader('Cache-Control', 'public, max-age=31536000, immutable'); - } - }); - stream.on('file', () => { - forwardError = true; - }); - stream.pipe(res); - } else { + let forwardError = false; + + stream.on('error', (err) => { + if (forwardError) { + const logger = app.getAdapterLogger(); + logger.error(`Could not serve static file ${req.url}`); + console.error(err); + res.statusCode = 500; + res.end('Internal Server Error'); + return; + } + // File not found + // For asset files (when runMiddlewareOnRequest is enabled), return 404 directly + // instead of forwarding to SSR to avoid running middleware on missing assets + if (options.runMiddlewareOnRequest && !shouldRunMiddleware(urlPath, routeData, true)) { + res.statusCode = 404; + res.end('Not Found'); + return; + } + // Forward to the SSR handler for HTML pages ssr(); - } + }); + stream.on('headers', (_res: ServerResponse) => { + // assets in dist/_astro are hashed and should get the immutable header + if (pathname.startsWith(`/${options.assets}/`)) { + // This is the "far future" cache header, used for static files whose name includes their digest hash. + // 1 year (31,536,000 seconds) is convention. + // Taken from https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control#immutable + _res.setHeader('Cache-Control', 'public, max-age=31536000, immutable'); + } + }); + stream.on('file', () => { + forwardError = true; + }); + stream.pipe(res); }; } @@ -122,7 +192,9 @@ function resolveClientDir(options: Options) { const rel = path.relative(url.fileURLToPath(serverURLRaw), url.fileURLToPath(clientURLRaw)); // walk up the parent folders until you find the one that is the root of the server entry folder. This is how we find the client folder relatively. - const serverFolder = path.basename(options.server); + // Convert server to string/path in case it's a URL object + const serverPath = typeof options.server === 'string' ? options.server : url.fileURLToPath(serverURLRaw); + const serverFolder = path.basename(serverPath); let serverEntryFolderURL = path.dirname(import.meta.url); while (!serverEntryFolderURL.endsWith(serverFolder)) { serverEntryFolderURL = path.dirname(serverEntryFolderURL); @@ -140,3 +212,6 @@ function prependForwardSlash(pth: string) { function appendForwardSlash(pth: string) { return pth.endsWith('/') ? pth : pth + '/'; } + + + diff --git a/packages/integrations/node/src/serve-utils.ts b/packages/integrations/node/src/serve-utils.ts new file mode 100644 index 000000000000..687eac5d9503 --- /dev/null +++ b/packages/integrations/node/src/serve-utils.ts @@ -0,0 +1,112 @@ +import fs from 'node:fs'; +import type { IncomingMessage, ServerResponse } from 'node:http'; +import path from 'node:path'; +import type { RouteData } from 'astro'; +import { NodeApp } from 'astro/app/node'; +import send from 'send'; + +/** + * Safely create a Request object with proper error handling. + * Returns null if Request creation fails. + */ +export function createRequestSafely( + req: IncomingMessage, + app: NodeApp, +): { request: Request; error: null } | { request: null; error: Error } { + try { + const request = NodeApp.createRequest(req, { + allowedDomains: app.getAllowedDomains?.() ?? [], + }); + return { request, error: null }; + } catch (err) { + return { request: null, error: err instanceof Error ? err : new Error(String(err)) }; + } +} + +/** + * Handle errors that occur during Request creation. + * Logs the error and sends a 500 response. + */ +export function handleRequestCreationError( + req: IncomingMessage, + res: ServerResponse, + error: Error, + app: NodeApp, +): void { + const logger = app.getAdapterLogger(); + logger.error(`Could not render ${req.url}`); + console.error(error); + res.statusCode = 500; + res.end('Internal Server Error'); +} + +/** + * Attempt to serve a prerendered 500 error page. + * Returns true if successful, false otherwise. + */ +export function tryServe500ErrorPage( + req: IncomingMessage, + res: ServerResponse, + clientDir: string, + app: NodeApp, + error: Error, +): boolean { + const logger = app.getAdapterLogger(); + const errorPagePath = path.join(clientDir, app.removeBase('/500.html')); + + try { + if (fs.existsSync(errorPagePath)) { + res.statusCode = 500; + const errorStream = send(req, app.removeBase('/500.html'), { + root: clientDir, + }); + errorStream.pipe(res); + logger.error(`Error in middleware for ${req.url}, serving prerendered error page`); + console.error(error); + return true; + } + } catch { + // Fall through to return false + } + + return false; +} + +/** + * Send a generic 500 Internal Server Error response. + */ +export function send500Response( + req: IncomingMessage, + res: ServerResponse, + error: Error, + app: NodeApp, +): void { + const logger = app.getAdapterLogger(); + logger.error(`Could not render ${req.url}`); + console.error(error); + res.statusCode = 500; + res.end('Internal Server Error'); +} + +/** + * Set headers for a route from the headersMap. + * This applies headers that were defined in the Astro config for specific routes. + */ +export function setRouteHeaders( + res: ServerResponse, + app: NodeApp, + routeData: RouteData | undefined, + urlPath: string, +): void { + // Only set headers if we have the headersMap and the route is prerendered + if (!app.headersMap || app.headersMap.length === 0 || !routeData?.prerender) { + return; + } + + const matchedRoute = app.headersMap.find((header) => header.pathname.includes(urlPath)); + if (matchedRoute) { + for (const header of matchedRoute.headers) { + res.setHeader(header.key, header.value); + } + } +} diff --git a/packages/integrations/node/src/standalone.ts b/packages/integrations/node/src/standalone.ts index 22587e1a5416..99727b7be3c3 100644 --- a/packages/integrations/node/src/standalone.ts +++ b/packages/integrations/node/src/standalone.ts @@ -1,3 +1,4 @@ +import { AsyncLocalStorage } from 'node:async_hooks'; import fs from 'node:fs'; import http from 'node:http'; import https from 'node:https'; @@ -9,6 +10,12 @@ import { createAppHandler } from './serve-app.js'; import { createStaticHandler } from './serve-static.js'; import type { Options } from './types.js'; +/** + * Shared AsyncLocalStorage for tracking request URLs across static and app handlers. + * Used to provide helpful error messages in unhandledRejection logs. + */ +export const requestAls = new AsyncLocalStorage(); + // Used to get Host Value at Runtime export const hostOptions = (host: Options['host']): string => { if (typeof host === 'boolean') { @@ -20,6 +27,20 @@ export const hostOptions = (host: Options['host']): string => { export default function standalone(app: NodeApp, options: Options) { const port = process.env.PORT ? Number(process.env.PORT) : (options.port ?? 8080); const host = process.env.HOST ?? hostOptions(options.host); + + // Set up a single unhandledRejection handler for both static and app handlers + const logger = app.getAdapterLogger(); + const unhandledRejectionHandler = (reason: unknown) => { + const requestUrl = requestAls.getStore(); + if (requestUrl) { + logger.error(`Unhandled rejection while processing ${requestUrl}`); + } else { + logger.error('Unhandled rejection'); + } + console.error(reason); + }; + process.on('unhandledRejection', unhandledRejectionHandler); + const handler = createStandaloneHandler(app, options); const server = createServer(handler, host, port); server.server.listen(port, host); diff --git a/packages/integrations/node/src/types.ts b/packages/integrations/node/src/types.ts index 4a318bdeda29..0c6fa71e053c 100644 --- a/packages/integrations/node/src/types.ts +++ b/packages/integrations/node/src/types.ts @@ -30,6 +30,29 @@ export interface UserOptions { * static files are hosted on a different domain. Do not include a path in the URL: it will be ignored. */ experimentalErrorPageHost?: string | URL; + + /** + * Run middleware on request, even for prerendered (static) pages. + * + * If enabled, middleware will execute before serving static HTML files, + * allowing access to cookies, headers, and query parameters for authentication, + * personalization, and other request-based logic. Middleware will not run during build time. + * + * **Important limitations:** + * - `Astro.cookies` and `context.locals` accessed in page components will NOT reflect + * middleware changes, as pages are prerendered during build time. Only middleware + * has access to the runtime request context. + * - CSRF/origin checking is automatically disabled for prerendered pages to allow + * static file serving. Implement your own security checks in middleware if needed. + * - Middleware runs on every request to HTML pages, which may impact performance + * compared to purely static serving. + * + * **Note:** This option specifically enables middleware for static/prerendered pages. + * For SSR/hybrid pages, middleware runs by default without this option. + * + * @default false + */ + runMiddlewareOnRequest?: boolean; } export interface Options extends UserOptions { @@ -40,6 +63,7 @@ export interface Options extends UserOptions { assets: string; trailingSlash?: SSRManifest['trailingSlash']; experimentalStaticHeaders: boolean; + runMiddlewareOnRequest: boolean; } export type RequestHandler = (...args: RequestHandlerParams) => void | Promise; diff --git a/packages/integrations/node/test/fixtures/middleware-static/astro.config.mjs b/packages/integrations/node/test/fixtures/middleware-static/astro.config.mjs new file mode 100644 index 000000000000..8b04b7a26c67 --- /dev/null +++ b/packages/integrations/node/test/fixtures/middleware-static/astro.config.mjs @@ -0,0 +1,13 @@ +import { defineConfig } from 'astro/config'; +import nodejs from '@astrojs/node'; + +export default defineConfig({ + output: 'static', + adapter: nodejs({ + mode: 'standalone', + runMiddlewareOnRequest: true, + }), + security: { + checkOrigin: true, + }, +}); diff --git a/packages/integrations/node/test/fixtures/middleware-static/package.json b/packages/integrations/node/test/fixtures/middleware-static/package.json new file mode 100644 index 000000000000..b8f55849dbee --- /dev/null +++ b/packages/integrations/node/test/fixtures/middleware-static/package.json @@ -0,0 +1,9 @@ +{ + "name": "@test/middleware-static", + "version": "0.0.0", + "private": true, + "dependencies": { + "@astrojs/node": "workspace:*", + "astro": "workspace:*" + } +} diff --git a/packages/integrations/node/test/fixtures/middleware-static/src/middleware.ts b/packages/integrations/node/test/fixtures/middleware-static/src/middleware.ts new file mode 100644 index 000000000000..27761ee404c8 --- /dev/null +++ b/packages/integrations/node/test/fixtures/middleware-static/src/middleware.ts @@ -0,0 +1,63 @@ +import { defineMiddleware } from 'astro:middleware'; + +/** + * Test middleware for static pages with runMiddlewareOnRequest enabled. + * + * Note: This middleware has access to runtime request data (cookies, headers, etc.), + * but the prerendered page components do NOT. Pages are built at build time and + * cannot access runtime request context like Astro.cookies or context.locals. + */ +export const onRequest = defineMiddleware(async (context, next) => { + const url = new URL(context.request.url); + + // Handle blocked paths BEFORE calling next() + if (url.pathname === '/blocked') { + return new Response('Access denied', { status: 403 }); + } + + // Handle redirects BEFORE calling next() + if (url.pathname === '/redirect-me') { + return context.redirect('/redirected', 302); + } + + // Call next() to get the response + const response = await next(); + + // Add a header to show middleware ran + response.headers.set('x-middleware-ran', 'true'); + + // Check if locals were set by external middleware (e.g., Express) + if (context.locals.expressUser) { + response.headers.set('x-express-user', String(context.locals.expressUser)); + } + if (context.locals.expressSessionId) { + response.headers.set('x-express-session', String(context.locals.expressSessionId)); + } + + // Handle cookies + const testCookie = context.cookies.get('test-cookie'); + if (testCookie) { + response.headers.set('x-cookie-value', testCookie.value); + } + + // Handle user-agent + const userAgent = context.request.headers.get('user-agent'); + if (userAgent === 'test-agent') { + response.headers.set('x-user-agent-processed', 'true'); + } + + // Handle query parameters + if (url.searchParams.get('debug') === 'true') { + response.headers.set('x-debug-mode', 'true'); + } + + // Set cookies for specific pages + if (url.pathname === '/set-cookie-page') { + context.cookies.set('middleware-cookie', 'value', { + path: '/', + httpOnly: true, + }); + } + + return response; +}); diff --git a/packages/integrations/node/test/fixtures/middleware-static/src/pages/allowed.astro b/packages/integrations/node/test/fixtures/middleware-static/src/pages/allowed.astro new file mode 100644 index 000000000000..1e886266283f --- /dev/null +++ b/packages/integrations/node/test/fixtures/middleware-static/src/pages/allowed.astro @@ -0,0 +1,8 @@ + + + Allowed + + +

Static Page

+ + diff --git a/packages/integrations/node/test/fixtures/middleware-static/src/pages/blocked.astro b/packages/integrations/node/test/fixtures/middleware-static/src/pages/blocked.astro new file mode 100644 index 000000000000..3d333331eca8 --- /dev/null +++ b/packages/integrations/node/test/fixtures/middleware-static/src/pages/blocked.astro @@ -0,0 +1,8 @@ + + + Blocked + + +

This should not be visible

+ + diff --git a/packages/integrations/node/test/fixtures/middleware-static/src/pages/error-page.astro b/packages/integrations/node/test/fixtures/middleware-static/src/pages/error-page.astro new file mode 100644 index 000000000000..749945479a11 --- /dev/null +++ b/packages/integrations/node/test/fixtures/middleware-static/src/pages/error-page.astro @@ -0,0 +1,8 @@ + + + Error Page + + +

Static Error Page

+ + diff --git a/packages/integrations/node/test/fixtures/middleware-static/src/pages/redirected.astro b/packages/integrations/node/test/fixtures/middleware-static/src/pages/redirected.astro new file mode 100644 index 000000000000..65a830004082 --- /dev/null +++ b/packages/integrations/node/test/fixtures/middleware-static/src/pages/redirected.astro @@ -0,0 +1,8 @@ + + + Redirected + + +

You were redirected here

+ + diff --git a/packages/integrations/node/test/fixtures/middleware-static/src/pages/set-cookie-page.astro b/packages/integrations/node/test/fixtures/middleware-static/src/pages/set-cookie-page.astro new file mode 100644 index 000000000000..df02768290c8 --- /dev/null +++ b/packages/integrations/node/test/fixtures/middleware-static/src/pages/set-cookie-page.astro @@ -0,0 +1,8 @@ + + + Set Cookie + + +

Cookie will be set

+ + diff --git a/packages/integrations/node/test/fixtures/middleware-static/src/pages/static-page.astro b/packages/integrations/node/test/fixtures/middleware-static/src/pages/static-page.astro new file mode 100644 index 000000000000..ea068c3e483c --- /dev/null +++ b/packages/integrations/node/test/fixtures/middleware-static/src/pages/static-page.astro @@ -0,0 +1,12 @@ +--- +const cookieValue = Astro.cookies.get('test-cookie')?.value || 'no-cookie'; +--- + + + Static Page + + +

Static Page

+

Cookie: {cookieValue}

+ + diff --git a/packages/integrations/node/test/middleware-static-pages.test.js b/packages/integrations/node/test/middleware-static-pages.test.js new file mode 100644 index 000000000000..0fd1b91af423 --- /dev/null +++ b/packages/integrations/node/test/middleware-static-pages.test.js @@ -0,0 +1,286 @@ +// Test file for middleware with static pages +// packages/integrations/node/test/middleware-static-pages.test.js + +import assert from 'node:assert/strict'; +import { after, before, describe, it } from 'node:test'; +import nodejs from '../dist/index.js'; +import { loadFixture, waitServerListen } from './test-utils.js'; + +describe('Middleware for static pages', () => { + /** @type {import('./test-utils').Fixture} */ + let fixture; + let server; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/middleware-static/', + output: 'static', + adapter: nodejs({ + mode: 'standalone', + runMiddlewareOnRequest: true, // Enable the feature + }), + }); + await fixture.build(); + const { startServer } = await fixture.loadAdapterEntryModule(); + const res = startServer(); + server = res.server; + await waitServerListen(server.server); + }); + + after(async () => { + await server.stop(); + await fixture.clean(); + }); + + describe('Middleware execution', () => { + it('should run middleware for prerendered HTML pages', async () => { + const res = await fetch(`http://${server.host}:${server.port}/static-page`); + // Check for header added by middleware + assert.ok(res.headers.get('x-middleware-ran')); + assert.equal(res.headers.get('x-middleware-ran'), 'true'); + }); + + it('should have access to cookies in middleware', async () => { + const res = await fetch(`http://${server.host}:${server.port}/static-page`, { + headers: { + cookie: 'test-cookie=test-value', + }, + }); + // Middleware should have read the cookie and added it to response + assert.ok(res.headers.get('x-cookie-value')); + assert.equal(res.headers.get('x-cookie-value'), 'test-value'); + }); + + it('should have access to headers in middleware', async () => { + const res = await fetch(`http://${server.host}:${server.port}/static-page`, { + headers: { + 'user-agent': 'test-agent', + }, + }); + // Check if middleware processed the header + assert.ok(res.headers.get('x-user-agent-processed')); + assert.equal(res.headers.get('x-user-agent-processed'), 'true'); + }); + + it('should have access to query parameters', async () => { + const res = await fetch(`http://${server.host}:${server.port}/static-page?debug=true`); + // Middleware should have read query param and added header + assert.ok(res.headers.get('x-debug-mode')); + assert.equal(res.headers.get('x-debug-mode'), 'true'); + }); + + it('should resolve cookie value to "no-cookie" in prerendered page content', async () => { + // Send a request with a cookie + const res = await fetch(`http://${server.host}:${server.port}/static-page`, { + headers: { + cookie: 'test-cookie=should-not-appear', + }, + }); + assert.equal(res.status, 200); + const html = await res.text(); + + // The page was prerendered, so it should show "no-cookie" regardless of the cookie sent + // This is because Astro.cookies.get() on prerendered pages returns undefined/no-cookie + assert.ok(html.includes('Cookie: no-cookie')); + // Should NOT include the actual cookie value sent in the request + assert.ok(!html.includes('should-not-appear')); + }); + }); + + describe('Response handling', () => { + it('should use middleware response when middleware returns early', async () => { + const res = await fetch(`http://${server.host}:${server.port}/blocked`); + assert.equal(res.status, 403); + const text = await res.text(); + assert.ok(text.includes('Access denied')); + }); + + it('should redirect when middleware returns redirect', async () => { + const res = await fetch(`http://${server.host}:${server.port}/redirect-me`, { + redirect: 'manual', + }); + assert.equal(res.status, 302); + assert.equal(res.headers.get('location'), '/redirected'); + }); + + it('should serve static file when middleware calls next()', async () => { + const res = await fetch(`http://${server.host}:${server.port}/allowed`); + assert.equal(res.status, 200); + const html = await res.text(); + assert.ok(html.includes('

Static Page

')); + }); + + it('should set cookies from middleware', async () => { + const res = await fetch(`http://${server.host}:${server.port}/set-cookie-page`); + const setCookie = res.headers.get('set-cookie'); + assert.ok(setCookie); + assert.ok(setCookie.includes('middleware-cookie=value')); + }); + }); + + describe('Asset handling', () => { + it('should NOT run middleware for CSS files', async () => { + // Note: This test assumes CSS files exist in _astro folder after build + // For a proper test, we'd need to ensure some CSS is generated + const res = await fetch(`http://${server.host}:${server.port}/_astro/test.css`); + // Middleware should not add this header for assets (404 is ok, just no middleware header) + assert.equal(res.headers.get('x-middleware-ran'), null); + }); + + it('should NOT run middleware for JS files', async () => { + const res = await fetch(`http://${server.host}:${server.port}/_astro/test.js`); + assert.equal(res.headers.get('x-middleware-ran'), null); + }); + + it('should NOT run middleware for images', async () => { + const res = await fetch(`http://${server.host}:${server.port}/test.png`); + assert.equal(res.headers.get('x-middleware-ran'), null); + }); + }); + + describe('Error handling', () => { + it('should serve static file even if middleware doesn\'t affect it', async () => { + const res = await fetch(`http://${server.host}:${server.port}/error-page`); + // Should still serve the static file + assert.equal(res.status, 200); + const html = await res.text(); + assert.ok(html.includes('

Static Error Page

')); + }); + + it('should handle middleware errors gracefully', async () => { + // This test verifies that the server doesn't crash when middleware throws + // In practice, middleware errors should be caught and result in 500 responses + // For now, we'll just verify the server is still running after potential errors + const res = await fetch(`http://${server.host}:${server.port}/static-page`); + assert.equal(res.status, 200); + // If we got here, the server is still functional + assert.ok(true, 'Server handled request successfully'); + }); + }); + + describe('Locals support', () => { + it('should allow middleware to access locals (simulated via manual handler)', async () => { + // This test verifies that the handler signature supports locals + // In a real Express app, Express middleware would set locals + // Note: This is a simple check that the signature is correct + // Full integration testing would require Express setup + + const { createStaticHandler } = await import('../dist/serve-static.js'); + const { handler: appModule } = await fixture.loadAdapterEntryModule(); + const staticHandler = createStaticHandler(appModule.app, { + mode: 'standalone', + host: false, + port: server.port, + server: new URL('./fixtures/middleware-static/dist/server/', import.meta.url), + client: new URL('./fixtures/middleware-static/dist/client/', import.meta.url), + assets: '_astro', + experimentalStaticHeaders: false, + runMiddlewareOnRequest: true, + }); + + // Create mock request/response + let responseHeaders = {}; + + const mockReq = { + url: '/static-page', + method: 'GET', + headers: { + host: 'localhost:' + server.port, + }, + socket: { + encrypted: false, + }, + }; + + const mockRes = { + statusCode: 200, + writeHead(status, headers) { + this.statusCode = status; + if (headers) { + Object.assign(responseHeaders, headers); + } + }, + setHeader(name, value) { + responseHeaders[name] = value; + }, + getHeader(name) { + return responseHeaders[name]; + }, + end() { + // Response ended + }, + headersSent: false, + }; + + // Simulate Express middleware setting locals + const locals = { expressUser: 'testuser', expressSessionId: '12345' }; + + // Call the static handler with locals + await staticHandler(mockReq, mockRes, () => {}, locals); + + // Wait a bit for async operations + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Verify middleware ran and had access to locals + assert.ok(responseHeaders['x-middleware-ran']); + assert.equal(responseHeaders['x-express-user'], 'testuser'); + assert.equal(responseHeaders['x-express-session'], '12345'); + }); + }); + + describe('Origin checking (CSRF protection)', () => { + it('should allow GET requests regardless of origin (safe method)', async () => { + // GET is a safe method and should always be allowed + const res = await fetch(`http://${server.host}:${server.port}/static-page`, { + method: 'GET', + headers: { + // Simulating a different origin + origin: 'https://evil.com', + }, + }); + assert.equal(res.status, 200); + }); + + it('should allow HEAD requests regardless of origin (safe method)', async () => { + // HEAD is a safe method and should always be allowed + const res = await fetch(`http://${server.host}:${server.port}/static-page`, { + method: 'HEAD', + headers: { + origin: 'https://evil.com', + }, + }); + assert.equal(res.status, 200); + }); + + it('should allow OPTIONS requests regardless of origin (safe method)', async () => { + // OPTIONS is a safe method and should always be allowed + const res = await fetch(`http://${server.host}:${server.port}/static-page`, { + method: 'OPTIONS', + headers: { + origin: 'https://evil.com', + }, + }); + // Should not be blocked (though static pages might return 404 for OPTIONS) + assert.notEqual(res.status, 403); + }); + + it('should skip origin check for prerendered pages (marked as isPrerendered)', async () => { + // Origin checking middleware checks context.isPrerendered and skips for prerendered pages + // This test verifies that POST requests to static pages are NOT blocked + // because they are marked as prerendered + const res = await fetch(`http://${server.host}:${server.port}/static-page`, { + method: 'POST', + headers: { + 'content-type': 'application/x-www-form-urlencoded', + origin: 'https://evil.com', + }, + body: 'test=data', + }); + // Should NOT be 403 because prerendered pages skip origin checking + // (The actual status might be 404 or 405 since POST to static file isn't supported, + // but it shouldn't be 403 from CSRF protection) + assert.notEqual(res.status, 403, 'Prerendered pages should skip origin checking'); + }); + }); +}); + diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 364b0cd54860..afc629da1092 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -387,6 +387,15 @@ importers: specifier: ^0.34.3 version: 0.34.4 + examples/static-with-middleware: + dependencies: + '@astrojs/node': + specifier: ^9.4.6 + version: link:../../packages/integrations/node + astro: + specifier: ^5.14.4 + version: link:../../packages/astro + examples/toolbar-app: devDependencies: '@types/node': @@ -5697,6 +5706,15 @@ importers: specifier: workspace:* version: link:../../../../../astro + packages/integrations/node/test/fixtures/middleware-static: + dependencies: + '@astrojs/node': + specifier: workspace:* + version: link:../../.. + astro: + specifier: workspace:* + version: link:../../../../../astro + packages/integrations/node/test/fixtures/node-middleware: dependencies: '@astrojs/node':