diff --git a/.changeset/tame-pants-raise.md b/.changeset/tame-pants-raise.md new file mode 100644 index 000000000000..a184bdc134d5 --- /dev/null +++ b/.changeset/tame-pants-raise.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/adapter-netlify': patch +--- + +Adds support for Netlify Edge Functions diff --git a/packages/adapter-netlify/README.md b/packages/adapter-netlify/README.md index 056408674e8e..79e884b151fb 100644 --- a/packages/adapter-netlify/README.md +++ b/packages/adapter-netlify/README.md @@ -17,9 +17,15 @@ import adapter from '@sveltejs/adapter-netlify'; export default { kit: { + // default options are shown adapter: adapter({ + // if true, will create a Netlify Edge Function rather + // than using standard Node-based functions + edge: false, + // if true, will split your app into multiple functions - // instead of creating a single one for the entire app + // instead of creating a single one for the entire app. + // if `edge` is true, this option cannot be used split: false }) } @@ -36,6 +42,10 @@ Then, make sure you have a [netlify.toml](https://docs.netlify.com/configure-bui If the `netlify.toml` file or the `build.publish` value is missing, a default value of `"build"` will be used. Note that if you have set the publish directory in the Netlify UI to something else then you will need to set it in `netlify.toml` too, or use the default value of `"build"`. +## Netlify Edge Functions (beta) + +SvelteKit supports the beta release of Netlify Edge Functions. If you pass the option `edge: true` to the `adapter` function, server-side rendering will happen in a Deno-based edge function that's deployed close to the site visitor. If set to `false` (the default), the site will deploy to standard Node-based Netlify Functions. + ## Netlify alternatives to SvelteKit functionality You may build your app using functionality provided directly by SvelteKit without relying on any Netlify functionality. Using the SvelteKit versions of these features will allow them to be used in dev mode, tested with integration tests, and to work with other adapters should you ever decide to switch away from Netlify. However, in some scenarios you may find it beneficial to use the Netlify versions of these features. One example would be if you're migrating an app that's already hosted on Netlify to SvelteKit. diff --git a/packages/adapter-netlify/ambient.d.ts b/packages/adapter-netlify/ambient.d.ts index 2501e38bf35a..450140da9871 100644 --- a/packages/adapter-netlify/ambient.d.ts +++ b/packages/adapter-netlify/ambient.d.ts @@ -1,3 +1,10 @@ declare module '0SERVER' { export { Server } from '@sveltejs/kit'; } + +declare module 'MANIFEST' { + import { SSRManifest } from '@sveltejs/kit'; + + export const manifest: SSRManifest; + export const prerendered: Set; +} diff --git a/packages/adapter-netlify/index.d.ts b/packages/adapter-netlify/index.d.ts index 241175bc0667..df05aec1d7b1 100644 --- a/packages/adapter-netlify/index.d.ts +++ b/packages/adapter-netlify/index.d.ts @@ -1,4 +1,5 @@ import { Adapter } from '@sveltejs/kit'; -declare function plugin(opts?: { split?: boolean }): Adapter; +declare function plugin(opts?: { split?: boolean; edge?: boolean }): Adapter; + export = plugin; diff --git a/packages/adapter-netlify/index.js b/packages/adapter-netlify/index.js index 9d9229f9277a..7d72a12acc5b 100644 --- a/packages/adapter-netlify/index.js +++ b/packages/adapter-netlify/index.js @@ -1,5 +1,5 @@ import { appendFileSync, existsSync, readFileSync, writeFileSync } from 'fs'; -import { join, resolve } from 'path'; +import { join, resolve, posix } from 'path'; import { fileURLToPath } from 'url'; import glob from 'tiny-glob/sync.js'; import esbuild from 'esbuild'; @@ -12,10 +12,30 @@ import toml from '@iarna/toml'; * } & toml.JsonMap} NetlifyConfig */ +/** + * @typedef {{ + * functions: Array< + * | { + * function: string; + * path: string; + * } + * | { + * function: string; + * pattern: string; + * } + * >; + * version: 1; + * }} HandlerManifest + */ + const files = fileURLToPath(new URL('./files', import.meta.url).href); +const src = fileURLToPath(new URL('./src', import.meta.url).href); +const edgeSetInEnvVar = + process.env.NETLIFY_SVELTEKIT_USE_EDGE === 'true' || + process.env.NETLIFY_SVELTEKIT_USE_EDGE === '1'; /** @type {import('.')} */ -export default function ({ split = false } = {}) { +export default function ({ split = false, edge = edgeSetInEnvVar } = {}) { return { name: '@sveltejs/adapter-netlify', @@ -26,99 +46,26 @@ export default function ({ split = false } = {}) { const publish = get_publish_directory(netlify_config, builder) || 'build'; // empty out existing build directories - builder.rimraf(publish); + builder.rimraf('.netlify/edge-functions'); builder.rimraf('.netlify/functions-internal'); builder.rimraf('.netlify/server'); builder.rimraf('.netlify/package.json'); builder.rimraf('.netlify/handler.js'); - builder.mkdirp('.netlify/functions-internal'); - builder.log.minor(`Publishing to "${publish}"`); - builder.writeServer('.netlify/server'); - // for esbuild, use ESM // for zip-it-and-ship-it, use CJS until https://github.com/netlify/zip-it-and-ship-it/issues/750 const esm = netlify_config?.functions?.node_bundler === 'esbuild'; - /** @type {string[]} */ - const redirects = []; - - const replace = { - '0SERVER': './server/index.js' // digit prefix prevents CJS build from using this as a variable name, which would also get replaced - }; - - if (esm) { - builder.copy(`${files}/esm`, '.netlify', { replace }); + if (edge) { + if (split) { + throw new Error('Cannot use `split: true` alongside `edge: true`'); + } + + await generate_edge_functions({ builder }); } else { - glob('**/*.js', { cwd: '.netlify/server' }).forEach((file) => { - const filepath = `.netlify/server/${file}`; - const input = readFileSync(filepath, 'utf8'); - const output = esbuild.transformSync(input, { format: 'cjs', target: 'node12' }).code; - writeFileSync(filepath, output); - }); - - builder.copy(`${files}/cjs`, '.netlify', { replace }); - writeFileSync(join('.netlify', 'package.json'), JSON.stringify({ type: 'commonjs' })); - } - - if (split) { - builder.log.minor('Generating serverless functions...'); - - builder.createEntries((route) => { - const parts = []; - - // Netlify's syntax uses '*' and ':param' as "splats" and "placeholders" - // https://docs.netlify.com/routing/redirects/redirect-options/#splats - for (const segment of route.segments) { - if (segment.rest) { - parts.push('*'); - break; // Netlify redirects don't allow anything after a * - } else if (segment.dynamic) { - parts.push(`:${parts.length}`); - } else { - parts.push(segment.content); - } - } - - const pattern = `/${parts.join('/')}`; - const name = parts.join('-').replace(/[:.]/g, '_').replace('*', '__rest') || 'index'; - - return { - id: pattern, - filter: (other) => matches(route.segments, other.segments), - complete: (entry) => { - const manifest = entry.generateManifest({ - relativePath: '../server', - format: esm ? 'esm' : 'cjs' - }); - - const fn = esm - ? `import { init } from '../handler.js';\n\nexport const handler = init(${manifest});\n` - : `const { init } = require('../handler.js');\n\nexports.handler = init(${manifest});\n`; - - writeFileSync(`.netlify/functions-internal/${name}.js`, fn); - - redirects.push(`${pattern} /.netlify/functions/${name} 200`); - } - }; - }); - } else { - builder.log.minor('Generating serverless functions...'); - - const manifest = builder.generateManifest({ - relativePath: '../server', - format: esm ? 'esm' : 'cjs' - }); - - const fn = esm - ? `import { init } from '../handler.js';\n\nexport const handler = init(${manifest});\n` - : `const { init } = require('../handler.js');\n\nexports.handler = init(${manifest});\n`; - - writeFileSync('.netlify/functions-internal/render.js', fn); - - redirects.push('* /.netlify/functions/render 200'); + await generate_lambda_functions({ builder, esm, split, publish }); } builder.log.minor('Copying assets...'); @@ -126,11 +73,6 @@ export default function ({ split = false } = {}) { builder.writeClient(publish); builder.writePrerendered(publish); - builder.log.minor('Writing redirects...'); - const redirect_file = join(publish, '_redirects'); - builder.copy('_redirects', redirect_file); - appendFileSync(redirect_file, `\n\n${redirects.join('\n')}`); - builder.log.minor('Writing custom headers...'); const headers_file = join(publish, '_headers'); builder.copy('_headers', headers_file); @@ -141,6 +83,159 @@ export default function ({ split = false } = {}) { } }; } +/** + * @param { object } params + * @param {import('@sveltejs/kit').Builder} params.builder + */ +async function generate_edge_functions({ builder }) { + // Don't match the static directory + const pattern = '^/.*$'; + + // Go doesn't support lookarounds, so we can't do this + // const pattern = appDir ? `^/(?!${escapeStringRegexp(appDir)}).*$` : '^/.*$'; + + /** @type {HandlerManifest} */ + const edge_manifest = { + functions: [ + { + function: 'render', + pattern + } + ], + version: 1 + }; + const tmp = builder.getBuildDirectory('netlify-tmp'); + + builder.rimraf(tmp); + + builder.mkdirp('.netlify/edge-functions'); + + builder.log.minor('Generating Edge Function...'); + const relativePath = posix.relative(tmp, builder.getServerDirectory()); + + builder.copy(`${src}/edge_function.js`, `${tmp}/entry.js`, { + replace: { + '0SERVER': `${relativePath}/index.js`, + MANIFEST: './manifest.js' + } + }); + + const manifest = builder.generateManifest({ + relativePath + }); + + writeFileSync( + `${tmp}/manifest.js`, + `export const manifest = ${manifest};\n\nexport const prerendered = new Set(${JSON.stringify( + builder.prerendered.paths + )});\n` + ); + + await esbuild.build({ + entryPoints: [`${tmp}/entry.js`], + outfile: '.netlify/edge-functions/render.js', + bundle: true, + format: 'esm', + target: 'es2020', + platform: 'browser' + }); + + writeFileSync('.netlify/edge-functions/manifest.json', JSON.stringify(edge_manifest)); +} +/** + * @param { object } params + * @param {import('@sveltejs/kit').Builder} params.builder + * @param { string } params.publish + * @param { boolean } params.split + * @param { boolean } params.esm + */ +function generate_lambda_functions({ builder, publish, split, esm }) { + builder.mkdirp('.netlify/functions-internal'); + + /** @type {string[]} */ + const redirects = []; + builder.writeServer('.netlify/server'); + + const replace = { + '0SERVER': './server/index.js' // digit prefix prevents CJS build from using this as a variable name, which would also get replaced + }; + if (esm) { + builder.copy(`${files}/esm`, '.netlify', { replace }); + } else { + glob('**/*.js', { cwd: '.netlify/server' }).forEach((file) => { + const filepath = `.netlify/server/${file}`; + const input = readFileSync(filepath, 'utf8'); + const output = esbuild.transformSync(input, { format: 'cjs', target: 'node12' }).code; + writeFileSync(filepath, output); + }); + + builder.copy(`${files}/cjs`, '.netlify', { replace }); + writeFileSync(join('.netlify', 'package.json'), JSON.stringify({ type: 'commonjs' })); + } + + if (split) { + builder.log.minor('Generating serverless functions...'); + + builder.createEntries((route) => { + const parts = []; + // Netlify's syntax uses '*' and ':param' as "splats" and "placeholders" + // https://docs.netlify.com/routing/redirects/redirect-options/#splats + for (const segment of route.segments) { + if (segment.rest) { + parts.push('*'); + break; // Netlify redirects don't allow anything after a * + } else if (segment.dynamic) { + parts.push(`:${parts.length}`); + } else { + parts.push(segment.content); + } + } + + const pattern = `/${parts.join('/')}`; + const name = parts.join('-').replace(/[:.]/g, '_').replace('*', '__rest') || 'index'; + + return { + id: pattern, + filter: (other) => matches(route.segments, other.segments), + complete: (entry) => { + const manifest = entry.generateManifest({ + relativePath: '../server', + format: esm ? 'esm' : 'cjs' + }); + + const fn = esm + ? `import { init } from '../handler.js';\n\nexport const handler = init(${manifest});\n` + : `const { init } = require('../handler.js');\n\nexports.handler = init(${manifest});\n`; + + writeFileSync(`.netlify/functions-internal/${name}.js`, fn); + + redirects.push(`${pattern} /.netlify/functions/${name} 200`); + } + }; + }); + } else { + builder.log.minor('Generating serverless functions...'); + + const manifest = builder.generateManifest({ + relativePath: '../server', + format: esm ? 'esm' : 'cjs' + }); + + const fn = esm + ? `import { init } from '../handler.js';\n\nexport const handler = init(${manifest});\n` + : `const { init } = require('../handler.js');\n\nexports.handler = init(${manifest});\n`; + + writeFileSync('.netlify/functions-internal/render.js', fn); + redirects.push('* /.netlify/functions/render 200'); + } + + builder.log.minor('Writing redirects...'); + const redirect_file = join(publish, '_redirects'); + if (existsSync('_redirects')) { + builder.copy('_redirects', redirect_file); + } + appendFileSync(redirect_file, `\n\n${redirects.join('\n')}`); +} function get_netlify_config() { if (!existsSync('netlify.toml')) return null; @@ -159,8 +254,8 @@ function get_netlify_config() { **/ function get_publish_directory(netlify_config, builder) { if (netlify_config) { - if (!netlify_config.build || !netlify_config.build.publish) { - builder.log.warn('No publish directory specified in netlify.toml, using default'); + if (!netlify_config.build?.publish) { + builder.log.minor('No publish directory specified in netlify.toml, using default'); return; } diff --git a/packages/adapter-netlify/src/edge_function.js b/packages/adapter-netlify/src/edge_function.js new file mode 100644 index 000000000000..6a8e78fee8c7 --- /dev/null +++ b/packages/adapter-netlify/src/edge_function.js @@ -0,0 +1,57 @@ +import { Server } from '0SERVER'; +import { manifest, prerendered } from 'MANIFEST'; + +const server = new Server(manifest); +const prefix = `/${manifest.appDir}/`; + +/** + * @param { Request } request + * @param { any } context + * @returns { Promise } + */ +export default async function handler(request, context) { + if (is_static_file(request)) { + // Static files can skip the handler + return; + } + try { + const response = await server.respond(request, { + platform: { context }, + getClientAddress() { + return request.headers.get('x-nf-client-connection-ip'); + } + }); + return response; + } catch (error) { + return new Response('Error rendering route:' + (error.message || error.toString()), { + status: 500 + }); + } +} + +/** + * @param {Request} request + */ +function is_static_file(request) { + const url = new URL(request.url); + + // Assets in the app dir + if (url.pathname.startsWith(prefix)) { + return true; + } + // prerendered pages and index.html files + const pathname = url.pathname.replace(/\/$/, ''); + let file = pathname.substring(1); + + try { + file = decodeURIComponent(file); + } catch (err) { + // ignore + } + + return ( + manifest.assets.has(file) || + manifest.assets.has(file + '/index.html') || + prerendered.has(pathname || '/') + ); +}