From 467cdb9713e7d6a3355a35ffb5ee17bcd58a9c30 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Wed, 20 Apr 2022 15:20:03 -0400 Subject: [PATCH] Update `adapter-vercel` to use new build output API (#4663) * start working on v3 filesystem API * fix * support v1 and v3 * implement route splitting * WIP edge support * DYAC * lint * fix config and .vc-config.json * remove catch-all route * fixes * use overrides for prerendered pages * update README * changesets --- .changeset/giant-ants-hang.md | 5 + .changeset/smooth-dodos-clap.md | 5 + packages/adapter-vercel/README.md | 25 +- packages/adapter-vercel/files/edge.js | 15 + .../files/{entry.js => serverless.js} | 4 +- packages/adapter-vercel/files/shims.js | 2 - packages/adapter-vercel/index.d.ts | 2 + packages/adapter-vercel/index.js | 381 ++++++++++++++---- packages/kit/src/core/adapt/builder.js | 5 +- packages/kit/types/index.d.ts | 2 +- packages/kit/types/private.d.ts | 3 +- 11 files changed, 347 insertions(+), 102 deletions(-) create mode 100644 .changeset/giant-ants-hang.md create mode 100644 .changeset/smooth-dodos-clap.md create mode 100644 packages/adapter-vercel/files/edge.js rename packages/adapter-vercel/files/{entry.js => serverless.js} (89%) delete mode 100644 packages/adapter-vercel/files/shims.js diff --git a/.changeset/giant-ants-hang.md b/.changeset/giant-ants-hang.md new file mode 100644 index 000000000000..2ed64e81bfcf --- /dev/null +++ b/.changeset/giant-ants-hang.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/adapter-vercel': patch +--- + +Support build output API, with edge functions and code-splitting diff --git a/.changeset/smooth-dodos-clap.md b/.changeset/smooth-dodos-clap.md new file mode 100644 index 000000000000..d45d135122b5 --- /dev/null +++ b/.changeset/smooth-dodos-clap.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/kit': patch +--- + +builder.createEntries returns a promise that awaits complete() callbacks diff --git a/packages/adapter-vercel/README.md b/packages/adapter-vercel/README.md index 9717b67f17e7..3eb2a525ef7f 100644 --- a/packages/adapter-vercel/README.md +++ b/packages/adapter-vercel/README.md @@ -6,6 +6,8 @@ If you're using [adapter-auto](../adapter-auto), you don't need to install this ## Usage +> The `edge` and `split` options depend on the Vercel Build Output API which is currently in beta. For now, you must opt in by visiting `https://vercel.com/svelte/[YOUR_PROJECT]/settings/environment-variables` and adding `ENABLE_VC_BUILD` with the value `1`. + Add `"@sveltejs/adapter-vercel": "next"` to the `devDependencies` in your `package.json` and run `npm install`. Then in your `svelte.config.js`: @@ -15,18 +17,25 @@ import vercel from '@sveltejs/adapter-vercel'; export default { kit: { - ... - adapter: vercel(options) + // default options are shown + adapter: vercel({ + // if true, will deploy the app using edge functions + // (https://vercel.com/docs/concepts/functions/edge-functions) + // rather than serverless functions + edge: false, + + // an array of dependencies that esbuild should treat + // as external when bundling functions + external: [], + + // if true, will split your app into multiple functions + // instead of creating a single one for the entire app + split: false + }) } }; ``` -## Options - -You can pass an `options` argument, if necessary, with the following: - -- `external` — an array of dependencies that [esbuild](https://esbuild.github.io/api/#external) should treat as external - ## Changelog [The Changelog for this package is available on GitHub](https://github.com/sveltejs/kit/blob/master/packages/adapter-vercel/CHANGELOG.md). diff --git a/packages/adapter-vercel/files/edge.js b/packages/adapter-vercel/files/edge.js new file mode 100644 index 000000000000..cc8cb64370b1 --- /dev/null +++ b/packages/adapter-vercel/files/edge.js @@ -0,0 +1,15 @@ +import { Server } from 'SERVER'; +import { manifest } from 'MANIFEST'; + +const server = new Server(manifest); + +/** + * @param {Request} request + */ +export default (request) => { + return server.respond(request, { + getClientAddress() { + return request.headers.get('x-forwarded-for'); + } + }); +}; diff --git a/packages/adapter-vercel/files/entry.js b/packages/adapter-vercel/files/serverless.js similarity index 89% rename from packages/adapter-vercel/files/entry.js rename to packages/adapter-vercel/files/serverless.js index dcbd3ebcb6bb..9e7285f26e9a 100644 --- a/packages/adapter-vercel/files/entry.js +++ b/packages/adapter-vercel/files/serverless.js @@ -1,8 +1,10 @@ -import './shims'; +import { installFetch } from '@sveltejs/kit/install-fetch'; import { getRequest, setResponse } from '@sveltejs/kit/node'; import { Server } from 'SERVER'; import { manifest } from 'MANIFEST'; +installFetch(); + const server = new Server(manifest); /** diff --git a/packages/adapter-vercel/files/shims.js b/packages/adapter-vercel/files/shims.js deleted file mode 100644 index 8d2fe00acd85..000000000000 --- a/packages/adapter-vercel/files/shims.js +++ /dev/null @@ -1,2 +0,0 @@ -import { installFetch } from '@sveltejs/kit/install-fetch'; -installFetch(); diff --git a/packages/adapter-vercel/index.d.ts b/packages/adapter-vercel/index.d.ts index 25e220d37ec8..1566ac142b82 100644 --- a/packages/adapter-vercel/index.d.ts +++ b/packages/adapter-vercel/index.d.ts @@ -1,7 +1,9 @@ import { Adapter } from '@sveltejs/kit'; type Options = { + edge?: boolean; external?: string[]; + split?: boolean; }; declare function plugin(options?: Options): Adapter; diff --git a/packages/adapter-vercel/index.js b/packages/adapter-vercel/index.js index 7ed513d8eb9e..781586e79f6a 100644 --- a/packages/adapter-vercel/index.js +++ b/packages/adapter-vercel/index.js @@ -1,10 +1,8 @@ -import { writeFileSync } from 'fs'; -import { posix } from 'path'; +import { mkdirSync, writeFileSync } from 'fs'; +import { dirname, posix } from 'path'; import { fileURLToPath } from 'url'; import esbuild from 'esbuild'; -const dir = '.vercel_build_output'; - // rules for clean URLs and trailing slash handling, // generated with @vercel/routing-utils const redirects = { @@ -83,99 +81,308 @@ const redirects = { }; /** @type {import('.')} **/ -export default function ({ external = [] } = {}) { +export default function ({ external = [], edge, split } = {}) { return { name: '@sveltejs/adapter-vercel', async adapt(builder) { - const tmp = builder.getBuildDirectory('vercel-tmp'); + if (process.env.ENABLE_VC_BUILD) { + await v3(builder, external, edge, split); + } else { + if (edge || split) { + throw new Error('edge and split options can only be used with ENABLE_VC_BUILD'); + } - builder.rimraf(dir); - builder.rimraf(tmp); + await v1(builder, external); + } + } + }; +} - const files = fileURLToPath(new URL('./files', import.meta.url).href); +/** + * @param {import('@sveltejs/kit').Builder} builder + * @param {string[]} external + */ +async function v1(builder, external) { + const dir = '.vercel_build_output'; - const dirs = { - static: `${dir}/static`, - lambda: `${dir}/functions/node/render` - }; + const tmp = builder.getBuildDirectory('vercel-tmp'); - builder.log.minor('Generating serverless function...'); + builder.rimraf(dir); + builder.rimraf(tmp); - const relativePath = posix.relative(tmp, builder.getServerDirectory()); + const files = fileURLToPath(new URL('./files', import.meta.url).href); - builder.copy(files, tmp, { - replace: { - SERVER: `${relativePath}/index.js`, - MANIFEST: './manifest.js' - } - }); - - writeFileSync( - `${tmp}/manifest.js`, - `export const manifest = ${builder.generateManifest({ - relativePath - })};\n` - ); - - await esbuild.build({ - entryPoints: [`${tmp}/entry.js`], - outfile: `${dirs.lambda}/index.js`, - target: 'node14', - bundle: true, - platform: 'node', - external - }); - - writeFileSync(`${dirs.lambda}/package.json`, JSON.stringify({ type: 'commonjs' })); - - builder.log.minor('Copying assets...'); - - builder.writeStatic(dirs.static); - builder.writeClient(dirs.static); - builder.writePrerendered(dirs.static); - - builder.log.minor('Writing routes...'); - - builder.mkdirp(`${dir}/config`); - - const prerendered_pages = Array.from(builder.prerendered.pages, ([src, page]) => ({ - src, - dest: page.file - })); - - const prerendered_redirects = Array.from( - builder.prerendered.redirects, - ([src, redirect]) => ({ - src, - headers: { - Location: redirect.location - }, - status: redirect.status - }) - ); - - writeFileSync( - `${dir}/config/routes.json`, - JSON.stringify([ - ...redirects[builder.config.kit.trailingSlash], - ...prerendered_pages, - ...prerendered_redirects, - { - src: `/${builder.config.kit.appDir}/.+`, - headers: { - 'cache-control': 'public, immutable, max-age=31536000' - } - }, - { - handle: 'filesystem' - }, - { - src: '/.*', - dest: '.vercel/functions/render' - } - ]) - ); + const dirs = { + static: `${dir}/static`, + lambda: `${dir}/functions/node/render` + }; + + builder.log.minor('Generating serverless function...'); + + const relativePath = posix.relative(tmp, builder.getServerDirectory()); + + builder.copy(files, tmp, { + replace: { + SERVER: `${relativePath}/index.js`, + MANIFEST: './manifest.js' } + }); + + writeFileSync( + `${tmp}/manifest.js`, + `export const manifest = ${builder.generateManifest({ + relativePath + })};\n` + ); + + await esbuild.build({ + entryPoints: [`${tmp}/entry.js`], + outfile: `${dirs.lambda}/index.js`, + target: 'node14', + bundle: true, + platform: 'node', + external + }); + + writeFileSync(`${dirs.lambda}/package.json`, JSON.stringify({ type: 'commonjs' })); + + builder.log.minor('Copying assets...'); + + builder.writeStatic(dirs.static); + builder.writeClient(dirs.static); + builder.writePrerendered(dirs.static); + + builder.log.minor('Writing routes...'); + + builder.mkdirp(`${dir}/config`); + + const prerendered_pages = Array.from(builder.prerendered.pages, ([src, page]) => ({ + src, + dest: page.file + })); + + const prerendered_redirects = Array.from(builder.prerendered.redirects, ([src, redirect]) => ({ + src, + headers: { + Location: redirect.location + }, + status: redirect.status + })); + + writeFileSync( + `${dir}/config/routes.json`, + JSON.stringify([ + ...redirects[builder.config.kit.trailingSlash], + ...prerendered_pages, + ...prerendered_redirects, + { + src: `/${builder.config.kit.appDir}/.+`, + headers: { + 'cache-control': 'public, immutable, max-age=31536000' + } + }, + { + handle: 'filesystem' + }, + { + src: '/.*', + dest: '.vercel/functions/render' + } + ]) + ); +} + +/** + * @param {import('@sveltejs/kit').Builder} builder + * @param {string[]} external + * @param {boolean} edge + * @param {boolean} split + */ +async function v3(builder, external, edge, split) { + const dir = '.vercel/output'; + + const tmp = builder.getBuildDirectory('vercel-tmp'); + + builder.rimraf(dir); + builder.rimraf(tmp); + + const files = fileURLToPath(new URL('./files', import.meta.url).href); + + const dirs = { + static: `${dir}/static`, + functions: `${dir}/functions` }; + + const prerendered_redirects = Array.from(builder.prerendered.redirects, ([src, redirect]) => ({ + src, + headers: { + Location: redirect.location + }, + status: redirect.status + })); + + /** @type {any[]} */ + const routes = [ + ...redirects[builder.config.kit.trailingSlash], + ...prerendered_redirects, + { + src: `/${builder.config.kit.appDir}/.+`, + headers: { + 'cache-control': 'public, immutable, max-age=31536000' + } + }, + { + handle: 'filesystem' + } + ]; + + builder.log.minor('Generating serverless function...'); + + /** + * @param {string} name + * @param {string} pattern + * @param {(options: { relativePath: string }) => string} generate_manifest + */ + async function generate_serverless_function(name, pattern, generate_manifest) { + const tmp = builder.getBuildDirectory(`vercel-tmp/${name}`); + const relativePath = posix.relative(tmp, builder.getServerDirectory()); + + builder.copy(`${files}/serverless.js`, `${tmp}/serverless.js`, { + replace: { + SERVER: `${relativePath}/index.js`, + MANIFEST: './manifest.js' + } + }); + + write( + `${tmp}/manifest.js`, + `export const manifest = ${generate_manifest({ relativePath })};\n` + ); + + await esbuild.build({ + entryPoints: [`${tmp}/serverless.js`], + outfile: `${dirs.functions}/${name}.func/index.js`, + target: 'node14', + bundle: true, + platform: 'node', + format: 'cjs', + external + }); + + write( + `${dirs.functions}/${name}.func/.vc-config.json`, + JSON.stringify({ + runtime: 'nodejs14.x', + handler: 'index.js', + launcherType: 'Nodejs' + }) + ); + + write(`${dirs.functions}/${name}.func/package.json`, JSON.stringify({ type: 'commonjs' })); + + routes.push({ src: pattern, dest: `/${name}` }); + } + + /** + * @param {string} name + * @param {string} pattern + * @param {(options: { relativePath: string }) => string} generate_manifest + */ + async function generate_edge_function(name, pattern, generate_manifest) { + const tmp = builder.getBuildDirectory(`vercel-tmp/${name}`); + const relativePath = posix.relative(tmp, builder.getServerDirectory()); + + builder.copy(`${files}/edge.js`, `${tmp}/edge.js`, { + replace: { + SERVER: `${relativePath}/index.js`, + MANIFEST: './manifest.js' + } + }); + + write( + `${tmp}/manifest.js`, + `export const manifest = ${generate_manifest({ relativePath })};\n` + ); + + await esbuild.build({ + entryPoints: [`${tmp}/edge.js`], + outfile: `${dirs.functions}/${name}.func/index.js`, + target: 'node14', + bundle: true, + platform: 'node', + format: 'esm', + external + }); + + write( + `${dirs.functions}/${name}.func/.vc-config.json`, + JSON.stringify({ + runtime: 'edge', + entrypoint: 'index.js' + // TODO expose envVarsInUse + }) + ); + + routes.push({ src: pattern, middlewarePath: name }); + } + + const generate_function = edge ? generate_edge_function : generate_serverless_function; + + if (split) { + await builder.createEntries((route) => { + return { + id: route.pattern.toString(), // TODO is `id` necessary? + filter: (other) => route.pattern.toString() === other.pattern.toString(), + complete: async (entry) => { + const src = `${route.pattern + .toString() + .slice(1, -2) // remove leading / and trailing $/ + .replace(/\\\//g, '/')}(?:/__data.json)?$`; // TODO adding /__data.json is a temporary workaround — those endpoints should be treated as distinct routes + + await generate_function(route.id, src, entry.generateManifest); + } + }; + }); + } else { + await generate_function('render', '/.*', builder.generateManifest); + } + + builder.log.minor('Copying assets...'); + + builder.writeStatic(dirs.static); + builder.writeClient(dirs.static); + builder.writePrerendered(dirs.static); + + builder.log.minor('Writing routes...'); + + /** @type {Record} */ + const overrides = {}; + builder.prerendered.pages.forEach((page, src) => { + overrides[page.file] = { path: src.slice(1) }; + }); + + write( + `${dir}/config.json`, + JSON.stringify({ + version: 3, + target: 'production', + routes, + overrides + }) + ); +} + +/** + * @param {string} file + * @param {string} data + */ +function write(file, data) { + try { + mkdirSync(dirname(file), { recursive: true }); + } catch { + // do nothing + } + + writeFileSync(file, data); } diff --git a/packages/kit/src/core/adapt/builder.js b/packages/kit/src/core/adapt/builder.js index 7c589dff643a..057d06dd80f2 100644 --- a/packages/kit/src/core/adapt/builder.js +++ b/packages/kit/src/core/adapt/builder.js @@ -33,11 +33,12 @@ export function create_builder({ config, build_data, prerendered, log }) { config, prerendered, - createEntries(fn) { + async createEntries(fn) { const { routes } = build_data.manifest_data; /** @type {import('types').RouteDefinition[]} */ const facades = routes.map((route) => ({ + id: route.id, type: route.type, segments: route.id.split('/').map((segment) => ({ dynamic: segment.includes('['), @@ -81,7 +82,7 @@ export function create_builder({ config, build_data, prerendered, log }) { }); if (filtered.size > 0) { - complete({ + await complete({ generateManifest: ({ relativePath, format }) => generate_manifest({ build_data, diff --git a/packages/kit/types/index.d.ts b/packages/kit/types/index.d.ts index 478cb3c1118a..6c956bab9029 100644 --- a/packages/kit/types/index.d.ts +++ b/packages/kit/types/index.d.ts @@ -38,7 +38,7 @@ export interface Builder { * Create entry points that map to individual functions * @param fn A function that groups a set of routes into an entry point */ - createEntries(fn: (route: RouteDefinition) => AdapterEntry): void; + createEntries(fn: (route: RouteDefinition) => AdapterEntry): Promise; generateManifest: (opts: { relativePath: string; format?: 'esm' | 'cjs' }) => string; diff --git a/packages/kit/types/private.d.ts b/packages/kit/types/private.d.ts index 6232aeb404d2..762a082e9854 100644 --- a/packages/kit/types/private.d.ts +++ b/packages/kit/types/private.d.ts @@ -23,7 +23,7 @@ export interface AdapterEntry { */ complete: (entry: { generateManifest: (opts: { relativePath: string; format?: 'esm' | 'cjs' }) => string; - }) => void; + }) => Promise; } // Based on https://github.com/josh-hemphill/csp-typed-directives/blob/latest/src/csp.types.ts @@ -222,6 +222,7 @@ export interface ResolveOptions { export type ResponseHeaders = Record; export interface RouteDefinition { + id: string; type: 'page' | 'endpoint'; pattern: RegExp; segments: RouteSegment[];