Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(vercel): ISR #9714

Merged
merged 21 commits into from
Feb 7, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions .changeset/flat-snakes-hammer.md
Original file line number Diff line number Diff line change
@@ -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" ]
}
})
})
```

4 changes: 4 additions & 0 deletions packages/integrations/vercel/src/lib/redirects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [];

Expand Down
250 changes: 147 additions & 103 deletions packages/integrations/vercel/src/serverless/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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 {
ematipico marked this conversation as resolved.
Show resolved Hide resolved
/**
* A secret random string that you create.
ematipico marked this conversation as resolved.
Show resolved Hide resolved
* 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;
natemoo-re marked this conversation as resolved.
Show resolved Hide resolved

/**
* 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({
Expand All @@ -136,6 +172,7 @@ export default function vercelServerless({
functionPerRoute = false,
edgeMiddleware = false,
maxDuration,
isr = false,
}: VercelServerlessConfig = {}): AstroIntegration {
if (maxDuration) {
if (typeof maxDuration !== 'number') {
Expand All @@ -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: {
Expand Down Expand Up @@ -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();
})
natemoo-re marked this conversation as resolved.
Show resolved Hide resolved
}
},
'astro:build:ssr': async ({ entryPoints, middlewareEntryPoint }) => {
_entryPoints = entryPoints;
_middlewareEntryPoint = middlewareEntryPoint;
Expand Down Expand Up @@ -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) {
Expand All @@ -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
Expand Down Expand Up @@ -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/<name>.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/<name>.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 {
Expand Down
Loading
Loading