diff --git a/.changeset/fine-ghosts-camp.md b/.changeset/fine-ghosts-camp.md new file mode 100644 index 000000000000..6eacab83c095 --- /dev/null +++ b/.changeset/fine-ghosts-camp.md @@ -0,0 +1,7 @@ +--- +'@sveltejs/adapter-netlify': major +--- + +breaking: write output that conforms to the stable [Netlify Frameworks API](https://docs.netlify.com/build/frameworks/frameworks-api/). + +Deploying and previewing with Netlify CLI now requires [v17.31.0](https://github.com/netlify/cli/releases/tag/v17.31.0) or later. Run `npm i -g netlify-cli@latest` to upgrade. diff --git a/packages/adapter-netlify/index.js b/packages/adapter-netlify/index.js index 32413fc586d3..fa82803e6cd5 100644 --- a/packages/adapter-netlify/index.js +++ b/packages/adapter-netlify/index.js @@ -1,11 +1,15 @@ /** @import { BuildOptions } from 'esbuild' */ -import { appendFileSync, existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'; -import { join, resolve, posix } from 'node:path'; +import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { join, 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'; +import { matches, get_publish_directory } from './utils.js'; + +const pkg = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf-8')); +const adapter_version = pkg.version; /** * @typedef {{ @@ -55,11 +59,13 @@ export default function ({ split = false, edge = edge_set_in_env_var } = {}) { // empty out existing build directories builder.rimraf(publish); + builder.rimraf('.netlify/v1'); + + // clean up legacy directories from older adapter versions 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)) { @@ -75,13 +81,13 @@ export default function ({ split = false, edge = edge_set_in_env_var } = {}) { 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` - ); + // Copy user's custom _headers file if it exists + if (existsSync('_headers')) { + builder.copy('_headers', join(publish, '_headers')); + } + + builder.log.minor('Writing Netlify config...'); + write_frameworks_config({ builder }); if (edge) { if (split) { @@ -109,7 +115,8 @@ async function generate_edge_functions({ builder }) { builder.rimraf(tmp); builder.mkdirp(tmp); - builder.mkdirp('.netlify/edge-functions'); + // https://docs.netlify.com/build/frameworks/frameworks-api/#edge-functions + builder.mkdirp('.netlify/v1/edge-functions'); builder.log.minor('Generating Edge Function...'); const relativePath = posix.relative(tmp, builder.getServerDirectory()); @@ -134,7 +141,7 @@ async function generate_edge_functions({ builder }) { const path = '/*'; // We only need to specify paths without the trailing slash because // Netlify will handle the optional trailing slash for us - const excluded = [ + const excluded_paths = [ // Contains static files `/${builder.getAppPath()}/immutable/*`, `/${builder.getAppPath()}/version.json`, @@ -153,18 +160,6 @@ async function generate_edge_functions({ builder }) { '/.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, @@ -185,29 +180,34 @@ async function generate_edge_functions({ builder }) { 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', + outfile: `.netlify/v1/edge-functions/${FUNCTION_PREFIX}render.js`, ...esbuild_config }), builder.hasServerInstrumentationFile() && esbuild.build({ - entryPoints: [`${builder.getServerDirectory()}/instrumentation.server.js`], - outfile: '.netlify/edge/instrumentation.server.js', + entryPoints: [ + `${builder.getServerDirectory()}/${FUNCTION_PREFIX}instrumentation.server.js` + ], + outfile: `.netlify/v1/edge-functions/${FUNCTION_PREFIX}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' + entrypoint: `.netlify/v1/edge-functions/${FUNCTION_PREFIX}render.js`, + instrumentation: `.netlify/v1/edge-functions/${FUNCTION_PREFIX}instrumentation.server.js`, + start: `.netlify/v1/edge-functions/${FUNCTION_PREFIX}start.js` }); } - writeFileSync('.netlify/edge-functions/manifest.json', JSON.stringify(edge_manifest)); + // https://docs.netlify.com/build/frameworks/frameworks-api/#edge-functions + // Edge function config goes in config.json + add_edge_function_config({ builder, path, excluded_paths }); } /** * @param { object } params @@ -216,15 +216,16 @@ async function generate_edge_functions({ builder }) { * @param { boolean } params.split */ function generate_lambda_functions({ builder, publish, split }) { - builder.mkdirp('.netlify/functions-internal/.svelte-kit'); + // https://docs.netlify.com/build/frameworks/frameworks-api/#netlifyv1functions + builder.mkdirp('.netlify/v1/functions'); - builder.writeServer('.netlify/server'); + builder.writeServer('.netlify/v1/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.copy(files, '.netlify/v1', { replace, filter: (file) => !file.endsWith('edge.js') }); builder.log.minor('Generating serverless functions...'); @@ -279,17 +280,17 @@ function generate_lambda_functions({ builder, publish, split }) { const config = generate_config_export(pattern); if (builder.hasServerInstrumentationFile()) { - writeFileSync(`.netlify/functions-internal/${name}.mjs`, fn); + writeFileSync(`.netlify/v1/functions/${name}.mjs`, fn); builder.instrument({ - entrypoint: `.netlify/functions-internal/${name}.mjs`, - instrumentation: '.netlify/server/instrumentation.server.js', - start: `.netlify/functions-start/${name}.start.mjs`, + entrypoint: `.netlify/v1/functions/${name}.mjs`, + instrumentation: '.netlify/v1/server/instrumentation.server.js', + start: `.netlify/v1/functions/${name}.start.mjs`, module: { generateText: generate_traced_module(config) } }); } else { - writeFileSync(`.netlify/functions-internal/${name}.mjs`, `${fn}\n${config}`); + writeFileSync(`.netlify/v1/functions/${name}.mjs`, `${fn}\n${config}`); } } } else { @@ -301,17 +302,17 @@ function generate_lambda_functions({ builder, publish, split }) { const config = generate_config_export('/*'); if (builder.hasServerInstrumentationFile()) { - writeFileSync(`.netlify/functions-internal/${FUNCTION_PREFIX}render.mjs`, fn); + writeFileSync(`.netlify/v1/functions/${FUNCTION_PREFIX}render.mjs`, fn); builder.instrument({ - entrypoint: `.netlify/functions-internal/${FUNCTION_PREFIX}render.mjs`, - instrumentation: '.netlify/server/instrumentation.server.js', - start: `.netlify/functions-start/${FUNCTION_PREFIX}render.start.mjs`, + entrypoint: `.netlify/v1/functions/${FUNCTION_PREFIX}render.mjs`, + instrumentation: '.netlify/v1/server/instrumentation.server.js', + start: `.netlify/v1/functions/${FUNCTION_PREFIX}render.start.mjs`, module: { generateText: generate_traced_module(config) } }); } else { - writeFileSync(`.netlify/functions-internal/${FUNCTION_PREFIX}render.mjs`, `${fn}\n${config}`); + writeFileSync(`.netlify/v1/functions/${FUNCTION_PREFIX}render.mjs`, `${fn}\n${config}`); } } @@ -335,66 +336,61 @@ function get_netlify_config() { } /** - * @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; - } + * Writes the Netlify Frameworks API config file + * https://docs.netlify.com/build/frameworks/frameworks-api/ + * @param {{ builder: import('@sveltejs/kit').Builder }} params + */ +function write_frameworks_config({ builder }) { + // https://docs.netlify.com/build/frameworks/frameworks-api/#headers + /** @type {{ headers: Array<{ for: string, values: Record }> }} */ + const config = { + headers: [ + { + for: `/${builder.getAppPath()}/immutable/*`, + values: { + 'cache-control': 'public, immutable, max-age=31536000' + } + } + ] + }; - builder.log.warn( - 'No netlify.toml found. Using default publish directory. Consult https://svelte.dev/docs/kit/adapter-netlify#usage for more details' - ); + builder.mkdirp('.netlify/v1'); + writeFileSync('.netlify/v1/config.json', JSON.stringify(config, null, '\t')); } /** - * @typedef {{ rest: boolean, dynamic: boolean, content: string }} RouteSegment - */ - -/** - * @param {RouteSegment[]} a - * @param {RouteSegment[]} b - * @returns {boolean} + * Adds edge function configuration to the Frameworks API config file + * https://docs.netlify.com/build/frameworks/frameworks-api/#edge-functions + * @param {{ builder: import('@sveltejs/kit').Builder, path: string, excluded_paths: string[] }} params */ -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; +function add_edge_function_config({ path, excluded_paths }) { + const config_path = '.netlify/v1/config.json'; + const config = JSON.parse(readFileSync(config_path, 'utf-8')); + + // https://docs.netlify.com/build/frameworks/frameworks-api/#edge-functions + config.edge_functions = [ + { + function: `${FUNCTION_PREFIX}render`, + name: 'SvelteKit', + generator: get_generator_string(), + path, + excludedPath: excluded_paths } + ]; - if (!b[0].dynamic) { - if (!a[0].dynamic && a[0].content !== b[0].content) return false; - } + writeFileSync(config_path, JSON.stringify(config, null, '\t')); +} - 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; - } +/** + * Gets the generator string for Netlify function metadata + * @returns {string} + */ +function get_generator_string() { + return `@sveltejs/adapter-netlify@${adapter_version}`; } /** + * https://docs.netlify.com/functions/get-started/?fn-language=ts#response * @param {string} manifest * @returns {string} */ @@ -413,6 +409,8 @@ export default init(${manifest}); function generate_config_export(pattern) { return `\ export const config = { + name: "SvelteKit server", + generator: "${get_generator_string()}", path: "${pattern}", excludedPath: "/.netlify/*", preferStatic: true diff --git a/packages/adapter-netlify/test/apps/basic/netlify.toml b/packages/adapter-netlify/test/apps/basic/netlify.toml index 5bd2fc95156c..00917ab74a18 100644 --- a/packages/adapter-netlify/test/apps/basic/netlify.toml +++ b/packages/adapter-netlify/test/apps/basic/netlify.toml @@ -1,9 +1,8 @@ [build] publish = "build" -# TODO: remove this after we refactor to the Netlify frameworks API +# TODO: remove this once @netlify/dev works with the Netlify frameworks API # we are purposely misusing the user functions config to discover our framework -# build output because our adapter still outputs using an older API but the new -# Netlify dev server adheres to the new API +# build output because the Netlify dev plugin doesn't currently detect it [functions] -directory = ".netlify/functions-internal" +directory = ".netlify/v1/functions" diff --git a/packages/adapter-netlify/test/apps/edge/netlify.toml b/packages/adapter-netlify/test/apps/edge/netlify.toml index fba5510330d6..5ea18d6abd4e 100644 --- a/packages/adapter-netlify/test/apps/edge/netlify.toml +++ b/packages/adapter-netlify/test/apps/edge/netlify.toml @@ -1,14 +1,13 @@ [build] publish = "build" -# TODO: remove these once we overhaul the Netlify adapter to use the new edge declarations https://docs.netlify.com/build/edge-functions/declarations/#declare-edge-functions-inline +# TODO: remove this once @netlify/dev works with the Netlify frameworks API +# we are purposely misusing the edge functions config to discover our framework +# build output because the Netlify dev plugin doesn't currently detect it +edge_functions = ".netlify/v1/edge-functions" -# defaults to "netlify/edge-functions" (without the . prefix) -edge_functions = ".netlify/edge-functions" - -# the dev server doesn't read the manifest.json in edge-functions so we need -# to explicitly declare this here +# Netlify dev plugin doesn't detect .netlify/v1/config.json [[edge_functions]] +function = "sveltekit-render" path = "/*" -function = "render" excludedPath = ["/_app/immutable/*", "/_app/version.json", "/.netlify/*"] diff --git a/packages/adapter-netlify/test/apps/split/netlify.toml b/packages/adapter-netlify/test/apps/split/netlify.toml index 5bd2fc95156c..00917ab74a18 100644 --- a/packages/adapter-netlify/test/apps/split/netlify.toml +++ b/packages/adapter-netlify/test/apps/split/netlify.toml @@ -1,9 +1,8 @@ [build] publish = "build" -# TODO: remove this after we refactor to the Netlify frameworks API +# TODO: remove this once @netlify/dev works with the Netlify frameworks API # we are purposely misusing the user functions config to discover our framework -# build output because our adapter still outputs using an older API but the new -# Netlify dev server adheres to the new API +# build output because the Netlify dev plugin doesn't currently detect it [functions] -directory = ".netlify/functions-internal" +directory = ".netlify/v1/functions" diff --git a/packages/adapter-netlify/utils.js b/packages/adapter-netlify/utils.js new file mode 100644 index 000000000000..27d37aab9d2d --- /dev/null +++ b/packages/adapter-netlify/utils.js @@ -0,0 +1,70 @@ +import { resolve } from 'node:path'; +import process from 'node:process'; + +/** + * @typedef {{ rest: boolean, dynamic: boolean, content: string }} RouteSegment + */ + +/** + * @typedef {{ + * build?: { publish?: string } + * functions?: { node_bundler?: 'zisi' | 'esbuild' } + * }} NetlifyConfig + */ + +/** + * @param {RouteSegment[]} a + * @param {RouteSegment[]} b + * @returns {boolean} + */ +export 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 {NetlifyConfig | null} netlify_config + * @param {import('@sveltejs/kit').Builder} builder + * @returns {string | undefined} + */ +export 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' + ); +} diff --git a/packages/adapter-netlify/utils.spec.js b/packages/adapter-netlify/utils.spec.js new file mode 100644 index 000000000000..60da6beda5cd --- /dev/null +++ b/packages/adapter-netlify/utils.spec.js @@ -0,0 +1,161 @@ +import process from 'node:process'; +import { describe, test, expect, vi } from 'vitest'; +import { matches, get_publish_directory } from './utils.js'; + +/** + * Helper to create a static route segment + * @param {string} content + * @returns {{ rest: boolean, dynamic: boolean, content: string }} + */ +function static_segment(content) { + return { rest: false, dynamic: false, content }; +} + +/** + * Helper to create a dynamic route segment + * @param {string} content + * @returns {{ rest: boolean, dynamic: boolean, content: string }} + */ +function dynamic_segment(content) { + return { rest: false, dynamic: true, content }; +} + +/** + * Helper to create a rest route segment + * @param {string} content + * @returns {{ rest: boolean, dynamic: boolean, content: string }} + */ +function rest_segment(content) { + return { rest: true, dynamic: true, content }; +} + +describe('matches', () => { + test('two identical static routes match', () => { + expect( + matches( + [static_segment('blog'), static_segment('post')], + [static_segment('blog'), static_segment('post')] + ) + ).toBe(true); + }); + + test('static segment mismatch returns false', () => { + expect(matches([static_segment('blog')], [static_segment('about')])).toBe(false); + }); + + test('dynamic segment matches any static segment', () => { + expect(matches([static_segment('blog')], [dynamic_segment('[slug]')])).toBe(true); + }); + + test('rest segment at end matches everything', () => { + expect( + matches([static_segment('blog'), static_segment('post')], [rest_segment('[...rest]')]) + ).toBe(true); + }); + + test('rest-only route matches any route', () => { + expect( + matches( + [static_segment('a'), static_segment('b'), static_segment('c')], + [rest_segment('[...rest]')] + ) + ).toBe(true); + }); + + test('rest segment matches remaining segments', () => { + expect( + matches( + [static_segment('blog'), static_segment('2024'), static_segment('post')], + [static_segment('blog'), rest_segment('[...rest]')] + ) + ).toBe(true); + }); + + test('empty segments (index routes)', () => { + expect(matches([], [])).toBe(false); + }); + + test('mismatched lengths without rest return false', () => { + expect( + matches([static_segment('blog'), static_segment('post')], [static_segment('blog')]) + ).toBe(false); + }); + + test('rest with following segments', () => { + expect( + matches( + [static_segment('a'), static_segment('b'), static_segment('page')], + [rest_segment('[...rest]'), static_segment('page')] + ) + ).toBe(true); + }); + + test('rest with following segments that do not match', () => { + expect( + matches( + [static_segment('a'), static_segment('b'), static_segment('other')], + [rest_segment('[...rest]'), static_segment('page')] + ) + ).toBe(false); + }); + + test('a has trailing rest and b is shorter', () => { + expect(matches([rest_segment('[...rest]')], [static_segment('blog')])).toBe(true); + }); + + test('a is longer without rest in b', () => { + expect(matches([static_segment('a'), static_segment('b')], [static_segment('a')])).toBe(false); + }); + + test('b is longer without rest in a', () => { + expect(matches([static_segment('a')], [static_segment('a'), static_segment('b')])).toBe(false); + }); + + test('dynamic in a matches static in b', () => { + expect(matches([dynamic_segment('[id]')], [static_segment('blog')])).toBe(true); + }); + + test('both dynamic segments match', () => { + expect(matches([dynamic_segment('[id]')], [dynamic_segment('[slug]')])).toBe(true); + }); +}); + +describe('get_publish_directory', () => { + test('returns undefined when no netlify.toml, with warning logged', () => { + const warn = vi.fn(); + const builder = /** @type {any} */ ({ log: { warn, minor: vi.fn() } }); + + const result = get_publish_directory(null, builder); + + expect(result).toBeUndefined(); + expect(warn).toHaveBeenCalledOnce(); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('No netlify.toml found')); + }); + + test('returns undefined when config has no build.publish, with minor log', () => { + const minor = vi.fn(); + const builder = /** @type {any} */ ({ log: { warn: vi.fn(), minor } }); + + const result = get_publish_directory({ build: {} }, builder); + + expect(result).toBeUndefined(); + expect(minor).toHaveBeenCalledOnce(); + expect(minor).toHaveBeenCalledWith(expect.stringContaining('No publish directory specified')); + }); + + test('returns the publish value when specified', () => { + const builder = /** @type {any} */ ({ log: { warn: vi.fn(), minor: vi.fn() } }); + + const result = get_publish_directory({ build: { publish: 'dist' } }, builder); + + expect(result).toBe('dist'); + }); + + test('throws when publish is site root', () => { + const builder = /** @type {any} */ ({ log: { warn: vi.fn(), minor: vi.fn() } }); + + expect(() => get_publish_directory({ build: { publish: process.cwd() } }, builder)).toThrow( + 'The publish directory cannot be set to the site root' + ); + }); +});