UNPKG

@sveltejs/adapter-netlify

Version:

A SvelteKit adapter that creates a Netlify app

456 lines (390 loc) 13 kB
/** @import { BuildOptions } from 'esbuild' */ import { appendFileSync, existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'; import { join, resolve, posix } from 'node:path'; import { fileURLToPath } from 'node:url'; import { builtinModules } from 'node:module'; import process from 'node:process'; import esbuild from 'esbuild'; import toml from '@iarna/toml'; /** * @typedef {{ * build?: { publish?: string } * functions?: { node_bundler?: 'zisi' | 'esbuild' } * } & toml.JsonMap} NetlifyConfig */ const name = '@sveltejs/adapter-netlify'; const files = fileURLToPath(new URL('./files', import.meta.url).href); const edge_set_in_env_var = process.env.NETLIFY_SVELTEKIT_USE_EDGE === 'true' || process.env.NETLIFY_SVELTEKIT_USE_EDGE === '1'; const FUNCTION_PREFIX = 'sveltekit-'; /** @type {import('./index.js').default} */ export default function ({ split = false, edge = edge_set_in_env_var } = {}) { return { name, /** @param {import('@sveltejs/kit').Builder} builder */ async adapt(builder) { if (!builder.routes) { throw new Error( '@sveltejs/adapter-netlify >=2.x (possibly installed through @sveltejs/adapter-auto) requires @sveltejs/kit version 1.5 or higher. ' + 'Either downgrade the adapter or upgrade @sveltejs/kit' ); } if (existsSync(`${builder.config.kit.files.assets}/_headers`)) { throw new Error( `The _headers file should be placed in the project root rather than the ${builder.config.kit.files.assets} directory` ); } if (existsSync(`${builder.config.kit.files.assets}/_redirects`)) { throw new Error( `The _redirects file should be placed in the project root rather than the ${builder.config.kit.files.assets} directory` ); } const netlify_config = get_netlify_config(); // "build" is the default publish directory when Netlify detects SvelteKit 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/server'); builder.rimraf('.netlify/package.json'); builder.rimraf('.netlify/serverless.js'); if (existsSync('.netlify/functions-internal')) { for (const file of readdirSync('.netlify/functions-internal')) { if (file.startsWith(FUNCTION_PREFIX)) { builder.rimraf(join('.netlify/functions-internal', file)); } } } builder.log.minor(`Publishing to "${publish}"`); builder.log.minor('Copying assets...'); const publish_dir = `${publish}${builder.config.kit.paths.base}`; builder.writeClient(publish_dir); builder.writePrerendered(publish_dir); builder.log.minor('Writing custom headers...'); const headers_file = join(publish, '_headers'); builder.copy('_headers', headers_file); appendFileSync( headers_file, `\n\n/${builder.getAppPath()}/immutable/*\n cache-control: public\n cache-control: immutable\n cache-control: max-age=31536000\n` ); if (edge) { if (split) { throw new Error('Cannot use `split: true` alongside `edge: true`'); } await generate_edge_functions({ builder }); } else { generate_lambda_functions({ builder, split, publish }); } }, supports: { read: () => true, instrumentation: () => true } }; } /** * @param { object } params * @param {import('@sveltejs/kit').Builder} params.builder */ async function generate_edge_functions({ builder }) { const tmp = builder.getBuildDirectory('netlify-tmp'); builder.rimraf(tmp); builder.mkdirp(tmp); builder.mkdirp('.netlify/edge-functions'); builder.log.minor('Generating Edge Function...'); const relativePath = posix.relative(tmp, builder.getServerDirectory()); builder.copy(`${files}/edge.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`); /** @type {{ assets: Set<string> }} */ // we have to prepend the file:// protocol because Windows doesn't support absolute path imports const { assets } = (await import(`file://${tmp}/manifest.js`)).manifest; const path = '/*'; // We only need to specify paths without the trailing slash because // Netlify will handle the optional trailing slash for us const excluded = [ // Contains static files `/${builder.getAppPath()}/immutable/*`, `/${builder.getAppPath()}/version.json`, ...builder.prerendered.paths, ...Array.from(assets).flatMap((asset) => { if (asset.endsWith('/index.html')) { const dir = asset.replace(/\/index\.html$/, ''); return [ `${builder.config.kit.paths.base}/${asset}`, `${builder.config.kit.paths.base}/${dir}` ]; } return `${builder.config.kit.paths.base}/${asset}`; }), // Should not be served by SvelteKit at all '/.netlify/*' ]; /** @type {import('@netlify/edge-functions').Manifest} */ const edge_manifest = { functions: [ { function: 'render', path, excludedPath: /** @type {`/${string}`[]} */ (excluded) } ], version: 1 }; /** @type {BuildOptions} */ const esbuild_config = { bundle: true, format: 'esm', platform: 'browser', sourcemap: 'linked', target: 'es2020', loader: { '.wasm': 'copy', '.woff': 'copy', '.woff2': 'copy', '.ttf': 'copy', '.eot': 'copy', '.otf': 'copy' }, // Node built-ins are allowed, but must be prefixed with `node:` // https://docs.netlify.com/edge-functions/api/#runtime-environment external: builtinModules.map((id) => `node:${id}`), alias: Object.fromEntries(builtinModules.map((id) => [id, `node:${id}`])) }; await Promise.all([ esbuild.build({ entryPoints: [`${tmp}/entry.js`], outfile: '.netlify/edge-functions/render.js', ...esbuild_config }), builder.hasServerInstrumentationFile() && esbuild.build({ entryPoints: [`${builder.getServerDirectory()}/instrumentation.server.js`], outfile: '.netlify/edge/instrumentation.server.js', ...esbuild_config }) ]); if (builder.hasServerInstrumentationFile()) { builder.instrument({ entrypoint: '.netlify/edge-functions/render.js', instrumentation: '.netlify/edge/instrumentation.server.js', start: '.netlify/edge/start.js' }); } 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 */ function generate_lambda_functions({ builder, publish, split }) { builder.mkdirp('.netlify/functions-internal/.svelte-kit'); 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 }; builder.copy(files, '.netlify', { replace, filter: (name) => !name.endsWith('edge.js') }); builder.log.minor('Generating serverless functions...'); if (split) { const seen = new Set(); for (let i = 0; i < builder.routes.length; i++) { const route = builder.routes[i]; if (route.prerender === true) continue; const routes = [route]; /** @type {string[]} */ const parts = []; // The parts should conform to URLPattern syntax // https://docs.netlify.com/build/functions/get-started/?fn-language=ts&data-tab=TypeScript#route-requests for (const segment of route.segments) { if (segment.rest) { parts.push('*'); } else if (segment.dynamic) { // URLPattern requires params to start with letters parts.push(`:param${parts.length}`); } else { parts.push(segment.content); } } // Netlify handles trailing slashes for us, so we don't need to include them in the pattern const pattern = `/${parts.join('/')}`; const name = FUNCTION_PREFIX + (parts.join('-').replace(/[:.]/g, '_').replace('*', '__rest') || 'index'); // skip routes with identical patterns, they were already folded into another function if (seen.has(pattern)) continue; const patterns = [pattern, `${pattern === '/' ? '' : pattern}/__data.json`]; patterns.forEach((p) => seen.add(p)); // figure out which lower priority routes should be considered fallbacks for (let j = i + 1; j < builder.routes.length; j += 1) { const other = builder.routes[j]; if (other.prerender === true) continue; if (matches(route.segments, other.segments)) { routes.push(other); } } generate_serverless_function({ builder, routes, patterns, name }); } generate_serverless_function({ builder, routes: [], patterns: ['/*'], name: `${FUNCTION_PREFIX}catch-all`, exclude: Array.from(seen) }); } else { generate_serverless_function({ builder, routes: undefined, patterns: ['/*'], name: `${FUNCTION_PREFIX}render` }); } // Copy user's custom _redirects file if it exists if (existsSync('_redirects')) { builder.log.minor('Copying user redirects...'); const redirects_file = join(publish, '_redirects'); builder.copy('_redirects', redirects_file); } } function get_netlify_config() { if (!existsSync('netlify.toml')) return null; try { return /** @type {NetlifyConfig} */ (toml.parse(readFileSync('netlify.toml', 'utf-8'))); } catch (err) { err.message = `Error parsing netlify.toml: ${err.message}`; throw err; } } /** * @param {NetlifyConfig | null} netlify_config * @param {import('@sveltejs/kit').Builder} builder **/ function get_publish_directory(netlify_config, builder) { if (netlify_config) { if (!netlify_config.build?.publish) { builder.log.minor('No publish directory specified in netlify.toml, using default'); return; } if (resolve(netlify_config.build.publish) === process.cwd()) { throw new Error( 'The publish directory cannot be set to the site root. Please change it to another value such as "build" in netlify.toml.' ); } return netlify_config.build.publish; } builder.log.warn( 'No netlify.toml found. Using default publish directory. Consult https://svelte.dev/docs/kit/adapter-netlify#usage for more details' ); } /** * @typedef {{ rest: boolean, dynamic: boolean, content: string }} RouteSegment */ /** * @param {RouteSegment[]} a * @param {RouteSegment[]} b * @returns {boolean} */ function matches(a, b) { if (a[0] && b[0]) { if (b[0].rest) { if (b.length === 1) return true; const next_b = b.slice(1); for (let i = 0; i < a.length; i += 1) { if (matches(a.slice(i), next_b)) return true; } return false; } if (!b[0].dynamic) { if (!a[0].dynamic && a[0].content !== b[0].content) return false; } if (a.length === 1 && b.length === 1) return true; return matches(a.slice(1), b.slice(1)); } else if (a[0]) { return a.length === 1 && a[0].rest; } else { return b.length === 1 && b[0].rest; } } /** * * @param {{ * builder: import('@sveltejs/kit').Builder, * routes: import('@sveltejs/kit').RouteDefinition[] | undefined, * patterns: string[], * name: string, * exclude?: string[] * }} opts */ function generate_serverless_function({ builder, routes, patterns, name, exclude }) { const manifest = builder.generateManifest({ relativePath: '../server', routes }); const fn = generate_serverless_function_module(manifest); const config = generate_config_export(patterns, exclude); if (builder.hasServerInstrumentationFile()) { writeFileSync(`.netlify/functions-internal/${name}.mjs`, fn); builder.instrument({ entrypoint: `.netlify/functions-internal/${name}.mjs`, instrumentation: '.netlify/server/instrumentation.server.js', start: `.netlify/functions-start/${name}.start.mjs`, module: { generateText: generate_traced_module(config) } }); } else { writeFileSync(`.netlify/functions-internal/${name}.mjs`, `${fn}\n${config}`); } } /** * @param {string} manifest * @returns {string} */ function generate_serverless_function_module(manifest) { return `\ import { init } from '../serverless.js'; export default init(${manifest}); `; } /** * @param {string[]} patterns * @param {string[]} [exclude] * @returns {string} */ function generate_config_export(patterns, exclude = []) { // TODO: add a human friendly name for the function https://docs.netlify.com/build/frameworks/frameworks-api/#configuration-options-2 return `\ export const config = { path: [${patterns.map((s) => JSON.stringify(s)).join(', ')}], excludedPath: [${['/.netlify/*', ...exclude].map((s) => JSON.stringify(s)).join(', ')}], preferStatic: true }; `; } /** * @param {string} config * @returns {(opts: { instrumentation: string; start: string }) => string} */ function generate_traced_module(config) { return ({ instrumentation, start }) => { return `\ import './${instrumentation}'; const { default: _0 } = await import('./${start}'); export { _0 as default }; ${config}`; }; }