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 14 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
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 @@ -122,6 +128,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({
Expand All @@ -135,6 +171,7 @@ export default function vercelServerless({
functionPerRoute = false,
edgeMiddleware = false,
maxDuration,
isr = false,
}: VercelServerlessConfig = {}): AstroIntegration {
if (maxDuration) {
if (typeof maxDuration !== 'number') {
Expand All @@ -153,8 +190,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 @@ -224,6 +259,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;
Expand Down Expand Up @@ -256,7 +305,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 @@ -272,45 +321,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 : {};
if (isrConfig.exclude?.length) {
await builder.buildServerlessFolder(entryFile, NODE_PATH);
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 @@ -365,80 +411,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
9 changes: 7 additions & 2 deletions packages/integrations/vercel/src/serverless/entrypoint.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Expand All @@ -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() {}
13 changes: 13 additions & 0 deletions packages/integrations/vercel/test/fixtures/isr/astro.config.mjs
Original file line number Diff line number Diff line change
@@ -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"]
}
})
});
9 changes: 9 additions & 0 deletions packages/integrations/vercel/test/fixtures/isr/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"name": "@test/vercel-isr",
"version": "0.0.0",
"private": true,
"dependencies": {
"@astrojs/vercel": "workspace:*",
"astro": "workspace:*"
}
}
Loading
Loading