diff --git a/.changeset/flat-snakes-hammer.md b/.changeset/flat-snakes-hammer.md new file mode 100644 index 000000000000..787d2899dfa9 --- /dev/null +++ b/.changeset/flat-snakes-hammer.md @@ -0,0 +1,54 @@ +--- +"@astrojs/vercel": minor +--- + +Introduces a new config option, `isr`, that allows you to deploy your project as an ISR function. [ISR (Incremental Static Regeneration)](https://vercel.com/docs/incremental-static-regeneration) caches your on-demand rendered pages in the same way as prerendered pages after first request. + +To enable this feature, set `isr` to true in your Vercel adapter configuration in `astro.config.mjs`: + +```js +export default defineConfig({ + output: "server", + adapter: vercel({ isr: true }) +}) +``` + + +## Cache invalidation options + +By default, ISR responses are cached for the duration of your deployment. You can further control caching by setting an `expiration` time or prevent caching entirely for certain routes. + +### Time-based invalidation + +You can change the length of time to cache routes this by configuring an `expiration` value in seconds: + +```js +export default defineConfig({ + output: "server", + adapter: vercel({ + isr: { + // caches all pages on first request and saves for 1 day + expiration: 60 * 60 * 24 + } + }) +}) +``` + +### Manual invalidation + +To implement Vercel's [Draft mode](https://vercel.com/docs/build-output-api/v3/features#draft-mode), or [On-Demand Incremental Static Regeneration (ISR)](https://vercel.com/docs/build-output-api/v3/features#on-demand-incremental-static-regeneration-isr), you can create a bypass token and provide it to the `isr` config along with the paths to exclude from caching: + +```js +export default defineConfig({ + output: "server", + adapter: vercel({ + isr: { + // A secret random string that you create. + bypassToken: "005556d774a8", + // Paths that will always be served fresh. + exclude: [ "/api/invalidate" ] + } + }) +}) +``` + diff --git a/packages/integrations/vercel/src/lib/redirects.ts b/packages/integrations/vercel/src/lib/redirects.ts index 46c97d349691..6e03f8a37dfc 100644 --- a/packages/integrations/vercel/src/lib/redirects.ts +++ b/packages/integrations/vercel/src/lib/redirects.ts @@ -76,6 +76,10 @@ function getRedirectStatus(route: RouteData): number { return 301; } +export function escapeRegex(content: string) { + return `^${getMatchPattern([[{ content, dynamic: false, spread: false }]])}$` +} + export function getRedirects(routes: RouteData[], config: AstroConfig): VercelRoute[] { let redirects: VercelRoute[] = []; diff --git a/packages/integrations/vercel/src/serverless/adapter.ts b/packages/integrations/vercel/src/serverless/adapter.ts index 11efdc16f62d..e588ac736129 100644 --- a/packages/integrations/vercel/src/serverless/adapter.ts +++ b/packages/integrations/vercel/src/serverless/adapter.ts @@ -17,7 +17,7 @@ import { } from '../image/shared.js'; import { removeDir, writeJson } from '../lib/fs.js'; import { copyDependenciesToFunction } from '../lib/nft.js'; -import { getRedirects } from '../lib/redirects.js'; +import { escapeRegex, getRedirects } from '../lib/redirects.js'; import { getSpeedInsightsViteConfig, type VercelSpeedInsightsConfig, @@ -35,6 +35,7 @@ const PACKAGE_NAME = '@astrojs/vercel/serverless'; * with the original path as the value of this header. */ export const ASTRO_PATH_HEADER = 'x-astro-path'; +export const ASTRO_PATH_PARAM = 'x_astro_path'; /** * The edge function calls the node server at /_render, @@ -48,6 +49,11 @@ export const VERCEL_EDGE_MIDDLEWARE_FILE = 'vercel-edge-middleware'; export const NODE_PATH = '_render'; const MIDDLEWARE_PATH = '_middleware'; +// This isn't documented by vercel anywhere, but unlike serverless +// and edge functions, isr functions are not passed the original path. +// Instead, we have to use $0 to refer to the regex match from "src". +const ISR_PATH = `/_isr?${ASTRO_PATH_PARAM}=$0`; + // https://vercel.com/docs/concepts/functions/serverless-functions/runtimes/node-js#node.js-version const SUPPORTED_NODE_VERSIONS: Record< string, @@ -123,6 +129,36 @@ export interface VercelServerlessConfig { /** The maximum duration (in seconds) that Serverless Functions can run before timing out. See the [Vercel documentation](https://vercel.com/docs/functions/serverless-functions/runtimes#maxduration) for the default and maximum limit for your account plan. */ maxDuration?: number; + + /** Whether to cache on-demand rendered pages in the same way as static files. */ + isr?: boolean | VercelISRConfig; +} + +interface VercelISRConfig { + /** + * A secret random string that you create. + * Its presence in the `__prerender_bypass` cookie will result in fresh responses being served, bypassing the cache. See Vercel’s documentation on [Draft Mode](https://vercel.com/docs/build-output-api/v3/features#draft-mode) for more information. + * Its presence in the `x-prerender-revalidate` header will result in a fresh response which will then be cached for all future requests to be used. See Vercel’s documentation on [On-Demand Incremental Static Regeneration (ISR)](https://vercel.com/docs/build-output-api/v3/features#on-demand-incremental-static-regeneration-isr) for more information. + * + * @default `undefined` + */ + bypassToken?: string; + + /** + * Expiration time (in seconds) before the pages will be re-generated. + * + * Setting to `false` means that the page will stay cached as long as the current deployment is in production. + * + * @default `false` + */ + expiration?: number | false; + + /** + * Paths that will always be served by a serverless function instead of an ISR function. + * + * @default `[]` + */ + exclude?: string[]; } export default function vercelServerless({ @@ -136,6 +172,7 @@ export default function vercelServerless({ functionPerRoute = false, edgeMiddleware = false, maxDuration, + isr = false, }: VercelServerlessConfig = {}): AstroIntegration { if (maxDuration) { if (typeof maxDuration !== 'number') { @@ -154,8 +191,6 @@ export default function vercelServerless({ // Extra files to be merged with `includeFiles` during build const extraFilesToInclude: URL[] = []; - const NTF_CACHE = Object.create(null); - return { name: PACKAGE_NAME, hooks: { @@ -225,6 +260,20 @@ export default function vercelServerless({ ); } }, + 'astro:server:setup' ({ server }) { + // isr functions do not have access to search params, this middleware removes them for the dev mode + if (isr) { + const exclude_ = typeof isr === "object" ? isr.exclude ?? [] : []; + // we create a regex to emulate vercel's production behavior + const exclude = exclude_.concat("/_image").map(ex => new RegExp(escapeRegex(ex))); + server.middlewares.use(function removeIsrParams(req, _, next) { + const { pathname } = new URL(`https://example.com${req.url}`); + if (exclude.some(ex => ex.test(pathname))) return next(); + req.url = pathname; + return next(); + }) + } + }, 'astro:build:ssr': async ({ entryPoints, middlewareEntryPoint }) => { _entryPoints = entryPoints; _middlewareEntryPoint = middlewareEntryPoint; @@ -257,7 +306,7 @@ export default function vercelServerless({ .concat(extraFilesToInclude); const excludeFiles = _excludeFiles.map((file) => new URL(file, _config.root)); - const runtime = getRuntime(process, logger); + const builder = new VercelBuilder(_config, excludeFiles, includeFiles, logger, maxDuration); // Multiple entrypoint support if (_entryPoints.size) { @@ -273,45 +322,42 @@ export default function vercelServerless({ ? getRouteFuncName(route) : getFallbackFuncName(entryFile); - await createFunctionFolder({ - functionName: func, - runtime, - entry: entryFile, - config: _config, - logger, - NTF_CACHE, - includeFiles, - excludeFiles, - maxDuration, - }); + await builder.buildServerlessFolder(entryFile, func); + routeDefinitions.push({ src: route.pattern.source, dest: func, }); } } else { - await createFunctionFolder({ - functionName: NODE_PATH, - runtime, - entry: new URL(_serverEntry, _buildTempFolder), - config: _config, - logger, - NTF_CACHE, - includeFiles, - excludeFiles, - maxDuration, - }); - const dest = _middlewareEntryPoint ? MIDDLEWARE_PATH : NODE_PATH; - for (const route of routes) { - if (!route.prerender) routeDefinitions.push({ src: route.pattern.source, dest }); + const entryFile = new URL(_serverEntry, _buildTempFolder) + if (isr) { + const isrConfig = typeof isr === "object" ? isr : {}; + await builder.buildServerlessFolder(entryFile, NODE_PATH); + if (isrConfig.exclude?.length) { + const dest = _middlewareEntryPoint ? MIDDLEWARE_PATH : NODE_PATH; + for (const route of isrConfig.exclude) { + // vercel interprets src as a regex pattern, so we need to escape it + routeDefinitions.push({ src: escapeRegex(route), dest }) + } + } + await builder.buildISRFolder(entryFile, '_isr', isrConfig); + for (const route of routes) { + const src = route.pattern.source; + const dest = src.startsWith("^\\/_image") ? NODE_PATH : ISR_PATH; + if (!route.prerender) routeDefinitions.push({ src, dest }); + } + } + else { + await builder.buildServerlessFolder(entryFile, NODE_PATH); + const dest = _middlewareEntryPoint ? MIDDLEWARE_PATH : NODE_PATH; + for (const route of routes) { + if (!route.prerender) routeDefinitions.push({ src: route.pattern.source, dest }); + } } } if (_middlewareEntryPoint) { - await createMiddlewareFolder({ - functionName: MIDDLEWARE_PATH, - entry: _middlewareEntryPoint, - config: _config, - }); + await builder.buildMiddlewareFolder(_middlewareEntryPoint, MIDDLEWARE_PATH); } const fourOhFourRoute = routes.find((route) => route.pathname === '/404'); // Output configuration @@ -366,80 +412,78 @@ export default function vercelServerless({ type Runtime = `nodejs${string}.x`; -interface CreateMiddlewareFolderArgs { - config: AstroConfig; - entry: URL; - functionName: string; -} - -async function createMiddlewareFolder({ functionName, entry, config }: CreateMiddlewareFolderArgs) { - const functionFolder = new URL(`./functions/${functionName}.func/`, config.outDir); +class VercelBuilder { + readonly NTF_CACHE = {} + + constructor( + readonly config: AstroConfig, + readonly excludeFiles: URL[], + readonly includeFiles: URL[], + readonly logger: AstroIntegrationLogger, + readonly maxDuration?: number, + readonly runtime = getRuntime(process, logger) + ) {} + + async buildServerlessFolder(entry: URL, functionName: string) { + const { config, includeFiles, excludeFiles, logger, NTF_CACHE, runtime, maxDuration } = this; + // .vercel/output/functions/.func/ + const functionFolder = new URL(`./functions/${functionName}.func/`, config.outDir); + const packageJson = new URL(`./functions/${functionName}.func/package.json`, config.outDir); + const vcConfig = new URL(`./functions/${functionName}.func/.vc-config.json`, config.outDir); + + // Copy necessary files (e.g. node_modules/) + const { handler } = await copyDependenciesToFunction( + { + entry, + outDir: functionFolder, + includeFiles, + excludeFiles, + logger, + }, + NTF_CACHE + ); - await generateEdgeMiddleware( - entry, - new URL(VERCEL_EDGE_MIDDLEWARE_FILE, config.srcDir), - new URL('./middleware.mjs', functionFolder) - ); + // Enable ESM + // https://aws.amazon.com/blogs/compute/using-node-js-es-modules-and-top-level-await-in-aws-lambda/ + await writeJson(packageJson, { type: 'module' }); + + // Serverless function config + // https://vercel.com/docs/build-output-api/v3#vercel-primitives/serverless-functions/configuration + await writeJson(vcConfig, { + runtime, + handler: handler.replaceAll('\\', '/'), + launcherType: 'Nodejs', + maxDuration, + supportsResponseStreaming: true, + }); + } - await writeJson(new URL(`./.vc-config.json`, functionFolder), { - runtime: 'edge', - entrypoint: 'middleware.mjs', - }); -} + async buildISRFolder(entry: URL, functionName: string, isr: VercelISRConfig) { + await this.buildServerlessFolder(entry, functionName); + const prerenderConfig = new URL(`./functions/${functionName}.prerender-config.json`, this.config.outDir) + // https://vercel.com/docs/build-output-api/v3/primitives#prerender-configuration-file + await writeJson(prerenderConfig, { + expiration: isr.expiration ?? false, + bypassToken: isr.bypassToken, + allowQuery: [ASTRO_PATH_PARAM], + passQuery: true + }); + } -interface CreateFunctionFolderArgs { - functionName: string; - runtime: Runtime; - entry: URL; - config: AstroConfig; - logger: AstroIntegrationLogger; - NTF_CACHE: any; - includeFiles: URL[]; - excludeFiles: URL[]; - maxDuration: number | undefined; -} + async buildMiddlewareFolder(entry: URL, functionName: string) { + const functionFolder = new URL(`./functions/${functionName}.func/`, this.config.outDir); -async function createFunctionFolder({ - functionName, - runtime, - entry, - config, - logger, - NTF_CACHE, - includeFiles, - excludeFiles, - maxDuration, -}: CreateFunctionFolderArgs) { - // .vercel/output/functions/.func/ - const functionFolder = new URL(`./functions/${functionName}.func/`, config.outDir); - const packageJson = new URL(`./functions/${functionName}.func/package.json`, config.outDir); - const vcConfig = new URL(`./functions/${functionName}.func/.vc-config.json`, config.outDir); - - // Copy necessary files (e.g. node_modules/) - const { handler } = await copyDependenciesToFunction( - { + await generateEdgeMiddleware( entry, - outDir: functionFolder, - includeFiles, - excludeFiles, - logger, - }, - NTF_CACHE - ); - - // Enable ESM - // https://aws.amazon.com/blogs/compute/using-node-js-es-modules-and-top-level-await-in-aws-lambda/ - await writeJson(packageJson, { type: 'module' }); - - // Serverless function config - // https://vercel.com/docs/build-output-api/v3#vercel-primitives/serverless-functions/configuration - await writeJson(vcConfig, { - runtime, - handler: handler.replaceAll('\\', '/'), - launcherType: 'Nodejs', - maxDuration, - supportsResponseStreaming: true, - }); + new URL(VERCEL_EDGE_MIDDLEWARE_FILE, this.config.srcDir), + new URL('./middleware.mjs', functionFolder) + ); + + await writeJson(new URL(`./.vc-config.json`, functionFolder), { + runtime: 'edge', + entrypoint: 'middleware.mjs', + }); + } } function getRuntime(process: NodeJS.Process, logger: AstroIntegrationLogger): Runtime { diff --git a/packages/integrations/vercel/src/serverless/entrypoint.ts b/packages/integrations/vercel/src/serverless/entrypoint.ts index fe88c367d320..31777ed6ce70 100644 --- a/packages/integrations/vercel/src/serverless/entrypoint.ts +++ b/packages/integrations/vercel/src/serverless/entrypoint.ts @@ -1,16 +1,17 @@ import type { SSRManifest } from 'astro'; import { applyPolyfills, NodeApp } from 'astro/app/node'; import type { IncomingMessage, ServerResponse } from 'node:http'; -import { ASTRO_PATH_HEADER, ASTRO_LOCALS_HEADER } from './adapter.js'; +import { ASTRO_PATH_HEADER, ASTRO_PATH_PARAM, ASTRO_LOCALS_HEADER } from './adapter.js'; applyPolyfills(); export const createExports = (manifest: SSRManifest) => { const app = new NodeApp(manifest); const handler = async (req: IncomingMessage, res: ServerResponse) => { + const url = new URL(`https://example.com${req.url}`) const clientAddress = req.headers['x-forwarded-for'] as string | undefined; const localsHeader = req.headers[ASTRO_LOCALS_HEADER]; - const realPath = req.headers[ASTRO_PATH_HEADER]; + const realPath = req.headers[ASTRO_PATH_HEADER] ?? url.searchParams.get(ASTRO_PATH_PARAM); if (typeof realPath === 'string') { req.url = realPath; } @@ -26,3 +27,7 @@ export const createExports = (manifest: SSRManifest) => { return { default: handler }; }; + +// HACK: prevent warning +// @astrojs-ssr-virtual-entry (22:23) "start" is not exported by "dist/serverless/entrypoint.js", imported by "@astrojs-ssr-virtual-entry". +export function start() {} diff --git a/packages/integrations/vercel/test/fixtures/isr/astro.config.mjs b/packages/integrations/vercel/test/fixtures/isr/astro.config.mjs new file mode 100644 index 000000000000..e7545c6f2a34 --- /dev/null +++ b/packages/integrations/vercel/test/fixtures/isr/astro.config.mjs @@ -0,0 +1,13 @@ +import { defineConfig } from 'astro/config'; +import vercel from '@astrojs/vercel/serverless'; + +export default defineConfig({ + output: "server", + adapter: vercel({ + isr: { + bypassToken: "1c9e601d-9943-4e7c-9575-005556d774a8", + expiration: 120, + exclude: ["/two"] + } + }) +}); diff --git a/packages/integrations/vercel/test/fixtures/isr/package.json b/packages/integrations/vercel/test/fixtures/isr/package.json new file mode 100644 index 000000000000..a1fd601d3b1a --- /dev/null +++ b/packages/integrations/vercel/test/fixtures/isr/package.json @@ -0,0 +1,9 @@ +{ + "name": "@test/vercel-isr", + "version": "0.0.0", + "private": true, + "dependencies": { + "@astrojs/vercel": "workspace:*", + "astro": "workspace:*" + } +} diff --git a/packages/integrations/vercel/test/fixtures/isr/src/pages/one.astro b/packages/integrations/vercel/test/fixtures/isr/src/pages/one.astro new file mode 100644 index 000000000000..0c7fb90a735e --- /dev/null +++ b/packages/integrations/vercel/test/fixtures/isr/src/pages/one.astro @@ -0,0 +1,8 @@ + + + One + + +

One

+ + diff --git a/packages/integrations/vercel/test/fixtures/isr/src/pages/two.astro b/packages/integrations/vercel/test/fixtures/isr/src/pages/two.astro new file mode 100644 index 000000000000..e7ba9910e2a6 --- /dev/null +++ b/packages/integrations/vercel/test/fixtures/isr/src/pages/two.astro @@ -0,0 +1,8 @@ + + + Two + + +

Two

+ + diff --git a/packages/integrations/vercel/test/isr.test.js b/packages/integrations/vercel/test/isr.test.js new file mode 100644 index 000000000000..d10181c8d3a0 --- /dev/null +++ b/packages/integrations/vercel/test/isr.test.js @@ -0,0 +1,51 @@ +import { loadFixture } from "./test-utils.js"; +import { expect } from "chai"; + +describe("ISR", () => { + /** @type {import('./test-utils.js').Fixture} */ + let fixture; + + before(async () => { + fixture = await loadFixture({ + root: "./fixtures/isr/", + }); + await fixture.build(); + }); + + it("generates expected prerender config", async () => { + const vcConfig = JSON.parse( + await fixture.readFile("../.vercel/output/functions/_isr.prerender-config.json") + ); + expect(vcConfig).to.deep.include({ + "expiration": 120, + "bypassToken": "1c9e601d-9943-4e7c-9575-005556d774a8", + "allowQuery": ["x_astro_path"], + "passQuery": true + }) + }) + + it("generates expected routes", async () => { + const deploymentConfig = JSON.parse( + await fixture.readFile("../.vercel/output/config.json") + ); + // the first two are /_astro/*, and filesystem routes + expect(deploymentConfig.routes.slice(2)).to.deep.equal([ + { + "src": "^/two$", + "dest": "_render" + }, + { + "src": "^\\/_image$", + "dest": "_render" + }, + { + "src": "^\\/one\\/?$", + "dest": "/_isr?x_astro_path=$0" + }, + { + "src": "^\\/two\\/?$", + "dest": "/_isr?x_astro_path=$0" + } + ]) + }) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 007f9239d368..7aa6e9840296 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4764,6 +4764,15 @@ importers: specifier: workspace:* version: link:../../../../../astro + packages/integrations/vercel/test/fixtures/isr: + dependencies: + '@astrojs/vercel': + specifier: workspace:* + version: link:../../.. + astro: + specifier: workspace:* + version: link:../../../../../astro + packages/integrations/vercel/test/fixtures/max-duration: dependencies: '@astrojs/vercel':