Skip to content

Commit

Permalink
feat: add support for Netlify Edge Functions (#4657)
Browse files Browse the repository at this point in the history
* feat: add support for Netlify Edge Functions

* docs: update readme

* chore: add changeset

* fix: use early return for static files

* Apply suggestions from code review

Co-authored-by: Kyle Rollins <[email protected]>

* fix: wrap exists check around _redirects copy

* document edge in example config

* Apply suggestions from code review

Co-authored-by: Rich Harris <[email protected]>

* Update packages/adapter-netlify/README.md

Co-authored-by: Matt Kane <[email protected]>

Co-authored-by: Kyle Rollins <[email protected]>
Co-authored-by: Rich Harris <[email protected]>
  • Loading branch information
3 people authored Apr 20, 2022
1 parent d723e87 commit 41db4a3
Show file tree
Hide file tree
Showing 6 changed files with 267 additions and 92 deletions.
5 changes: 5 additions & 0 deletions .changeset/tame-pants-raise.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@sveltejs/adapter-netlify': patch
---

Adds support for Netlify Edge Functions
12 changes: 11 additions & 1 deletion packages/adapter-netlify/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
})
}
Expand All @@ -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.
Expand Down
7 changes: 7 additions & 0 deletions packages/adapter-netlify/ambient.d.ts
Original file line number Diff line number Diff line change
@@ -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<string>;
}
3 changes: 2 additions & 1 deletion packages/adapter-netlify/index.d.ts
Original file line number Diff line number Diff line change
@@ -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;
275 changes: 185 additions & 90 deletions packages/adapter-netlify/index.js
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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',

Expand All @@ -26,111 +46,33 @@ 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...');
builder.writeStatic(publish);
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);
Expand All @@ -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;
Expand All @@ -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;
}

Expand Down
Loading

0 comments on commit 41db4a3

Please sign in to comment.