diff --git a/documentation/docs/20-core-concepts/40-page-options.md b/documentation/docs/20-core-concepts/40-page-options.md index ab5f57c16e65..e5114b775560 100644 --- a/documentation/docs/20-core-concepts/40-page-options.md +++ b/documentation/docs/20-core-concepts/40-page-options.md @@ -139,11 +139,11 @@ declare module 'some-adapter' { } // @filename: index.js ----cut--- +// ---cut--- /// file: src/routes/+page.js /** @type {import('some-adapter').Config} */ -export const config: Config = { - runtime: 'edge'; +export const config = { + runtime: 'edge' }; ``` diff --git a/packages/adapter-static/index.js b/packages/adapter-static/index.js index 8f859dabcd8e..a57e7b6fee60 100644 --- a/packages/adapter-static/index.js +++ b/packages/adapter-static/index.js @@ -8,25 +8,9 @@ export default function (options) { async adapt(builder) { if (!options?.fallback) { - /** @type {string[]} */ - const dynamic_routes = []; - - // this is a bit of a hack — it allows us to know whether there are dynamic - // (i.e. prerender = false/'auto') routes without having dedicated API - // surface area for it - builder.createEntries((route) => { - dynamic_routes.push(route.id); - - return { - id: '', - filter: () => false, - complete: () => {} - }; - }); - - if (dynamic_routes.length > 0 && options?.strict !== false) { + if (builder.routes.length > 0 && options?.strict !== false) { const prefix = path.relative('.', builder.config.kit.files.routes); - const has_param_routes = dynamic_routes.some((route) => route.includes('[')); + const has_param_routes = builder.routes.some((route) => route.id.includes('[')); const config_option = has_param_routes || JSON.stringify(builder.config.kit.prerender.entries) !== '["*"]' ? ` - adjust the \`prerender.entries\` config option ${ @@ -38,7 +22,7 @@ export default function (options) { builder.log.error( `@sveltejs/adapter-static: all routes must be fully prerenderable, but found the following routes that are dynamic: -${dynamic_routes.map((id) => ` - ${path.posix.join(prefix, id)}`).join('\n')} +${builder.routes.map((route) => ` - ${path.posix.join(prefix, route.id)}`).join('\n')} You have the following options: - set the \`fallback\` option — see https://github.com/sveltejs/kit/tree/master/packages/adapter-static#spa-mode for more info. diff --git a/packages/adapter-vercel/index.js b/packages/adapter-vercel/index.js index eb1fdb9a81f8..ef58f79982fb 100644 --- a/packages/adapter-vercel/index.js +++ b/packages/adapter-vercel/index.js @@ -4,6 +4,16 @@ import { fileURLToPath } from 'url'; import { nodeFileTrace } from '@vercel/nft'; import esbuild from 'esbuild'; +const VALID_RUNTIMES = ['edge', 'nodejs16.x', 'nodejs18.x']; + +const DEFAULT_RUNTIME = 'nodejs18.x'; +const DEFAULT_REGION = 'iad1'; + +const DEFAULTS = { + memory: 128, + maxDuration: 30 // TODO check what the defaults actually are +}; + /** @type {import('.').default} **/ const plugin = function ({ external = [], edge, split, ...default_config } = {}) { return { @@ -31,11 +41,10 @@ const plugin = function ({ external = [], edge, split, ...default_config } = {}) /** * @param {string} name - * @param {string[]} patterns - * @param {import('.').Config | undefined} config - * @param {(options: { relativePath: string }) => string} generate_manifest + * @param {import('.').Config} config + * @param {import('@sveltejs/kit').RouteDefinition[]} routes */ - async function generate_serverless_function(name, patterns, config, generate_manifest) { + async function generate_serverless_function(name, config, routes) { const relativePath = path.posix.relative(tmp, builder.getServerDirectory()); builder.copy(`${files}/serverless.js`, `${tmp}/index.js`, { @@ -47,32 +56,34 @@ const plugin = function ({ external = [], edge, split, ...default_config } = {}) write( `${tmp}/manifest.js`, - `export const manifest = ${generate_manifest({ relativePath })};\n` + `export const manifest = ${builder.generateManifest({ relativePath, routes })};\n` ); await create_function_bundle( builder, `${tmp}/index.js`, `${dirs.functions}/${name}.func`, - `nodejs${node_version.major}.x`, + `nodejs${node_version.major}.x`, // TODO use function config config ); - - for (const pattern of patterns) { - static_config.routes.push({ src: pattern, dest: `/${name}` }); - } } /** * @param {string} name - * @param {string[]} patterns - * @param {import('.').Config | undefined} config - * @param {(options: { relativePath: string }) => string} generate_manifest + * @param {import('.').Config} config + * @param {import('@sveltejs/kit').RouteDefinition[]} routes */ - async function generate_edge_function(name, patterns, config, generate_manifest) { + async function generate_edge_function(name, config, routes) { const tmp = builder.getBuildDirectory(`vercel-tmp/${name}`); const relativePath = path.posix.relative(tmp, builder.getServerDirectory()); + const envVarsInUse = new Set(); + routes.forEach((route) => { + route.config?.envVarsInUse?.forEach((x) => { + envVarsInUse.add(x); + }); + }); + builder.copy(`${files}/edge.js`, `${tmp}/edge.js`, { replace: { SERVER: `${relativePath}/index.js`, @@ -82,7 +93,7 @@ const plugin = function ({ external = [], edge, split, ...default_config } = {}) write( `${tmp}/manifest.js`, - `export const manifest = ${generate_manifest({ relativePath })};\n` + `export const manifest = ${builder.generateManifest({ relativePath, routes })};\n` ); await esbuild.build({ @@ -101,59 +112,120 @@ const plugin = function ({ external = [], edge, split, ...default_config } = {}) `${dirs.functions}/${name}.func/.vc-config.json`, JSON.stringify({ ...config, - runtime: 'edge', + envVarsInUse: [...envVarsInUse], entrypoint: 'index.js' }) ); + } + + /** @type {Map[] }>} */ + const groups = new Map(); + + /** @type {Map} */ + const conflicts = new Map(); + + /** @type {Map} */ + const functions = new Map(); - for (const pattern of patterns) { - static_config.routes.push({ src: pattern, dest: `/${name}` }); + // group routes by config + for (const route of builder.routes) { + const pattern = route.pattern.toString(); + + const runtime = route.config?.runtime ?? default_config?.runtime ?? DEFAULT_RUNTIME; + if (!VALID_RUNTIMES.includes(runtime)) { + throw new Error( + `Invalid runtime '${runtime}' for route ${ + route.id + }. Valid runtimes are ${VALID_RUNTIMES.join(', ')}` + ); + } + + const regions = runtime === 'edge' ? ['all'] : [DEFAULT_REGION]; + + const config = { runtime, regions, ...DEFAULTS, ...default_config, ...route.config }; + + const hash = hash_config(config); + + // first, check there are no routes with incompatible configs that will be merged + const existing = conflicts.get(pattern); + if (existing) { + if (existing.hash !== hash) { + throw new Error( + `The ${route.id} and ${existing.route_id} routes must be merged into a single function that matches the ${route.pattern} regex, but they have incompatible configs. You must either rename one of the routes, or make their configs match.` + ); + } + } else { + conflicts.set(pattern, { hash, route_id: route.id }); + } + + // then, create a group for each config + let group = groups.get(hash); + if (!group) { + group = { i: groups.size, config, routes: [] }; + groups.set(hash, group); } + + group.routes.push(route); } - if (split || builder.hasRouteLevelConfig) { - await builder.createEntries((route) => { - const route_config = { ...default_config, ...route.config }; - return { - id: route.pattern.toString(), // TODO is `id` necessary? - filter: (other) => - (!split && !builder.hasRouteLevelConfig) || - route.pattern.toString() === other.pattern.toString(), - group: (other) => can_group(route_config, { ...default_config, ...other.config }), - complete: async (entry) => { - const patterns = entry.routes.map((route) => { - let sliced_pattern = route.pattern - .toString() - // remove leading / and trailing $/ - .slice(1, -2) - // replace escaped \/ with / - .replace(/\\\//g, '/'); - - // replace the root route "^/" with "^/?" - if (sliced_pattern === '^/') { - sliced_pattern = '^/?'; - } - - return `${sliced_pattern}(?:/__data.json)?$`; - }); - - const generate_function = - (edge && !route_config.runtime) || route_config.runtime === 'edge' - ? generate_edge_function - : generate_serverless_function; - - await generate_function( - route.id.slice(1) || 'index', - patterns, - route_config, - entry.generateManifest - ); + for (const group of groups.values()) { + const generate_function = + group.config.runtime === 'edge' ? generate_edge_function : generate_serverless_function; + + if (split) { + // generate individual functions + /** @type {Map[]>} */ + const merged = new Map(); + + for (const route of group.routes) { + const pattern = route.pattern.toString(); + const existing = merged.get(pattern); + if (existing) { + existing.push(route); + } else { + merged.set(pattern, [route]); } - }; - }); - } else { - const generate_function = edge ? generate_edge_function : generate_serverless_function; - await generate_function('render', ['/.*'], default_config, builder.generateManifest); + } + + let i = 0; + + for (const [pattern, routes] of merged) { + const name = `fn-${group.i}-${i++}`; + functions.set(pattern, name); + await generate_function(name, group.config, routes); + } + } else { + // generate one function for the group + const name = `fn-${group.i}`; + await generate_function(name, group.config, group.routes); + + for (const route of group.routes) { + functions.set(route.pattern.toString(), name); + } + } + } + + for (const route of builder.routes) { + const pattern = route.pattern.toString(); + + let src = pattern + // remove leading / and trailing $/ + .slice(1, -2) + // replace escaped \/ with / + .replace(/\\\//g, '/'); + + // replace the root route "^/" with "^/?" + if (src === '^/') { + src = '^/?'; + } + + src += '(?:/__data.json)?$'; + + const name = functions.get(pattern); + if (name) { + static_config.routes.push({ src, dest: `/${name}` }); + functions.delete(pattern); + } } builder.log.minor('Copying assets...'); @@ -168,37 +240,9 @@ const plugin = function ({ external = [], edge, split, ...default_config } = {}) }; }; -/** - * @param {import('.').Config | undefined} config_a - * @param {import('.').Config | undefined} config_b - */ -function can_group(config_a, config_b) { - if (config_a === config_b) return true; - if (!config_a || !config_b) return false; - - if (config_a.runtime !== config_b.runtime) return false; - if (config_a.maxDuration !== config_b.maxDuration) return false; - if (config_a.memory !== config_b.memory) return false; - if (arrays_different(config_a.envVarsInUse, config_b.envVarsInUse)) return false; - - const regions_a = config_a.regions === 'all' ? ['all'] : config_a.regions; - const regions_b = config_b.regions === 'all' ? ['all'] : config_b.regions; - if (arrays_different(regions_a, regions_b)) return false; - - return true; -} - -/** - * - * @param {any[] | undefined} a - * @param {any[] | undefined} b - * @returns - */ -function arrays_different(a, b) { - if (a === b) return false; - if (!a || !b) return true; - if (a.length !== b.length) return true; - return a.every((e) => b.includes(e)); +/** @param {import('.').Config} config */ +function hash_config(config) { + return [config.runtime, config.regions, config.memory, config.maxDuration].join('/'); } /** @@ -380,8 +424,8 @@ async function create_function_bundle(builder, entry, dir, runtime, config) { write( `${dir}/.vc-config.json`, JSON.stringify({ + runtime, ...config, - runtime: config?.runtime === 'serverless' || !config?.runtime ? runtime : config.runtime, handler: path.relative(base + ancestor, entry), launcherType: 'Nodejs' }) diff --git a/packages/kit/src/core/adapt/builder.js b/packages/kit/src/core/adapt/builder.js index c601dbc11399..f5d24636c75e 100644 --- a/packages/kit/src/core/adapt/builder.js +++ b/packages/kit/src/core/adapt/builder.js @@ -18,13 +18,51 @@ const pipe = promisify(pipeline); * config: import('types').ValidatedConfig; * build_data: import('types').BuildData; * server_metadata: import('types').ServerMetadata; - * routes: import('types').RouteData[]; + * route_data: import('types').RouteData[]; * prerendered: import('types').Prerendered; * log: import('types').Logger; * }} opts * @returns {import('types').Builder} */ -export function create_builder({ config, build_data, server_metadata, routes, prerendered, log }) { +export function create_builder({ + config, + build_data, + server_metadata, + route_data, + prerendered, + log +}) { + /** @type {Map} */ + const lookup = new Map(); + + /** + * Rather than exposing the internal `RouteData` type, which is subject to change, + * we expose a stable type that adapters can use to group/filter routes + */ + const routes = route_data.map((route) => { + const methods = + /** @type {import('types').HttpMethod[]} */ + (server_metadata.routes.get(route.id)?.methods); + const config = server_metadata.routes.get(route.id)?.config; + + /** @type {import('types').RouteDefinition} */ + const facade = { + id: route.id, + segments: get_route_segments(route.id).map((segment) => ({ + dynamic: segment.includes('['), + rest: segment.includes('[...'), + content: segment + })), + pattern: route.pattern, + methods, + config + }; + + lookup.set(facade, route); + + return facade; + }); + return { log, rimraf, @@ -33,9 +71,7 @@ export function create_builder({ config, build_data, server_metadata, routes, pr config, prerendered, - hasRouteLevelConfig: - !!server_metadata.routes && - !![...server_metadata.routes.values()].some((route) => route.config), + routes, async compress(directory) { if (!existsSync(directory)) { @@ -55,78 +91,41 @@ export function create_builder({ config, build_data, server_metadata, routes, pr }, async createEntries(fn) { - /** @type {import('types').RouteDefinition[]} */ - const facades = routes.map((route) => { - const methods = - /** @type {import('types').HttpMethod[]} */ - (server_metadata.routes.get(route.id)?.methods); - const config = server_metadata.routes.get(route.id)?.config; - - return { - id: route.id, - segments: get_route_segments(route.id).map((segment) => ({ - dynamic: segment.includes('['), - rest: segment.includes('[...'), - content: segment - })), - pattern: route.pattern, - methods, - config - }; - }); - const seen = new Set(); - const grouped = new Set(); - let ungrouped = facades.map((f, i) => ({ r: routes[i], f })); - for (let i = 0; i < routes.length; i += 1) { - const route = routes[i]; - const { id, filter, complete, group } = fn(facades[i]); + for (let i = 0; i < route_data.length; i += 1) { + const route = route_data[i]; + const { id, filter, complete } = fn(routes[i]); - if (seen.has(id) || grouped.has(route)) continue; + if (seen.has(id)) continue; seen.add(id); - const filtered = new Set([route]); - const route_definitions = new Set([facades[i]]); - - if (group) { - ungrouped = ungrouped.filter((candidate) => { - if (group(candidate.f)) { - filtered.add(candidate.r); - route_definitions.add(candidate.f); - grouped.add(candidate.r); - return false; - } - return true; - }); - } + const group = [route]; // figure out which lower priority routes should be considered fallbacks - for (let j = i + 1; j < routes.length; j += 1) { - if (!group && filter(facades[j])) { - filtered.add(routes[j]); - route_definitions.add(facades[j]); + for (let j = i + 1; j < route_data.length; j += 1) { + if (filter(routes[j])) { + group.push(route_data[j]); } } + const filtered = new Set(group); + // heuristic: if /foo/[bar] is included, /foo/[bar].json should // also be included, since the page likely needs the endpoint // TODO is this still necessary, given the new way of doing things? filtered.forEach((route) => { if (route.page) { - const idx = routes.findIndex((candidate) => candidate.id === route.id + '.json'); - const endpoint = routes[idx]; + const endpoint = route_data.find((candidate) => candidate.id === route.id + '.json'); if (endpoint) { filtered.add(endpoint); - route_definitions.add(facades[idx]); } } }); if (filtered.size > 0) { await complete({ - routes: Array.from(route_definitions), generateManifest: ({ relativePath }) => generate_manifest({ build_data, @@ -165,11 +164,13 @@ export function create_builder({ config, build_data, server_metadata, routes, pr }); }, - generateManifest: ({ relativePath }) => { + generateManifest: ({ relativePath, routes: subset }) => { return generate_manifest({ build_data, relative_path: relativePath, - routes + routes: subset + ? subset.map((route) => /** @type {import('types').RouteData} */ (lookup.get(route))) + : route_data }); }, diff --git a/packages/kit/src/core/adapt/builder.spec.js b/packages/kit/src/core/adapt/builder.spec.js index 2f10ff4b22f1..983b37e75b05 100644 --- a/packages/kit/src/core/adapt/builder.spec.js +++ b/packages/kit/src/core/adapt/builder.spec.js @@ -31,7 +31,7 @@ test('copy files', () => { build_data: {}, // @ts-expect-error server_metadata: {}, - routes: [], + route_data: [], // @ts-expect-error prerendered: { paths: [] diff --git a/packages/kit/src/core/adapt/index.js b/packages/kit/src/core/adapt/index.js index 430054830748..0dd8dd116565 100644 --- a/packages/kit/src/core/adapt/index.js +++ b/packages/kit/src/core/adapt/index.js @@ -18,7 +18,7 @@ export async function adapt(config, build_data, server_metadata, prerendered, pr config, build_data, server_metadata, - routes: build_data.manifest_data.routes.filter((route) => { + route_data: build_data.manifest_data.routes.filter((route) => { if (!route.page && !route.endpoint) return false; const prerender = prerender_map.get(route.id); diff --git a/packages/kit/types/index.d.ts b/packages/kit/types/index.d.ts index 3d60e591b091..ed7571680ce2 100644 --- a/packages/kit/types/index.d.ts +++ b/packages/kit/types/index.d.ts @@ -7,13 +7,14 @@ import { CompileOptions } from 'svelte/types/compiler/interfaces'; import { AdapterEntry, CspDirectives, + HttpMethod, Logger, MaybePromise, Prerendered, PrerenderHttpErrorHandlerValue, PrerenderMissingIdHandlerValue, RequestOptions, - RouteDefinition, + RouteSegment, UniqueInterface } from './private.js'; import { SSRNodeLoader, SSRRoute, ValidatedConfig } from './internal.js'; @@ -83,12 +84,13 @@ export interface Builder { config: ValidatedConfig; /** Information about prerendered pages and assets, if any. */ prerendered: Prerendered; - /** `true` if one or more routes have `export const config = ..` in it */ - hasRouteLevelConfig: boolean; + /** An array of dynamic (not prerendered) routes */ + routes: RouteDefinition[]; /** * Create separate functions that map to one or more routes of your app. * @param fn A function that groups a set of routes into an entry point + * @deprecated Use `builder.routes` instead */ createEntries(fn: (route: RouteDefinition) => AdapterEntry): Promise; @@ -101,7 +103,7 @@ export interface Builder { * Generate a server-side manifest to initialise the SvelteKit [server](https://kit.svelte.dev/docs/types#public-types-server) with. * @param opts a relative path to the base directory of the app and optionally in which format (esm or cjs) the manifest should be generated */ - generateManifest(opts: { relativePath: string }): string; + generateManifest(opts: { relativePath: string; routes?: RouteDefinition[] }): string; /** * Resolve a path to the `name` directory inside `outDir`, e.g. `/path/to/.svelte-kit/my-adapter`. @@ -958,6 +960,14 @@ export interface ResolveOptions { preload?(input: { type: 'font' | 'css' | 'js' | 'asset'; path: string }): boolean; } +export interface RouteDefinition { + id: string; + pattern: RegExp; + segments: RouteSegment[]; + methods: HttpMethod[]; + config: Config; +} + export class Server { constructor(manifest: SSRManifest); init(options: ServerInitOptions): Promise; diff --git a/packages/kit/types/private.d.ts b/packages/kit/types/private.d.ts index 6391d12f42eb..29152ae4ffab 100644 --- a/packages/kit/types/private.d.ts +++ b/packages/kit/types/private.d.ts @@ -2,6 +2,8 @@ // but which cannot be imported from `@sveltejs/kit`. Care should // be taken to avoid breaking changes when editing this file +import { RouteDefinition } from './index.js'; + export interface AdapterEntry { /** * A string that uniquely identifies an HTTP service (e.g. serverless function) and is used for deduplication. @@ -10,33 +12,21 @@ export interface AdapterEntry { */ id: string; - /** - * A function that compares the lower candidate route with the current route to determine - * if it should be grouped with the current route. Has no effect when `group` is set. - * - * Use cases: - * - Fallback pages: `/foo/[c]` is a fallback for `/foo/a-[b]`, and `/[...catchall]` is a fallback for all routes - */ - filter(route: RouteDefinition): boolean; - /** * A function that compares the candidate route with the current route to determine - * if it should be grouped with the current route. In contrast to `filter`, this - * results in the other route not being invoked by `createEntries` again, if grouped. + * if it should be grouped with the current route. * * Use cases: + * - Fallback pages: `/foo/[c]` is a fallback for `/foo/a-[b]`, and `/[...catchall]` is a fallback for all routes * - Grouping routes that share a common `config`: `/foo` should be deployed to the edge, `/bar` and `/baz` should be deployed to a serverless function */ - group?(route: RouteDefinition): boolean; + filter(route: RouteDefinition): boolean; /** * A function that is invoked once the entry has been created. This is where you * should write the function to the filesystem and generate redirect manifests. */ - complete(entry: { - routes: RouteDefinition[]; - generateManifest(opts: { relativePath: string }): string; - }): MaybePromise; + complete(entry: { generateManifest(opts: { relativePath: string }): string }): MaybePromise; } // Based on https://github.com/josh-hemphill/csp-typed-directives/blob/latest/src/csp.types.ts @@ -227,14 +217,6 @@ export interface RequestOptions { platform?: App.Platform; } -export interface RouteDefinition { - id: string; - pattern: RegExp; - segments: RouteSegment[]; - methods: HttpMethod[]; - config: Config; -} - export interface RouteSegment { content: string; dynamic: boolean;