diff --git a/.changeset/chatty-moles-warn.md b/.changeset/chatty-moles-warn.md new file mode 100644 index 000000000000..c61a30dcab06 --- /dev/null +++ b/.changeset/chatty-moles-warn.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/kit': minor +--- + +feat: support route-level configuration diff --git a/.changeset/cuddly-rats-decide.md b/.changeset/cuddly-rats-decide.md new file mode 100644 index 000000000000..c00aeb2f6dff --- /dev/null +++ b/.changeset/cuddly-rats-decide.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/adapter-vercel': minor +--- + +feat: support route-level configuration options diff --git a/.changeset/fresh-lamps-fetch.md b/.changeset/fresh-lamps-fetch.md new file mode 100644 index 000000000000..bd8f00b2e745 --- /dev/null +++ b/.changeset/fresh-lamps-fetch.md @@ -0,0 +1,8 @@ +--- +'@sveltejs/adapter-auto': major +'@sveltejs/adapter-netlify': major +'@sveltejs/adapter-static': major +'@sveltejs/adapter-vercel': major +--- + +breaking: bump `@sveltejs/kit` peer dependency diff --git a/.changeset/perfect-penguins-smash.md b/.changeset/perfect-penguins-smash.md new file mode 100644 index 000000000000..d8669901a49d --- /dev/null +++ b/.changeset/perfect-penguins-smash.md @@ -0,0 +1,5 @@ +--- +'create-svelte': patch +--- + +chore: bump `@sveltejs/kit` and `@sveltejs/adapter-auto` versions diff --git a/documentation/docs/20-core-concepts/40-page-options.md b/documentation/docs/20-core-concepts/40-page-options.md index 78f23a9c2df6..e5114b775560 100644 --- a/documentation/docs/20-core-concepts/40-page-options.md +++ b/documentation/docs/20-core-concepts/40-page-options.md @@ -125,3 +125,51 @@ export const trailingSlash = 'always'; This option also affects [prerendering](#prerender). If `trailingSlash` is `always`, a route like `/about` will result in an `about/index.html` file, otherwise it will create `about.html`, mirroring static webserver conventions. > Ignoring trailing slashes is not recommended — the semantics of relative paths differ between the two cases (`./y` from `/x` is `/y`, but from `/x/` is `/x/y`), and `/x` and `/x/` are treated as separate URLs which is harmful to SEO. + +## config + +With the concept of [adapters](/docs/adapters), SvelteKit is able to run on a variety of platforms. Each of these might have specific configuration to further tweak the deployment — for example with Vercel or Netlify you could chose to deploy some parts of your app on the edge and others on serverless environments. + +`config` is an object with key-value pairs at the top level. Beyond that, the concrete shape is dependent on the adapter you're using. Every adapter should provide a `Config` interface to import for type safety. Consult the documentation of your adapter for more information. + +```js +// @filename: ambient.d.ts +declare module 'some-adapter' { + export interface Config { runtime: string } +} + +// @filename: index.js +// ---cut--- +/// file: src/routes/+page.js +/** @type {import('some-adapter').Config} */ +export const config = { + runtime: 'edge' +}; +``` + +`config` objects are merged at the top level (but _not_ deeper levels). This means you don't need to repeat all the values in a `+page.js` if you want to only override some of the values in the upper `+layout.js`. For example this layout configuration... + +```js +/// file: src/routes/+layout.js +export const config = { + runtime: 'edge', + regions: 'all', + foo: { + bar: true + } +} +``` + +...is overridden by this page configuration... + +```js +/// file: src/routes/+page.js +export const config = { + regions: ['us1', 'us2'], + foo: { + baz: true + } +} +``` + +...which results in the config value `{ runtime: 'edge', regions: ['us1', 'us2'], foo: { baz: true } }` for that page. diff --git a/documentation/docs/25-build-and-deploy/90-adapter-vercel.md b/documentation/docs/25-build-and-deploy/90-adapter-vercel.md index bcf38cac213a..6b206385db40 100644 --- a/documentation/docs/25-build-and-deploy/90-adapter-vercel.md +++ b/documentation/docs/25-build-and-deploy/90-adapter-vercel.md @@ -11,34 +11,103 @@ This adapter will be installed by default when you use [`adapter-auto`](adapter- Install with `npm i -D @sveltejs/adapter-vercel`, then add the adapter to your `svelte.config.js`: ```js -// @errors: 2307 +// @errors: 2307 2345 /// file: svelte.config.js import adapter from '@sveltejs/adapter-vercel'; export default { kit: { - // default options are shown adapter: adapter({ - // if true, will deploy the app using edge functions - // (https://vercel.com/docs/concepts/functions/edge-functions) - // rather than serverless functions - edge: false, - - // an array of dependencies that esbuild should treat - // as external when bundling functions. this only applies - // to edge functions, and should only be used to exclude - // optional dependencies that will not run outside Node - external: [], - - // if true, will split your app into multiple functions - // instead of creating a single one for the entire app - split: false + // see the 'Deployment configuration' section below }) } }; ``` -## Environment Variables +## Deployment configuration + +To control how your routes are deployed to Vercel as functions, you can specify deployment configuration, either through the option shown above or with [`export const config`](/docs/page-options#config) inside `+server.js`, `+page(.server).js` and `+layout(.server).js` files. + +For example you could deploy some parts of your app as [Edge Functions](https://vercel.com/docs/concepts/functions/edge-functions)... + +```js +/// file: about/+page.js +/** @type {import('@sveltejs/adapter-vercel').Config} */ +export const config = { + runtime: 'edge' +}; +``` + +...and others as [Serverless Functions](https://vercel.com/docs/concepts/functions/serverless-functions) (note that by specifying `config` inside a layout, it applies to all child pages): + +```js +/// file: admin/+layout.js +/** @type {import('@sveltejs/adapter-vercel').Config} */ +export const config = { + runtime: 'nodejs18.x' +}; +``` + +The following options apply to all functions: + +- `runtime`: `'edge'`, `'nodejs16.x'` or `'nodejs18.x'`. By default, the adapter will select `'nodejs16.x'` or `'nodejs18.x'` depending on the Node version your project is configured to use on the Vercel dashboard +- `regions`: an array of [edge network regions](https://vercel.com/docs/concepts/edge-network/regions) (defaulting to `["iad1"]` for serverless functions) or `'all'` if `runtime` is `edge` (its default). Note that multiple regions for serverless functions are only supported on Enterprise plans +- `split`: if `true`, causes a route to be deployed as an individual function. If `split` is set to `true` at the adapter level, all routes will be deployed as individual functions + +Additionally, the following options apply to edge functions: +- `envVarsInUse`: an array of environment variables that should be accessible inside the edge function +- `external`: an array of dependencies that esbuild should treat as external when bundling functions. This should only be used to exclude optional dependencies that will not run outside Node + +And the following option apply to serverless functions: +- `memory`: the amount of memory available to the function. Defaults to `1024` Mb, and can be decreased to `128` Mb or [increased](https://vercel.com/docs/concepts/limits/overview#serverless-function-memory) in 64Mb increments up to `3008` Mb on Pro or Enterprise accounts +- `maxDuration`: maximum execution duration of the function. Defaults to `10` seconds for Hobby accounts, `60` for Pro and `900` for Enterprise +- `isr`: configuration Incremental Static Regeneration, described below + +If your functions need to access data in a specific region, it's recommended that they be deployed in the same region (or close to it) for optimal performance. + +## Incremental Static Regeneration + +Vercel supports [Incremental Static Regeneration](https://vercel.com/docs/concepts/incremental-static-regeneration/overview) (ISR), which provides the performance and cost advantages of prerendered content with the flexibility of dynamically rendered content. + +To add ISR to a route, include the `isr` property in your `config` object: + +```js +/// file: blog/[slug]/+page.server.js +// @filename: ambient.d.ts +declare module '$env/static/private' { + export const BYPASS_TOKEN: string; +} + +// @filename: index.js +// ---cut--- +import { BYPASS_TOKEN } from '$env/static/private'; + +export const config = { + isr: { + // Expiration time (in seconds) before the cached asset will be re-generated by invoking the Serverless Function. + // Setting the value to `false` means it will never expire. + expiration: 60, + + // Option group number of the asset. Assets with the same group number will all be re-validated at the same time. + group: 1, + + // Random token that can be provided in the URL to bypass the cached version of the asset, by requesting the asset + // with a __prerender_bypass= cookie. + // + // Making a `GET` or `HEAD` request with `x-prerender-revalidate: ` will force the asset to be re-validated. + bypassToken: BYPASS_TOKEN, + + // List of query string parameter names that will be cached independently. + // If an empty array, query values are not considered for caching. + // If `undefined` each unique query value is cached independently + allowQuery: ['search'] + } +}; +``` + +The `expiration` property is required; all others are optional. + +## Environment variables Vercel makes a set of [deployment-specific environment variables](https://vercel.com/docs/concepts/projects/environment-variables#system-environment-variables) available. Like other environment variables, these are accessible from `$env/static/private` and `$env/dynamic/private` (sometimes — more on that later), and inaccessible from their public counterparts. To access one of these variables from the client: @@ -65,7 +134,7 @@ export function load() {

This staging environment was deployed from {data.deploymentGitBranch}.

``` -Since all of these variables are unchanged between build time and run time when building on Vercel, we recommend using `$env/static/private` — which will statically replace the variables, enabling optimisations like dead code elimination — rather than `$env/dynamic/private`. If you're deploying with `edge: true` you _must_ use `$env/static/private`, as `$env/dynamic/private` and `$env/dynamic/public` are not currently populated in edge functions on Vercel. +Since all of these variables are unchanged between build time and run time when building on Vercel, we recommend using `$env/static/private` — which will statically replace the variables, enabling optimisations like dead code elimination — rather than `$env/dynamic/private`. If you're deploying with `edge: true` you must either use `$env/static/private` or populate the `envVarsInUse` configuration. ## Notes diff --git a/packages/adapter-auto/adapters.js b/packages/adapter-auto/adapters.js index bbd36575c55a..a904f1af4dcb 100644 --- a/packages/adapter-auto/adapters.js +++ b/packages/adapter-auto/adapters.js @@ -5,7 +5,7 @@ export const adapters = [ name: 'Vercel', test: () => !!process.env.VERCEL, module: '@sveltejs/adapter-vercel', - version: '1' + version: '2' }, { name: 'Cloudflare Pages', @@ -17,7 +17,7 @@ export const adapters = [ name: 'Netlify', test: () => !!process.env.NETLIFY, module: '@sveltejs/adapter-netlify', - version: '1' + version: '2' }, { name: 'Azure Static Web Apps', diff --git a/packages/adapter-netlify/index.js b/packages/adapter-netlify/index.js index 879db643c73f..025636e9487f 100644 --- a/packages/adapter-netlify/index.js +++ b/packages/adapter-netlify/index.js @@ -162,10 +162,17 @@ async function generate_lambda_functions({ builder, publish, split }) { // Configuring the function to use ESM as the output format. const fn_config = JSON.stringify({ config: { nodeModuleFormat: 'esm' }, version: 1 }); + builder.log.minor('Generating serverless functions...'); + if (split) { - builder.log.minor('Generating serverless functions...'); + 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]; - await builder.createEntries((route) => { const parts = []; // Netlify's syntax uses '*' and ':param' as "splats" and "placeholders" // https://docs.netlify.com/routing/redirects/redirect-options/#splats @@ -183,27 +190,33 @@ async function generate_lambda_functions({ builder, publish, split }) { 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' - }); + // skip routes with identical patterns, they were already folded into another function + if (seen.has(pattern)) continue; + seen.add(pattern); - const fn = `import { init } from '../serverless.js';\n\nexport const handler = init(${manifest});\n`; + // figure out which lower priority routes should be considered fallbacks + for (let j = i + 1; j < builder.routes.length; j += 1) { + if (routes[j].prerender === true) continue; - writeFileSync(`.netlify/functions-internal/${name}.mjs`, fn); - writeFileSync(`.netlify/functions-internal/${name}.json`, fn_config); - - redirects.push(`${pattern} /.netlify/functions/${name} 200`); - redirects.push(`${pattern}/__data.json /.netlify/functions/${name} 200`); + if (matches(route.segments, routes[j].segments)) { + routes.push(builder.routes[j]); } - }; - }); - } else { - builder.log.minor('Generating serverless functions...'); + } + + const manifest = builder.generateManifest({ + relativePath: '../server', + routes + }); + + const fn = `import { init } from '../serverless.js';\n\nexport const handler = init(${manifest});\n`; + writeFileSync(`.netlify/functions-internal/${name}.mjs`, fn); + writeFileSync(`.netlify/functions-internal/${name}.json`, fn_config); + + redirects.push(`${pattern} /.netlify/functions/${name} 200`); + redirects.push(`${pattern}/__data.json /.netlify/functions/${name} 200`); + } + } else { const manifest = builder.generateManifest({ relativePath: '../server' }); diff --git a/packages/adapter-netlify/package.json b/packages/adapter-netlify/package.json index da3955d04639..3ad9b954e560 100644 --- a/packages/adapter-netlify/package.json +++ b/packages/adapter-netlify/package.json @@ -50,6 +50,6 @@ "uvu": "^0.5.6" }, "peerDependencies": { - "@sveltejs/kit": "^1.0.0" + "@sveltejs/kit": "^1.5.0" } } diff --git a/packages/adapter-static/index.js b/packages/adapter-static/index.js index 8f859dabcd8e..09c11d574b35 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.some((route) => route.prerender !== true) && 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-static/package.json b/packages/adapter-static/package.json index 9d546c1c8cd6..c4777b0027b9 100644 --- a/packages/adapter-static/package.json +++ b/packages/adapter-static/package.json @@ -38,6 +38,6 @@ "vite": "^4.0.4" }, "peerDependencies": { - "@sveltejs/kit": "^1.0.0" + "@sveltejs/kit": "^1.5.0" } } diff --git a/packages/adapter-vercel/files/edge.js b/packages/adapter-vercel/files/edge.js index 7549ae15022d..b7d58f9c9c32 100644 --- a/packages/adapter-vercel/files/edge.js +++ b/packages/adapter-vercel/files/edge.js @@ -3,7 +3,7 @@ import { manifest } from 'MANIFEST'; const server = new Server(manifest); const initialized = server.init({ - env: process.env + env: /** @type {Record} */ (process.env) }); /** @@ -13,7 +13,7 @@ export default async (request) => { await initialized; return server.respond(request, { getClientAddress() { - return request.headers.get('x-forwarded-for'); + return /** @type {string} */ (request.headers.get('x-forwarded-for')); } }); }; diff --git a/packages/adapter-vercel/files/serverless.js b/packages/adapter-vercel/files/serverless.js index 25db646beefc..a9432521ed41 100644 --- a/packages/adapter-vercel/files/serverless.js +++ b/packages/adapter-vercel/files/serverless.js @@ -8,7 +8,7 @@ installPolyfills(); const server = new Server(manifest); await server.init({ - env: process.env + env: /** @type {Record} */ (process.env) }); /** @@ -22,7 +22,7 @@ export default async (req, res) => { try { request = await getRequest({ base: `https://${req.headers.host}`, request: req }); } catch (err) { - res.statusCode = err.status || 400; + res.statusCode = /** @type {any} */ (err).status || 400; return res.end('Invalid request body'); } @@ -30,7 +30,7 @@ export default async (req, res) => { res, await server.respond(request, { getClientAddress() { - return request.headers.get('x-forwarded-for'); + return /** @type {string} */ (request.headers.get('x-forwarded-for')); } }) ); diff --git a/packages/adapter-vercel/index.d.ts b/packages/adapter-vercel/index.d.ts index 49e4f9661985..a4e21982dfa8 100644 --- a/packages/adapter-vercel/index.d.ts +++ b/packages/adapter-vercel/index.d.ts @@ -1,9 +1,83 @@ import { Adapter } from '@sveltejs/kit'; -type Options = { - edge?: boolean; +export default function plugin(config?: Config): Adapter; + +export interface ServerlessConfig { + /** + * Whether to use [Edge Functions](https://vercel.com/docs/concepts/functions/edge-functions) or [Serverless Functions](https://vercel.com/docs/concepts/functions/serverless-functions) + * @default 'nodejs18.x' + */ + runtime?: 'nodejs16.x' | 'nodejs18.x'; + /** + * To which regions to deploy the app. A list of regions. + * More info: https://vercel.com/docs/concepts/edge-network/regions + */ + regions?: string[]; + /** + * Maximum execution duration (in seconds) that will be allowed for the Serverless Function. + * Serverless only. + */ + maxDuration?: number; + /** + * Amount of memory (RAM in MB) that will be allocated to the Serverless Function. + * Serverless only. + */ + memory?: number; + /** + * If `true`, this route will always be deployed as its own separate function + */ + split?: boolean; + /** + * [Incremental Static Regeneration](https://vercel.com/docs/concepts/incremental-static-regeneration/overview) configuration. + * Serverless only. + */ + isr?: { + /** + * Expiration time (in seconds) before the cached asset will be re-generated by invoking the Serverless Function. Setting the value to `false` means it will never expire. + */ + expiration?: number | false; + /** + * Option group number of the asset. Assets with the same group number will all be re-validated at the same time. + */ + group?: number; + /** + * Random token that can be provided in the URL to bypass the cached version of the asset, by requesting the asset + * with a __prerender_bypass= cookie. + * + * Making a `GET` or `HEAD` request with `x-prerender-revalidate: ` will force the asset to be re-validated. + */ + bypassToken?: string; + /** + * List of query string parameter names that will be cached independently. If an empty array, query values are not considered for caching. If undefined each unique query value is cached independently + */ + allowQuery?: string[] | undefined; + }; +} + +export interface EdgeConfig { + /** + * Whether to use [Edge Functions](https://vercel.com/docs/concepts/functions/edge-functions) or [Serverless Functions](https://vercel.com/docs/concepts/functions/serverless-functions) + */ + runtime?: 'edge'; + /** + * To which regions to deploy the app. A list of regions or `'all'`. + * More info: https://vercel.com/docs/concepts/edge-network/regions + */ + regions?: string[] | 'all'; + /** + * List of environment variable names that will be available for the Edge Function to utilize. + * Edge only. + */ + envVarsInUse?: string[]; + /** + * List of packages that should not be bundled into the Edge Function. + * Edge only. + */ external?: string[]; + /** + * If `true`, this route will always be deployed as its own separate function + */ split?: boolean; -}; +} -export default function plugin(options?: Options): Adapter; +export type Config = EdgeConfig | ServerlessConfig; diff --git a/packages/adapter-vercel/index.js b/packages/adapter-vercel/index.js index 47d384ee3feb..20d4a9772dbc 100644 --- a/packages/adapter-vercel/index.js +++ b/packages/adapter-vercel/index.js @@ -1,17 +1,31 @@ -import fs from 'fs'; -import path from 'path'; -import { fileURLToPath } from 'url'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; import { nodeFileTrace } from '@vercel/nft'; import esbuild from 'esbuild'; +const VALID_RUNTIMES = ['edge', 'nodejs16.x', 'nodejs18.x']; + +const get_default_runtime = () => { + const major = process.version.slice(1).split('.')[0]; + if (major === '16') return 'nodejs16.x'; + if (major === '18') return 'nodejs18.x'; + + throw new Error( + `Unsupported Node.js version: ${process.version}. Please use Node 16 or Node 18 to build your project, or explicitly specify a runtime in your adapter configuration.` + ); +}; + /** @type {import('.').default} **/ -const plugin = function ({ external = [], edge, split } = {}) { +const plugin = function (defaults = {}) { + if ('edge' in defaults) { + throw new Error("{ edge: true } has been removed in favour of { runtime: 'edge' }"); + } + return { name: '@sveltejs/adapter-vercel', async adapt(builder) { - const node_version = get_node_version(); - const dir = '.vercel/output'; const tmp = builder.getBuildDirectory('vercel-tmp'); @@ -25,16 +39,16 @@ const plugin = function ({ external = [], edge, split } = {}) { functions: `${dir}/functions` }; - const config = static_vercel_config(builder); + const static_config = static_vercel_config(builder); builder.log.minor('Generating serverless function...'); /** * @param {string} name - * @param {string} pattern - * @param {(options: { relativePath: string }) => string} generate_manifest + * @param {import('.').ServerlessConfig} config + * @param {import('@sveltejs/kit').RouteDefinition[]} routes */ - async function generate_serverless_function(name, pattern, 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`, { @@ -46,28 +60,49 @@ const plugin = function ({ external = [], edge, split } = {}) { 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` + config ); - config.routes.push({ src: pattern, dest: `/${name}` }); + if (config.isr) { + write( + `${dirs.functions}/${name}.prerender-config.json`, + JSON.stringify( + { + expiration: config.isr.expiration, + group: config.isr.group, + bypassToken: config.isr.bypassToken, + allowQuery: config.isr.allowQuery + }, + null, + '\t' + ) + ); + } } /** * @param {string} name - * @param {string} pattern - * @param {(options: { relativePath: string }) => string} generate_manifest + * @param {import('.').EdgeConfig} config + * @param {import('@sveltejs/kit').RouteDefinition[]} routes */ - async function generate_edge_function(name, pattern, 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`, @@ -77,7 +112,7 @@ const plugin = function ({ external = [], edge, split } = {}) { write( `${tmp}/manifest.js`, - `export const manifest = ${generate_manifest({ relativePath })};\n` + `export const manifest = ${builder.generateManifest({ relativePath, routes })};\n` ); await esbuild.build({ @@ -87,51 +122,122 @@ const plugin = function ({ external = [], edge, split } = {}) { bundle: true, platform: 'browser', format: 'esm', - external, + external: config.external, sourcemap: 'linked', banner: { js: 'globalThis.global = globalThis;' } }); write( `${dirs.functions}/${name}.func/.vc-config.json`, - JSON.stringify({ - runtime: 'edge', - entrypoint: 'index.js' - // TODO expose envVarsInUse - }) + JSON.stringify( + { + runtime: config.runtime, + regions: config.regions, + envVarsInUse: [...envVarsInUse], + entrypoint: 'index.js' + }, + null, + '\t' + ) ); + } + + /** @type {Map[] }>} */ + const groups = new Map(); + + /** @type {Map} */ + const conflicts = new Map(); + + /** @type {Map} */ + const functions = new Map(); + + // group routes by config + for (const route of builder.routes) { + if (route.prerender === true) continue; + + const pattern = route.pattern.toString(); + + const runtime = route.config?.runtime ?? defaults?.runtime ?? get_default_runtime(); + if (runtime && !VALID_RUNTIMES.includes(runtime)) { + throw new Error( + `Invalid runtime '${runtime}' for route ${ + route.id + }. Valid runtimes are ${VALID_RUNTIMES.join(', ')}` + ); + } + + const config = { runtime, ...defaults, ...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 + const id = config.split ? `${hash}-${groups.size}` : hash; + let group = groups.get(id); + if (!group) { + group = { i: groups.size, config, routes: [] }; + groups.set(id, group); + } - config.routes.push({ src: pattern, dest: `/${name}` }); + group.routes.push(route); } - const generate_function = edge ? generate_edge_function : generate_serverless_function; - - if (split) { - await builder.createEntries((route) => { - return { - id: route.pattern.toString(), // TODO is `id` necessary? - filter: (other) => route.pattern.toString() === other.pattern.toString(), - complete: async (entry) => { - 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 = '^/?'; - } - - const src = `${sliced_pattern}(?:/__data.json)?$`; // TODO adding /__data.json is a temporary workaround — those endpoints should be treated as distinct routes - - await generate_function(route.id.slice(1) || 'index', src, entry.generateManifest); - } - }; - }); - } else { - await generate_function('render', '/.*', builder.generateManifest); + for (const group of groups.values()) { + const generate_function = + group.config.runtime === 'edge' ? generate_edge_function : generate_serverless_function; + + // generate one function for the group + const name = `fn-${group.i}`; + await generate_function( + name, + /** @type {any} */ (group.config), + /** @type {import('@sveltejs/kit').RouteDefinition[]} */ (group.routes) + ); + + if (groups.size === 1) { + // Special case: One function for all routes + static_config.routes.push({ src: '/.*', dest: `/${name}` }); + } else { + for (const route of group.routes) { + functions.set(route.pattern.toString(), name); + } + } + } + + for (const route of builder.routes) { + if (route.prerender === true) continue; + + 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...'); @@ -141,11 +247,26 @@ const plugin = function ({ external = [], edge, split } = {}) { builder.log.minor('Writing routes...'); - write(`${dir}/config.json`, JSON.stringify(config, null, ' ')); + write(`${dir}/config.json`, JSON.stringify(static_config, null, '\t')); } }; }; +/** @param {import('.').EdgeConfig & import('.').ServerlessConfig} config */ +function hash_config(config) { + return [ + config.runtime ?? '', + config.external ?? '', + config.regions ?? '', + config.memory ?? '', + config.maxDuration ?? '', + config.isr?.expiration ?? '', + config.isr?.group ?? '', + config.isr?.bypassToken ?? '', + config.isr?.allowQuery ?? '' + ].join('/'); +} + /** * @param {string} file * @param {string} data @@ -160,19 +281,6 @@ function write(file, data) { fs.writeFileSync(file, data); } -function get_node_version() { - const full = process.version.slice(1); // 'v16.5.0' --> '16.5.0' - const major = parseInt(full.split('.')[0]); // '16.5.0' --> 16 - - if (major < 16) { - throw new Error( - `SvelteKit only supports Node.js version 16 or greater (currently using v${full}). Consult the documentation: https://vercel.com/docs/runtimes#official-runtimes/node-js/node-js-version` - ); - } - - return { major, full }; -} - // This function is duplicated in adapter-static /** @param {import('@sveltejs/kit').Builder} builder */ function static_vercel_config(builder) { @@ -235,9 +343,9 @@ function static_vercel_config(builder) { * @param {import('@sveltejs/kit').Builder} builder * @param {string} entry * @param {string} dir - * @param {string} runtime + * @param {import('.').ServerlessConfig} config */ -async function create_function_bundle(builder, entry, dir, runtime) { +async function create_function_bundle(builder, entry, dir, config) { fs.rmSync(dir, { force: true, recursive: true }); let base = entry; @@ -265,7 +373,7 @@ async function create_function_bundle(builder, entry, dir, runtime) { resolution_failures.set(importer, []); } - resolution_failures.get(importer).push(module); + /** @type {string[]} */ (resolution_failures.get(importer)).push(module); } else { throw error; } @@ -286,7 +394,8 @@ async function create_function_bundle(builder, entry, dir, runtime) { } // find common ancestor directory - let common_parts; + /** @type {string[]} */ + let common_parts = []; for (const file of traced.fileList) { if (common_parts) { @@ -330,11 +439,18 @@ async function create_function_bundle(builder, entry, dir, runtime) { write( `${dir}/.vc-config.json`, - JSON.stringify({ - runtime, - handler: path.relative(base + ancestor, entry), - launcherType: 'Nodejs' - }) + JSON.stringify( + { + runtime: config.runtime, + regions: config.regions, + memory: config.memory, + maxDuration: config.maxDuration, + handler: path.relative(base + ancestor, entry), + launcherType: 'Nodejs' + }, + null, + '\t' + ) ); write(`${dir}/package.json`, JSON.stringify({ type: 'module' })); diff --git a/packages/adapter-vercel/package.json b/packages/adapter-vercel/package.json index 159f4d45f178..c1871d2c6bd3 100644 --- a/packages/adapter-vercel/package.json +++ b/packages/adapter-vercel/package.json @@ -37,6 +37,6 @@ "typescript": "^4.9.4" }, "peerDependencies": { - "@sveltejs/kit": "^1.0.0" + "@sveltejs/kit": "^1.5.0" } } diff --git a/packages/adapter-vercel/tsconfig.json b/packages/adapter-vercel/tsconfig.json index 1fa78825bb67..ddff01c72d30 100644 --- a/packages/adapter-vercel/tsconfig.json +++ b/packages/adapter-vercel/tsconfig.json @@ -2,6 +2,7 @@ "compilerOptions": { "allowJs": true, "checkJs": true, + "strict": true, "noEmit": true, "noImplicitAny": true, "module": "es2022", diff --git a/packages/create-svelte/templates/default/package.template.json b/packages/create-svelte/templates/default/package.template.json index 7158e4e11bea..ba275c98b27c 100644 --- a/packages/create-svelte/templates/default/package.template.json +++ b/packages/create-svelte/templates/default/package.template.json @@ -9,8 +9,8 @@ "devDependencies": { "@fontsource/fira-mono": "^4.5.10", "@neoconfetti/svelte": "^1.0.0", - "@sveltejs/adapter-auto": "^1.0.0", - "@sveltejs/kit": "^1.0.0", + "@sveltejs/adapter-auto": "^2.0.0", + "@sveltejs/kit": "^1.5.0", "svelte": "^3.54.0", "vite": "^4.0.0" }, diff --git a/packages/create-svelte/templates/skeleton/package.template.json b/packages/create-svelte/templates/skeleton/package.template.json index 1252908472ae..ade7704210ff 100644 --- a/packages/create-svelte/templates/skeleton/package.template.json +++ b/packages/create-svelte/templates/skeleton/package.template.json @@ -8,8 +8,8 @@ "preview": "vite preview" }, "devDependencies": { - "@sveltejs/adapter-auto": "^1.0.0", - "@sveltejs/kit": "^1.0.0", + "@sveltejs/adapter-auto": "^2.0.0", + "@sveltejs/kit": "^1.5.0", "svelte": "^3.54.0", "vite": "^4.0.0" }, diff --git a/packages/create-svelte/templates/skeletonlib/package.template.json b/packages/create-svelte/templates/skeletonlib/package.template.json index fcb758d3a0c2..e6ac754c6516 100644 --- a/packages/create-svelte/templates/skeletonlib/package.template.json +++ b/packages/create-svelte/templates/skeletonlib/package.template.json @@ -7,8 +7,8 @@ "prepublishOnly": "echo 'Did you mean to publish `./package/`, instead of `./`?' && exit 1" }, "devDependencies": { - "@sveltejs/adapter-auto": "^1.0.0", - "@sveltejs/kit": "^1.0.0", + "@sveltejs/adapter-auto": "^2.0.0", + "@sveltejs/kit": "^1.5.0", "@sveltejs/package": "^1.0.0", "svelte": "^3.54.0", "tslib": "^2.4.1", diff --git a/packages/kit/src/core/adapt/builder.js b/packages/kit/src/core/adapt/builder.js index d8e2562dd03a..f30228d65823 100644 --- a/packages/kit/src/core/adapt/builder.js +++ b/packages/kit/src/core/adapt/builder.js @@ -18,13 +18,54 @@ 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; + * prerender_map: import('types').PrerenderMap; * 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, + prerender_map, + 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, + prerender: prerender_map.get(route.id) ?? false, + methods, + config + }; + + lookup.set(facade, route); + + return facade; + }); + return { log, rimraf, @@ -33,6 +74,7 @@ export function create_builder({ config, build_data, server_metadata, routes, pr config, prerendered, + routes, async compress(directory) { if (!existsSync(directory)) { @@ -52,29 +94,12 @@ 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); - - return { - id: route.id, - segments: get_route_segments(route.id).map((segment) => ({ - dynamic: segment.includes('['), - rest: segment.includes('[...'), - content: segment - })), - pattern: route.pattern, - methods - }; - }); - const seen = new Set(); - for (let i = 0; i < routes.length; i += 1) { - const route = routes[i]; - const { id, filter, complete } = fn(facades[i]); + for (let i = 0; i < route_data.length; i += 1) { + const route = route_data[i]; + if (prerender_map.get(route.id) === true) continue; + const { id, filter, complete } = fn(routes[i]); if (seen.has(id)) continue; seen.add(id); @@ -82,9 +107,10 @@ export function create_builder({ config, build_data, server_metadata, routes, pr const group = [route]; // figure out which lower priority routes should be considered fallbacks - for (let j = i + 1; j < routes.length; j += 1) { - if (filter(facades[j])) { - group.push(routes[j]); + for (let j = i + 1; j < route_data.length; j += 1) { + if (prerender_map.get(routes[j].id) === true) continue; + if (filter(routes[j])) { + group.push(route_data[j]); } } @@ -95,7 +121,7 @@ export function create_builder({ config, build_data, server_metadata, routes, pr // TODO is this still necessary, given the new way of doing things? filtered.forEach((route) => { if (route.page) { - const endpoint = routes.find((candidate) => candidate.id === route.id + '.json'); + const endpoint = route_data.find((candidate) => candidate.id === route.id + '.json'); if (endpoint) { filtered.add(endpoint); @@ -143,11 +169,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 278a06a7780e..6278c4af6b7a 100644 --- a/packages/kit/src/core/adapt/builder.spec.js +++ b/packages/kit/src/core/adapt/builder.spec.js @@ -31,12 +31,14 @@ test('copy files', () => { build_data: {}, // @ts-expect-error server_metadata: {}, - routes: [], + route_data: [], // @ts-expect-error prerendered: { paths: [] }, // @ts-expect-error + prerender_map: {}, + // @ts-expect-error log: {} }); diff --git a/packages/kit/src/core/adapt/index.js b/packages/kit/src/core/adapt/index.js index 430054830748..b5b3d151887b 100644 --- a/packages/kit/src/core/adapt/index.js +++ b/packages/kit/src/core/adapt/index.js @@ -18,13 +18,9 @@ export async function adapt(config, build_data, server_metadata, prerendered, pr config, build_data, server_metadata, - routes: build_data.manifest_data.routes.filter((route) => { - if (!route.page && !route.endpoint) return false; - - const prerender = prerender_map.get(route.id); - return prerender === false || prerender === undefined || prerender === 'auto'; - }), + route_data: build_data.manifest_data.routes.filter((route) => route.page || route.endpoint), prerendered, + prerender_map, log }); await adapt(builder); diff --git a/packages/kit/src/core/postbuild/analyse.js b/packages/kit/src/core/postbuild/analyse.js index 7aef3c55fbd6..6930a3323e10 100644 --- a/packages/kit/src/core/postbuild/analyse.js +++ b/packages/kit/src/core/postbuild/analyse.js @@ -67,6 +67,8 @@ async function analyse({ manifest_path, env }) { /** @type {import('types').PrerenderOption | undefined} */ let prerender = undefined; + /** @type {any} */ + let config = undefined; if (route.endpoint) { const mod = await route.endpoint(); @@ -123,13 +125,35 @@ async function analyse({ manifest_path, env }) { (should_prerender !== false && get_option(nodes, 'ssr') === false && !page?.server?.actions ? 'auto' : should_prerender ?? false); + + config = get_config(nodes); } metadata.routes.set(route.id, { prerender, + config, methods: Array.from(methods) }); } return metadata; } + +/** + * Do a shallow merge (first level) of the config object + * @param {Array} nodes + */ +function get_config(nodes) { + let current = {}; + for (const node of nodes) { + const config = node?.universal?.config ?? node?.server?.config; + if (config) { + current = { + ...current, + ...config + }; + } + } + + return Object.keys(current).length ? current : undefined; +} diff --git a/packages/kit/src/utils/exports.js b/packages/kit/src/utils/exports.js index 80de5c1609b2..3a38d9b46d17 100644 --- a/packages/kit/src/utils/exports.js +++ b/packages/kit/src/utils/exports.js @@ -31,7 +31,8 @@ export const validate_common_exports = validator([ 'prerender', 'csr', 'ssr', - 'trailingSlash' + 'trailingSlash', + 'config' ]); export const validate_page_server_exports = validator([ @@ -40,7 +41,8 @@ export const validate_page_server_exports = validator([ 'csr', 'ssr', 'actions', - 'trailingSlash' + 'trailingSlash', + 'config' ]); export const validate_server_exports = validator([ @@ -50,5 +52,6 @@ export const validate_server_exports = validator([ 'PUT', 'DELETE', 'prerender', - 'trailingSlash' + 'trailingSlash', + 'config' ]); diff --git a/packages/kit/src/utils/exports.spec.js b/packages/kit/src/utils/exports.spec.js index d0f6603cbb02..46396395948f 100644 --- a/packages/kit/src/utils/exports.spec.js +++ b/packages/kit/src/utils/exports.spec.js @@ -19,7 +19,7 @@ test('validates +layout.server.js, +layout.js, +page.js', () => { validate_common_exports({ actions: {} }); - }, /Invalid export 'actions' \(valid exports are load, prerender, csr, ssr, trailingSlash, or anything with a '_' prefix\)/); + }, /Invalid export 'actions' \(valid exports are load, prerender, csr, ssr, trailingSlash, config, or anything with a '_' prefix\)/); }); test('validates +page.server.js', () => { @@ -36,7 +36,7 @@ test('validates +page.server.js', () => { validate_page_server_exports({ answer: 42 }); - }, /Invalid export 'answer' \(valid exports are load, prerender, csr, ssr, actions, trailingSlash, or anything with a '_' prefix\)/); + }, /Invalid export 'answer' \(valid exports are load, prerender, csr, ssr, actions, trailingSlash, config, or anything with a '_' prefix\)/); }); test('validates +server.js', () => { @@ -52,7 +52,7 @@ test('validates +server.js', () => { validate_server_exports({ answer: 42 }); - }, /Invalid export 'answer' \(valid exports are GET, POST, PATCH, PUT, DELETE, prerender, trailingSlash, or anything with a '_' prefix\)/); + }, /Invalid export 'answer' \(valid exports are GET, POST, PATCH, PUT, DELETE, prerender, trailingSlash, config, or anything with a '_' prefix\)/); }); test.run(); diff --git a/packages/kit/types/index.d.ts b/packages/kit/types/index.d.ts index 0a211ff54216..bae95e13e35b 100644 --- a/packages/kit/types/index.d.ts +++ b/packages/kit/types/index.d.ts @@ -7,13 +7,15 @@ import { CompileOptions } from 'svelte/types/compiler/interfaces'; import { AdapterEntry, CspDirectives, + HttpMethod, Logger, MaybePromise, Prerendered, PrerenderHttpErrorHandlerValue, PrerenderMissingIdHandlerValue, + PrerenderOption, RequestOptions, - RouteDefinition, + RouteSegment, UniqueInterface } from './private.js'; import { SSRNodeLoader, SSRRoute, ValidatedConfig } from './internal.js'; @@ -85,10 +87,13 @@ export interface Builder { config: ValidatedConfig; /** Information about prerendered pages and assets, if any. */ prerendered: Prerendered; + /** 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 +106,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`. @@ -959,6 +964,15 @@ export interface ResolveOptions { preload?(input: { type: 'font' | 'css' | 'js' | 'asset'; path: string }): boolean; } +export interface RouteDefinition { + id: string; + pattern: RegExp; + prerender: PrerenderOption; + segments: RouteSegment[]; + methods: HttpMethod[]; + config: Config; +} + export class Server { constructor(manifest: SSRManifest); init(options: ServerInitOptions): Promise; diff --git a/packages/kit/types/internal.d.ts b/packages/kit/types/internal.d.ts index 0f053a16706e..6ac9467c173c 100644 --- a/packages/kit/types/internal.d.ts +++ b/packages/kit/types/internal.d.ts @@ -239,6 +239,7 @@ export interface ServerMetadata { { prerender: PrerenderOption | undefined; methods: HttpMethod[]; + config: any; } >; } @@ -279,6 +280,7 @@ export interface SSRNode { ssr?: boolean; csr?: boolean; trailingSlash?: TrailingSlash; + config?: any; }; server: { @@ -288,6 +290,7 @@ export interface SSRNode { csr?: boolean; trailingSlash?: TrailingSlash; actions?: Actions; + config?: any; }; // store this in dev so we can print serialization errors diff --git a/packages/kit/types/private.d.ts b/packages/kit/types/private.d.ts index 2927959e033d..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. @@ -12,8 +14,11 @@ export interface AdapterEntry { /** * A function that compares the candidate route with the current route to determine - * if it should be treated as a fallback for the current route. For example, `/foo/[c]` - * is a fallback for `/foo/a-[b]`, and `/[...catchall]` is a fallback for all routes + * 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 */ filter(route: RouteDefinition): boolean; @@ -212,13 +217,6 @@ export interface RequestOptions { platform?: App.Platform; } -export interface RouteDefinition { - id: string; - pattern: RegExp; - segments: RouteSegment[]; - methods: HttpMethod[]; -} - export interface RouteSegment { content: string; dynamic: boolean; diff --git a/sites/kit.svelte.dev/svelte.config.js b/sites/kit.svelte.dev/svelte.config.js index 80b24465e75d..46027fbe78d6 100644 --- a/sites/kit.svelte.dev/svelte.config.js +++ b/sites/kit.svelte.dev/svelte.config.js @@ -3,7 +3,9 @@ import adapter from '@sveltejs/adapter-vercel'; /** @type {import('@sveltejs/kit').Config} */ const config = { kit: { - adapter: adapter({ edge: true }) + adapter: adapter({ + runtime: 'edge' + }) }, vitePlugin: {