Skip to content

Commit 9439190

Browse files
authored
breaking: serve public env dynamically, prevent use of dynamic env vars 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 <[email protected]>
1 parent 4dc6aec commit 9439190

File tree

38 files changed

+235
-66
lines changed

38 files changed

+235
-66
lines changed

.changeset/calm-pugs-applaud.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@sveltejs/kit': major
3+
---
4+
5+
breaking: prevent use of dynamic env vars during prerendering, serve env vars dynamically

.changeset/pink-lobsters-protect.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@sveltejs/adapter-static': major
3+
---
4+
5+
breaking: update SvelteKit peer dependency to version 2

documentation/docs/60-appendix/30-migrating-to-sveltekit-2.md

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,14 @@ As such, SvelteKit 2 replaces `resolvePath` with a (slightly better named) funct
105105

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

108+
## Dynamic environment variables cannot be used during prerendering
109+
110+
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`.
111+
112+
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.
113+
114+
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.
115+
108116
## `form` and `data` have been removed from `use:enhance` callbacks
109117

110118
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.
@@ -117,6 +125,16 @@ If a form contains an `<input type="file">` but does not have an `enctype="multi
117125

118126
## Updated dependency requirements
119127

120-
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.
128+
SvelteKit 2 requires Node `18.13` or higher, and the following minimum dependency versions:
129+
130+
- `svelte@4`
131+
- `vite@5`
132+
- `typescript@5`
133+
- `@sveltejs/adapter-static@3` (if you're using it)
134+
- `@sveltejs/vite-plugin-svelte@3` (this is now required as a `peerDependency` of SvelteKit — previously it was directly depended upon)
135+
136+
`svelte-migrate` will update your `package.json` for you.
121137

122138
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).
139+
140+
SvelteKit 2 uses ES2022 features, which are supported in all modern browsers.

packages/adapter-static/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ See https://kit.svelte.dev/docs/page-options#prerender for more details`
6262
builder.rimraf(assets);
6363
builder.rimraf(pages);
6464

65+
builder.generateEnvModule();
6566
builder.writeClient(assets);
6667
builder.writePrerendered(pages);
6768

packages/adapter-static/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,6 @@
4040
"vite": "^5.0.8"
4141
},
4242
"peerDependencies": {
43-
"@sveltejs/kit": "^1.5.0 || ^2.0.0"
43+
"@sveltejs/kit": "^2.0.0"
4444
}
4545
}
Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
<script>
2+
import { browser } from '$app/environment';
3+
import { PUBLIC_ANSWER } from '$env/static/public';
24
import { env } from '$env/dynamic/public';
35
</script>
46

5-
<h1>The answer is {env.PUBLIC_ANSWER}</h1>
7+
<h1>The answer is {PUBLIC_ANSWER}</h1>
8+
9+
{#if browser}
10+
<h2>The dynamic answer is {env.PUBLIC_ANSWER}</h2>
11+
{/if}

packages/adapter-static/test/apps/prerendered/test/test.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,4 +24,5 @@ test('prerenders a referenced endpoint with implicit `prerender` setting', async
2424
test('exposes public env vars to the client', async ({ page }) => {
2525
await page.goto('/public-env');
2626
expect(await page.textContent('h1')).toEqual('The answer is 42');
27+
expect(await page.textContent('h2')).toEqual('The dynamic answer is 42');
2728
});
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
<script>
2-
import { env } from '$env/dynamic/public';
2+
import { PUBLIC_VALUE } from '$env/static/public';
33
</script>
44

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

7-
<b>{env.PUBLIC_VALUE}</b>
7+
<b>{PUBLIC_VALUE}</b>

packages/kit/src/core/adapt/builder.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,13 @@ export function create_builder({
156156
write(dest, fallback);
157157
},
158158

159+
generateEnvModule() {
160+
const dest = `${config.kit.outDir}/output/prerendered/dependencies/${config.kit.appDir}/env.js`;
161+
const env = get_env(config.kit.env, vite_config.mode);
162+
163+
write(dest, `export const env=${JSON.stringify(env.public)}`);
164+
},
165+
159166
generateManifest({ relativePath, routes: subset }) {
160167
return generate_manifest({
161168
build_data,

packages/kit/src/core/postbuild/analyse.js

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,11 @@ async function analyse({ manifest_path, env }) {
4343

4444
// set env, in case it's used in initialisation
4545
const { publicPrefix: public_prefix, privatePrefix: private_prefix } = config.env;
46-
internal.set_private_env(filter_private_env(env, { public_prefix, private_prefix }));
47-
internal.set_public_env(filter_public_env(env, { public_prefix, private_prefix }));
46+
const private_env = filter_private_env(env, { public_prefix, private_prefix });
47+
const public_env = filter_public_env(env, { public_prefix, private_prefix });
48+
internal.set_private_env(private_env);
49+
internal.set_public_env(public_env);
50+
internal.set_safe_public_env(public_env);
4851

4952
/** @type {import('types').ServerMetadata} */
5053
const metadata = {

packages/kit/src/core/postbuild/prerender.js

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,7 @@ async function prerender({ out, manifest_path, metadata, verbose, env }) {
150150
}
151151

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

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

475476
if (not_prerendered.length > 0) {
477+
const list = not_prerendered.map((id) => ` - ${id}`).join('\n');
478+
476479
throw new Error(
477-
`The following routes were marked as prerenderable, but were not prerendered because they were not found while crawling your app:\n${not_prerendered.map(
478-
(id) => ` - ${id}`
479-
)}\n\nSee https://kit.svelte.dev/docs/page-options#prerender-troubleshooting for info on how to solve this`
480+
`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`
480481
);
481482
}
482483

packages/kit/src/core/sync/write_server.js

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,10 @@ const server_template = ({
2828
import root from '../root.${isSvelte5Plus() ? 'js' : 'svelte'}';
2929
import { set_building } from '__sveltekit/environment';
3030
import { set_assets } from '__sveltekit/paths';
31-
import { set_private_env, set_public_env } from '${runtime_directory}/shared-server.js';
31+
import { set_private_env, set_public_env, set_safe_public_env } from '${runtime_directory}/shared-server.js';
3232
3333
export const options = {
34+
app_dir: ${s(config.kit.appDir)},
3435
app_template_contains_nonce: ${template.includes('%sveltekit.nonce%')},
3536
csp: ${s(config.kit.csp)},
3637
csrf_check_origin: ${s(config.kit.csrf.checkOrigin)},
@@ -62,7 +63,7 @@ export function get_hooks() {
6263
return ${hooks ? `import(${s(hooks)})` : '{}'};
6364
}
6465
65-
export { set_assets, set_building, set_private_env, set_public_env };
66+
export { set_assets, set_building, set_private_env, set_public_env, set_safe_public_env };
6667
`;
6768

6869
// TODO need to re-run this whenever src/app.html or src/error.html are

packages/kit/src/exports/public.d.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,11 @@ export interface Builder {
101101
*/
102102
generateFallback(dest: string): Promise<void>;
103103

104+
/**
105+
* Generate a module exposing build-time environment variables as `$env/dynamic/public`.
106+
*/
107+
generateEnvModule(): void;
108+
104109
/**
105110
* Generate a server-side manifest to initialise the SvelteKit [server](https://kit.svelte.dev/docs/types#public-types-server) with.
106111
* @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
@@ -284,7 +289,9 @@ export interface KitConfig {
284289
*/
285290
alias?: Record<string, string>;
286291
/**
287-
* 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 `/`.
292+
* The directory where SvelteKit keeps its stuff, including static assets (such as JS and CSS) and internally-used routes.
293+
*
294+
* If `paths.assets` is specified, there will be two app directories — `${paths.assets}/${appDir}` and `${paths.base}/${appDir}`.
288295
* @default "_app"
289296
*/
290297
appDir?: string;

packages/kit/src/exports/vite/dev/index.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,8 @@ export async function dev(vite, vite_config, svelte_config) {
120120
app: `${to_fs(svelte_config.kit.outDir)}/generated/client/app.js`,
121121
imports: [],
122122
stylesheets: [],
123-
fonts: []
123+
fonts: [],
124+
uses_env_dynamic_public: true
124125
},
125126
nodes: manifest_data.nodes.map((node, index) => {
126127
return async () => {

packages/kit/src/exports/vite/graph_analysis/index.js

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,9 @@
11
import path from 'node:path';
22
import { posixify } from '../../../utils/filesystem.js';
33
import { strip_virtual_prefix } from '../utils.js';
4+
import { env_dynamic_private, env_static_private } from '../module_ids.js';
45

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

119
/**

packages/kit/src/exports/vite/index.js

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,15 @@ import { s } from '../../utils/misc.js';
2727
import { hash } from '../../runtime/hash.js';
2828
import { dedent, isSvelte5Plus } from '../../core/sync/utils.js';
2929
import sirv from 'sirv';
30+
import {
31+
env_dynamic_private,
32+
env_dynamic_public,
33+
env_static_private,
34+
env_static_public,
35+
service_worker,
36+
sveltekit_environment,
37+
sveltekit_paths
38+
} from './module_ids.js';
3039

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

@@ -365,35 +374,35 @@ function kit({ svelte_config }) {
365374
}
366375

367376
switch (id) {
368-
case '\0virtual:$env/static/private':
377+
case env_static_private:
369378
return create_static_module('$env/static/private', env.private);
370379

371-
case '\0virtual:$env/static/public':
380+
case env_static_public:
372381
return create_static_module('$env/static/public', env.public);
373382

374-
case '\0virtual:$env/dynamic/private':
383+
case env_dynamic_private:
375384
return create_dynamic_module(
376385
'private',
377386
vite_config_env.command === 'serve' ? env.private : undefined
378387
);
379388

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

386395
return create_dynamic_module(
387396
'public',
388397
vite_config_env.command === 'serve' ? env.public : undefined
389398
);
390399

391-
case '\0virtual:$service-worker':
400+
case service_worker:
392401
return create_service_worker_module(svelte_config);
393402

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

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

434-
case '\0virtual:__sveltekit/environment': {
443+
case sveltekit_environment: {
435444
const { version } = svelte_config.kit;
436445

437446
return dedent`
@@ -572,7 +581,7 @@ function kit({ svelte_config }) {
572581
preserveEntrySignatures: 'strict'
573582
},
574583
ssrEmitAssets: true,
575-
target: ssr ? 'node16.14' : undefined
584+
target: ssr ? 'node18.13' : 'es2022'
576585
},
577586
publicDir: kit.files.assets,
578587
worker: {
@@ -765,7 +774,10 @@ function kit({ svelte_config }) {
765774
app: app.file,
766775
imports: [...start.imports, ...app.imports],
767776
stylesheets: [...start.stylesheets, ...app.stylesheets],
768-
fonts: [...start.fonts, ...app.fonts]
777+
fonts: [...start.fonts, ...app.fonts],
778+
uses_env_dynamic_public: output.some(
779+
(chunk) => chunk.type === 'chunk' && chunk.modules[env_dynamic_public]
780+
)
769781
};
770782

771783
const css = output.filter(
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
export const env_static_private = '\0virtual:$env/static/private';
2+
export const env_static_public = '\0virtual:$env/static/public';
3+
export const env_dynamic_private = '\0virtual:$env/dynamic/private';
4+
export const env_dynamic_public = '\0virtual:$env/dynamic/public';
5+
export const service_worker = '\0virtual:$service-worker';
6+
export const sveltekit_paths = '\0virtual:__sveltekit/paths';
7+
export const sveltekit_environment = '\0virtual:__sveltekit/environment';
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { public_env } from '../shared-server.js';
2+
3+
/** @type {string} */
4+
let body;
5+
6+
/** @type {string} */
7+
let etag;
8+
9+
/** @type {Headers} */
10+
let headers;
11+
12+
/**
13+
* @param {Request} request
14+
* @returns {Response}
15+
*/
16+
export function get_public_env(request) {
17+
body ??= `export const env=${JSON.stringify(public_env)}`;
18+
etag ??= `W/${Date.now()}`;
19+
headers ??= new Headers({
20+
'content-type': 'application/javascript; charset=utf-8',
21+
etag
22+
});
23+
24+
if (request.headers.get('if-none-match') === etag) {
25+
return new Response(undefined, { status: 304, headers });
26+
}
27+
28+
return new Response(body, { headers });
29+
}

packages/kit/src/runtime/server/index.js

Lines changed: 23 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,18 @@
11
import { respond } from './respond.js';
2-
import { set_private_env, set_public_env } from '../shared-server.js';
2+
import { set_private_env, set_public_env, set_safe_public_env } from '../shared-server.js';
33
import { options, get_hooks } from '__SERVER__/internal.js';
44
import { DEV } from 'esm-env';
55
import { filter_private_env, filter_public_env } from '../../utils/env.js';
6+
import { building } from '../app/environment.js';
7+
8+
/** @type {ProxyHandler<{ type: 'public' | 'private' }>} */
9+
const prerender_env_handler = {
10+
get({ type }, prop) {
11+
throw new Error(
12+
`Cannot read values from $env/dynamic/${type} while prerendering (attempted to read env.${prop.toString()}). Use $env/static/${type} instead`
13+
);
14+
}
15+
};
616

717
export class Server {
818
/** @type {import('types').SSROptions} */
@@ -27,19 +37,19 @@ export class Server {
2737
// Take care: Some adapters may have to call `Server.init` per-request to set env vars,
2838
// so anything that shouldn't be rerun should be wrapped in an `if` block to make sure it hasn't
2939
// been done already.
40+
3041
// set env, in case it's used in initialisation
31-
set_private_env(
32-
filter_private_env(env, {
33-
public_prefix: this.#options.env_public_prefix,
34-
private_prefix: this.#options.env_private_prefix
35-
})
36-
);
37-
set_public_env(
38-
filter_public_env(env, {
39-
public_prefix: this.#options.env_public_prefix,
40-
private_prefix: this.#options.env_private_prefix
41-
})
42-
);
42+
const prefixes = {
43+
public_prefix: this.#options.env_public_prefix,
44+
private_prefix: this.#options.env_private_prefix
45+
};
46+
47+
const private_env = filter_private_env(env, prefixes);
48+
const public_env = filter_public_env(env, prefixes);
49+
50+
set_private_env(building ? new Proxy({ type: 'private' }, prerender_env_handler) : private_env);
51+
set_public_env(building ? new Proxy({ type: 'public' }, prerender_env_handler) : public_env);
52+
set_safe_public_env(public_env);
4353

4454
if (!this.#options.hooks) {
4555
try {

0 commit comments

Comments
 (0)