Skip to content

Commit

Permalink
feat: route level config (#8740)
Browse files Browse the repository at this point in the history
* route level config wip

* this seems to work

* proper config

* do shallow merge

* docs

* use config

* default config

* vercel docs

* fix tests

* appease TS

* oops

* more docs

* this feels clunky

* Route level config alt (#8863)

* Revert "this feels clunky"

This reverts commit b3b1b6e.

* alternative approach to route-level config

* remove hasRouteLevelConfig

* handle data requests

* simplify

* fix types

* remove unused code

* fix site

* fix default runtime

* validate region, default to all for edge

* "all" not ["all"]

* changesets

* make defaultConfig a separate option key

* Update documentation/docs/25-build-and-deploy/90-adapter-vercel.md

* implement split: true

* tidy up

* fix site

* Update packages/adapter-vercel/index.d.ts

* tweaks

* get rid of top-level split option

* union type

* handle common case of one fn for everything separately to not pollute json for big apps

* tweak docs a little

* netlify

* make external a config option and simplify adapter options interface

* silence type union error

* use platform defaults

* include everything in builder.routes

* implement ISR

* fix some docs stuff

* clarify multi-region serverless is only for enterprise

* clarify memory stuff

* document ISR

* docs tweaks

* fix site

* add isr in config hash

* bump adapter-auto etc

* bump peerdeps

---------

Co-authored-by: Rich Harris <richard.a.harris@gmail.com>
Co-authored-by: Rich Harris <hello@rich-harris.dev>
  • Loading branch information
3 people committed Feb 6, 2023
1 parent b02d795 commit c7648f6
Show file tree
Hide file tree
Showing 30 changed files with 603 additions and 205 deletions.
5 changes: 5 additions & 0 deletions .changeset/chatty-moles-warn.md
@@ -0,0 +1,5 @@
---
'@sveltejs/kit': minor
---

feat: support route-level configuration
5 changes: 5 additions & 0 deletions .changeset/cuddly-rats-decide.md
@@ -0,0 +1,5 @@
---
'@sveltejs/adapter-vercel': minor
---

feat: support route-level configuration options
8 changes: 8 additions & 0 deletions .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
5 changes: 5 additions & 0 deletions .changeset/perfect-penguins-smash.md
@@ -0,0 +1,5 @@
---
'create-svelte': patch
---

chore: bump `@sveltejs/kit` and `@sveltejs/adapter-auto` versions
48 changes: 48 additions & 0 deletions documentation/docs/20-core-concepts/40-page-options.md
Expand Up @@ -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.
105 changes: 87 additions & 18 deletions documentation/docs/25-build-and-deploy/90-adapter-vercel.md
Expand Up @@ -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=<token> cookie.
//
// Making a `GET` or `HEAD` request with `x-prerender-revalidate: <token>` 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:

Expand All @@ -65,7 +134,7 @@ export function load() {
<p>This staging environment was deployed from {data.deploymentGitBranch}.</p>
```

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

Expand Down
4 changes: 2 additions & 2 deletions packages/adapter-auto/adapters.js
Expand Up @@ -5,7 +5,7 @@ export const adapters = [
name: 'Vercel',
test: () => !!process.env.VERCEL,
module: '@sveltejs/adapter-vercel',
version: '1'
version: '2'
},
{
name: 'Cloudflare Pages',
Expand All @@ -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',
Expand Down
51 changes: 32 additions & 19 deletions packages/adapter-netlify/index.js
Expand Up @@ -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
Expand All @@ -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'
});
Expand Down
2 changes: 1 addition & 1 deletion packages/adapter-netlify/package.json
Expand Up @@ -50,6 +50,6 @@
"uvu": "^0.5.6"
},
"peerDependencies": {
"@sveltejs/kit": "^1.0.0"
"@sveltejs/kit": "^1.5.0"
}
}
22 changes: 3 additions & 19 deletions packages/adapter-static/index.js
Expand Up @@ -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 ${
Expand All @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion packages/adapter-static/package.json
Expand Up @@ -38,6 +38,6 @@
"vite": "^4.0.4"
},
"peerDependencies": {
"@sveltejs/kit": "^1.0.0"
"@sveltejs/kit": "^1.5.0"
}
}
4 changes: 2 additions & 2 deletions packages/adapter-vercel/files/edge.js
Expand Up @@ -3,7 +3,7 @@ import { manifest } from 'MANIFEST';

const server = new Server(manifest);
const initialized = server.init({
env: process.env
env: /** @type {Record<string, string>} */ (process.env)
});

/**
Expand All @@ -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'));
}
});
};
6 changes: 3 additions & 3 deletions packages/adapter-vercel/files/serverless.js
Expand Up @@ -8,7 +8,7 @@ installPolyfills();
const server = new Server(manifest);

await server.init({
env: process.env
env: /** @type {Record<string, string>} */ (process.env)
});

/**
Expand All @@ -22,15 +22,15 @@ 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');
}

setResponse(
res,
await server.respond(request, {
getClientAddress() {
return request.headers.get('x-forwarded-for');
return /** @type {string} */ (request.headers.get('x-forwarded-for'));
}
})
);
Expand Down

0 comments on commit c7648f6

Please sign in to comment.