Skip to content

Commit

Permalink
breaking: serve public env dynamically, prevent use of dynamic env va…
Browse files Browse the repository at this point in the history
…rs during prerendering (#11277)

* create _env.js dynamic module

* tidy up

* tidy up

* allow %sveltekit.env.PUBLIC_FOO% to bypass proxy

* add config option

* update test

* docs

* apply same restriction to dynamic/private

* separate comments

* drive-by: partially fix message

* tweak

* update test

* add test

* migration notes

* preload _env.js when appropriate

* fix adapter-static tests

* expose generateEnvModule method for adapter-static

* check

* windows

* wow i really hate windows

* bump adapter-static peer dependency

* remove obsolete comment

* changeset

* doh

* bump adapter-static as part of migration

* update migration docs

* reuse appDir instead of polluting everything

* regenerate types

---------

Co-authored-by: Rich Harris <rich.harris@vercel.com>
  • Loading branch information
Rich-Harris and Rich-Harris committed Dec 13, 2023
1 parent 4dc6aec commit 9439190
Show file tree
Hide file tree
Showing 38 changed files with 235 additions and 66 deletions.
5 changes: 5 additions & 0 deletions .changeset/calm-pugs-applaud.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@sveltejs/kit': major
---

breaking: prevent use of dynamic env vars during prerendering, serve env vars dynamically
5 changes: 5 additions & 0 deletions .changeset/pink-lobsters-protect.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@sveltejs/adapter-static': major
---

breaking: update SvelteKit peer dependency to version 2
20 changes: 19 additions & 1 deletion documentation/docs/60-appendix/30-migrating-to-sveltekit-2.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,14 @@ As such, SvelteKit 2 replaces `resolvePath` with a (slightly better named) funct

`svelte-migrate` will do the method replacement for you, though if you later prepend the result with `base`, you need to remove that yourself.

## Dynamic environment variables cannot be used during prerendering

The `$env/dynamic/public` and `$env/dynamic/private` modules provide access to _run time_ environment variables, as opposed to the _build time_ environment variables exposed by `$env/static/public` and `$env/static/private`.

During prerendering in SvelteKit 1, they are one and the same. As such, prerendered pages that make use of 'dynamic' environment variables are really 'baking in' build time values, which is incorrect. Worse, `$env/dynamic/public` is populated in the browser with these stale values if the user happens to land on a prerendered page before navigating to dynamically-rendered pages.

Because of this, dynamic environment variables can no longer be read during prerendering in SvelteKit 2 — you should use the `static` modules instead. If the user lands on a prerendered page, SvelteKit will request up-to-date values for `$env/dynamic/public` from the server (by default from a module called `_env.js` — this can be configured with `config.kit.env.publicModule`) instead of reading them from the server-rendered HTML.

## `form` and `data` have been removed from `use:enhance` callbacks

If you provide a callback to [`use:enhance`](/docs/form-actions#progressive-enhancement-use-enhance), it will be called with an object containing various useful properties.
Expand All @@ -117,6 +125,16 @@ If a form contains an `<input type="file">` but does not have an `enctype="multi

## Updated dependency requirements

SvelteKit requires Node `18.13` or higher, Vite `^5.0`, vite-plugin-svelte `^3.0`, TypeScript `^5.0` and Svelte version 4 or higher. `svelte-migrate` will do the `package.json` bumps for you.
SvelteKit 2 requires Node `18.13` or higher, and the following minimum dependency versions:

- `svelte@4`
- `vite@5`
- `typescript@5`
- `@sveltejs/adapter-static@3` (if you're using it)
- `@sveltejs/vite-plugin-svelte@3` (this is now required as a `peerDependency` of SvelteKit — previously it was directly depended upon)

`svelte-migrate` will update your `package.json` for you.

As part of the TypeScript upgrade, the generated `tsconfig.json` (the one your `tsconfig.json` extends from) now uses `"moduleResolution": "bundler"` (which is recommended by the TypeScript team, as it properly resolves types from packages with an `exports` map in package.json) and `verbatimModuleSyntax` (which replaces the existing `importsNotUsedAsValues ` and `preserveValueImports` flags — if you have those in your `tsconfig.json`, remove them. `svelte-migrate` will do this for you).

SvelteKit 2 uses ES2022 features, which are supported in all modern browsers.
1 change: 1 addition & 0 deletions packages/adapter-static/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ See https://kit.svelte.dev/docs/page-options#prerender for more details`
builder.rimraf(assets);
builder.rimraf(pages);

builder.generateEnvModule();
builder.writeClient(assets);
builder.writePrerendered(pages);

Expand Down
2 changes: 1 addition & 1 deletion packages/adapter-static/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,6 @@
"vite": "^5.0.8"
},
"peerDependencies": {
"@sveltejs/kit": "^1.5.0 || ^2.0.0"
"@sveltejs/kit": "^2.0.0"
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
<script>
import { browser } from '$app/environment';
import { PUBLIC_ANSWER } from '$env/static/public';
import { env } from '$env/dynamic/public';
</script>

<h1>The answer is {env.PUBLIC_ANSWER}</h1>
<h1>The answer is {PUBLIC_ANSWER}</h1>

{#if browser}
<h2>The dynamic answer is {env.PUBLIC_ANSWER}</h2>
{/if}
1 change: 1 addition & 0 deletions packages/adapter-static/test/apps/prerendered/test/test.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,5 @@ test('prerenders a referenced endpoint with implicit `prerender` setting', async
test('exposes public env vars to the client', async ({ page }) => {
await page.goto('/public-env');
expect(await page.textContent('h1')).toEqual('The answer is 42');
expect(await page.textContent('h2')).toEqual('The dynamic answer is 42');
});
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<script>
import { env } from '$env/dynamic/public';
import { PUBLIC_VALUE } from '$env/static/public';
</script>

<h1>the fallback page was rendered</h1>

<b>{env.PUBLIC_VALUE}</b>
<b>{PUBLIC_VALUE}</b>
7 changes: 7 additions & 0 deletions packages/kit/src/core/adapt/builder.js
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,13 @@ export function create_builder({
write(dest, fallback);
},

generateEnvModule() {
const dest = `${config.kit.outDir}/output/prerendered/dependencies/${config.kit.appDir}/env.js`;
const env = get_env(config.kit.env, vite_config.mode);

write(dest, `export const env=${JSON.stringify(env.public)}`);
},

generateManifest({ relativePath, routes: subset }) {
return generate_manifest({
build_data,
Expand Down
7 changes: 5 additions & 2 deletions packages/kit/src/core/postbuild/analyse.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,11 @@ async function analyse({ manifest_path, env }) {

// set env, in case it's used in initialisation
const { publicPrefix: public_prefix, privatePrefix: private_prefix } = config.env;
internal.set_private_env(filter_private_env(env, { public_prefix, private_prefix }));
internal.set_public_env(filter_public_env(env, { public_prefix, private_prefix }));
const private_env = filter_private_env(env, { public_prefix, private_prefix });
const public_env = filter_public_env(env, { public_prefix, private_prefix });
internal.set_private_env(private_env);
internal.set_public_env(public_env);
internal.set_safe_public_env(public_env);

/** @type {import('types').ServerMetadata} */
const metadata = {
Expand Down
7 changes: 4 additions & 3 deletions packages/kit/src/core/postbuild/prerender.js
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ async function prerender({ out, manifest_path, metadata, verbose, env }) {
}

const files = new Set(walk(`${out}/client`).map(posixify));
files.add(`${config.appDir}/env.js`);

const immutable = `${config.appDir}/immutable`;
if (existsSync(`${out}/server/${immutable}`)) {
Expand Down Expand Up @@ -473,10 +474,10 @@ async function prerender({ out, manifest_path, metadata, verbose, env }) {
}

if (not_prerendered.length > 0) {
const list = not_prerendered.map((id) => ` - ${id}`).join('\n');

throw new Error(
`The following routes were marked as prerenderable, but were not prerendered because they were not found while crawling your app:\n${not_prerendered.map(
(id) => ` - ${id}`
)}\n\nSee https://kit.svelte.dev/docs/page-options#prerender-troubleshooting for info on how to solve this`
`The following routes were marked as prerenderable, but were not prerendered because they were not found while crawling your app:\n${list}\n\nSee https://kit.svelte.dev/docs/page-options#prerender-troubleshooting for info on how to solve this`
);
}

Expand Down
5 changes: 3 additions & 2 deletions packages/kit/src/core/sync/write_server.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,10 @@ const server_template = ({
import root from '../root.${isSvelte5Plus() ? 'js' : 'svelte'}';
import { set_building } from '__sveltekit/environment';
import { set_assets } from '__sveltekit/paths';
import { set_private_env, set_public_env } from '${runtime_directory}/shared-server.js';
import { set_private_env, set_public_env, set_safe_public_env } from '${runtime_directory}/shared-server.js';
export const options = {
app_dir: ${s(config.kit.appDir)},
app_template_contains_nonce: ${template.includes('%sveltekit.nonce%')},
csp: ${s(config.kit.csp)},
csrf_check_origin: ${s(config.kit.csrf.checkOrigin)},
Expand Down Expand Up @@ -62,7 +63,7 @@ export function get_hooks() {
return ${hooks ? `import(${s(hooks)})` : '{}'};
}
export { set_assets, set_building, set_private_env, set_public_env };
export { set_assets, set_building, set_private_env, set_public_env, set_safe_public_env };
`;

// TODO need to re-run this whenever src/app.html or src/error.html are
Expand Down
9 changes: 8 additions & 1 deletion packages/kit/src/exports/public.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,11 @@ export interface Builder {
*/
generateFallback(dest: string): Promise<void>;

/**
* Generate a module exposing build-time environment variables as `$env/dynamic/public`.
*/
generateEnvModule(): void;

/**
* 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
Expand Down Expand Up @@ -284,7 +289,9 @@ export interface KitConfig {
*/
alias?: Record<string, string>;
/**
* The directory relative to `paths.assets` where the built JS and CSS (and imported assets) are served from. (The filenames therein contain content-based hashes, meaning they can be cached indefinitely). Must not start or end with `/`.
* The directory where SvelteKit keeps its stuff, including static assets (such as JS and CSS) and internally-used routes.
*
* If `paths.assets` is specified, there will be two app directories — `${paths.assets}/${appDir}` and `${paths.base}/${appDir}`.
* @default "_app"
*/
appDir?: string;
Expand Down
3 changes: 2 additions & 1 deletion packages/kit/src/exports/vite/dev/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,8 @@ export async function dev(vite, vite_config, svelte_config) {
app: `${to_fs(svelte_config.kit.outDir)}/generated/client/app.js`,
imports: [],
stylesheets: [],
fonts: []
fonts: [],
uses_env_dynamic_public: true
},
nodes: manifest_data.nodes.map((node, index) => {
return async () => {
Expand Down
6 changes: 2 additions & 4 deletions packages/kit/src/exports/vite/graph_analysis/index.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,9 @@
import path from 'node:path';
import { posixify } from '../../../utils/filesystem.js';
import { strip_virtual_prefix } from '../utils.js';
import { env_dynamic_private, env_static_private } from '../module_ids.js';

const ILLEGAL_IMPORTS = new Set([
'\0virtual:$env/dynamic/private',
'\0virtual:$env/static/private'
]);
const ILLEGAL_IMPORTS = new Set([env_dynamic_private, env_static_private]);
const ILLEGAL_MODULE_NAME_PATTERN = /.*\.server\..+/;

/**
Expand Down
32 changes: 22 additions & 10 deletions packages/kit/src/exports/vite/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,15 @@ import { s } from '../../utils/misc.js';
import { hash } from '../../runtime/hash.js';
import { dedent, isSvelte5Plus } from '../../core/sync/utils.js';
import sirv from 'sirv';
import {
env_dynamic_private,
env_dynamic_public,
env_static_private,
env_static_public,
service_worker,
sveltekit_environment,
sveltekit_paths
} from './module_ids.js';

export { vitePreprocess } from '@sveltejs/vite-plugin-svelte';

Expand Down Expand Up @@ -365,35 +374,35 @@ function kit({ svelte_config }) {
}

switch (id) {
case '\0virtual:$env/static/private':
case env_static_private:
return create_static_module('$env/static/private', env.private);

case '\0virtual:$env/static/public':
case env_static_public:
return create_static_module('$env/static/public', env.public);

case '\0virtual:$env/dynamic/private':
case env_dynamic_private:
return create_dynamic_module(
'private',
vite_config_env.command === 'serve' ? env.private : undefined
);

case '\0virtual:$env/dynamic/public':
case env_dynamic_public:
// populate `$env/dynamic/public` from `window`
if (browser) {
return `export const env = ${global}.env;`;
return `export const env = ${global}.env ?? (await import(/* @vite-ignore */ ${global}.base + '/' + '${kit.appDir}/env.js')).env;`;
}

return create_dynamic_module(
'public',
vite_config_env.command === 'serve' ? env.public : undefined
);

case '\0virtual:$service-worker':
case service_worker:
return create_service_worker_module(svelte_config);

// for internal use only. it's published as $app/paths externally
// we use this alias so that we won't collide with user aliases
case '\0virtual:__sveltekit/paths': {
case sveltekit_paths: {
const { assets, base } = svelte_config.kit.paths;

// use the values defined in `global`, but fall back to hard-coded values
Expand Down Expand Up @@ -431,7 +440,7 @@ function kit({ svelte_config }) {
`;
}

case '\0virtual:__sveltekit/environment': {
case sveltekit_environment: {
const { version } = svelte_config.kit;

return dedent`
Expand Down Expand Up @@ -572,7 +581,7 @@ function kit({ svelte_config }) {
preserveEntrySignatures: 'strict'
},
ssrEmitAssets: true,
target: ssr ? 'node16.14' : undefined
target: ssr ? 'node18.13' : 'es2022'
},
publicDir: kit.files.assets,
worker: {
Expand Down Expand Up @@ -765,7 +774,10 @@ function kit({ svelte_config }) {
app: app.file,
imports: [...start.imports, ...app.imports],
stylesheets: [...start.stylesheets, ...app.stylesheets],
fonts: [...start.fonts, ...app.fonts]
fonts: [...start.fonts, ...app.fonts],
uses_env_dynamic_public: output.some(
(chunk) => chunk.type === 'chunk' && chunk.modules[env_dynamic_public]
)
};

const css = output.filter(
Expand Down
7 changes: 7 additions & 0 deletions packages/kit/src/exports/vite/module_ids.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export const env_static_private = '\0virtual:$env/static/private';
export const env_static_public = '\0virtual:$env/static/public';
export const env_dynamic_private = '\0virtual:$env/dynamic/private';
export const env_dynamic_public = '\0virtual:$env/dynamic/public';
export const service_worker = '\0virtual:$service-worker';
export const sveltekit_paths = '\0virtual:__sveltekit/paths';
export const sveltekit_environment = '\0virtual:__sveltekit/environment';
29 changes: 29 additions & 0 deletions packages/kit/src/runtime/server/env_module.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { public_env } from '../shared-server.js';

/** @type {string} */
let body;

/** @type {string} */
let etag;

/** @type {Headers} */
let headers;

/**
* @param {Request} request
* @returns {Response}
*/
export function get_public_env(request) {
body ??= `export const env=${JSON.stringify(public_env)}`;
etag ??= `W/${Date.now()}`;
headers ??= new Headers({
'content-type': 'application/javascript; charset=utf-8',
etag
});

if (request.headers.get('if-none-match') === etag) {
return new Response(undefined, { status: 304, headers });
}

return new Response(body, { headers });
}
36 changes: 23 additions & 13 deletions packages/kit/src/runtime/server/index.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,18 @@
import { respond } from './respond.js';
import { set_private_env, set_public_env } from '../shared-server.js';
import { set_private_env, set_public_env, set_safe_public_env } from '../shared-server.js';
import { options, get_hooks } from '__SERVER__/internal.js';
import { DEV } from 'esm-env';
import { filter_private_env, filter_public_env } from '../../utils/env.js';
import { building } from '../app/environment.js';

/** @type {ProxyHandler<{ type: 'public' | 'private' }>} */
const prerender_env_handler = {
get({ type }, prop) {
throw new Error(
`Cannot read values from $env/dynamic/${type} while prerendering (attempted to read env.${prop.toString()}). Use $env/static/${type} instead`
);
}
};

export class Server {
/** @type {import('types').SSROptions} */
Expand All @@ -27,19 +37,19 @@ export class Server {
// Take care: Some adapters may have to call `Server.init` per-request to set env vars,
// so anything that shouldn't be rerun should be wrapped in an `if` block to make sure it hasn't
// been done already.

// set env, in case it's used in initialisation
set_private_env(
filter_private_env(env, {
public_prefix: this.#options.env_public_prefix,
private_prefix: this.#options.env_private_prefix
})
);
set_public_env(
filter_public_env(env, {
public_prefix: this.#options.env_public_prefix,
private_prefix: this.#options.env_private_prefix
})
);
const prefixes = {
public_prefix: this.#options.env_public_prefix,
private_prefix: this.#options.env_private_prefix
};

const private_env = filter_private_env(env, prefixes);
const public_env = filter_public_env(env, prefixes);

set_private_env(building ? new Proxy({ type: 'private' }, prerender_env_handler) : private_env);
set_public_env(building ? new Proxy({ type: 'public' }, prerender_env_handler) : public_env);
set_safe_public_env(public_env);

if (!this.#options.hooks) {
try {
Expand Down

0 comments on commit 9439190

Please sign in to comment.