diff --git a/.changeset/polite-bulldogs-swim.md b/.changeset/polite-bulldogs-swim.md new file mode 100644 index 000000000000..b0255f34839e --- /dev/null +++ b/.changeset/polite-bulldogs-swim.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/kit': minor +--- + +feat: allow running client-side code at the top-level of universal pages/layouts when SSR is disabled diff --git a/packages/kit/package.json b/packages/kit/package.json index dae9833a5f72..e529db409f66 100644 --- a/packages/kit/package.json +++ b/packages/kit/package.json @@ -18,7 +18,9 @@ "homepage": "https://svelte.dev", "type": "module", "dependencies": { + "@sveltejs/acorn-typescript": "^1.0.5", "@types/cookie": "^0.6.0", + "acorn": "^8.14.1", "cookie": "^0.6.0", "devalue": "^5.1.0", "esm-env": "^1.2.2", diff --git a/packages/kit/src/core/postbuild/analyse.js b/packages/kit/src/core/postbuild/analyse.js index 25bd403f1eb8..349df4f9b1b6 100644 --- a/packages/kit/src/core/postbuild/analyse.js +++ b/packages/kit/src/core/postbuild/analyse.js @@ -89,10 +89,10 @@ async function analyse({ for (const route of manifest._.routes) { const page = route.page && - analyse_page( + (await analyse_page( route.page.layouts.map((n) => (n === undefined ? n : nodes[n])), nodes[route.page.leaf] - ); + )); const endpoint = route.endpoint && analyse_endpoint(route, await route.endpoint()); @@ -182,7 +182,7 @@ function analyse_endpoint(route, mod) { * @param {Array} layouts * @param {import('types').SSRNode} leaf */ -function analyse_page(layouts, leaf) { +async function analyse_page(layouts, leaf) { /** @type {Array<'GET' | 'POST'>} */ const methods = ['GET']; if (leaf.server?.actions) methods.push('POST'); @@ -191,10 +191,10 @@ function analyse_page(layouts, leaf) { nodes.validate(); return { - config: nodes.get_config(), - entries: leaf.universal?.entries ?? leaf.server?.entries, + config: await nodes.get_config(), + entries: (await leaf.universal?.entries) ?? leaf.server?.entries, methods, - prerender: nodes.prerender() + prerender: await nodes.prerender() }; } diff --git a/packages/kit/src/exports/vite/build/build_server.js b/packages/kit/src/exports/vite/build/build_server.js index ca3eedc18cb3..2c43f97fe960 100644 --- a/packages/kit/src/exports/vite/build/build_server.js +++ b/packages/kit/src/exports/vite/build/build_server.js @@ -4,6 +4,8 @@ import { filter_fonts, find_deps, resolve_symlinks } from './utils.js'; import { s } from '../../../utils/misc.js'; import { normalizePath } from 'vite'; import { basename } from 'node:path'; +import { statically_analyse_exports } from '../utils.js'; +import { dedent } from '../../../core/sync/utils.js'; /** * @param {string} out @@ -101,12 +103,38 @@ export function build_server_nodes(out, kit, manifest_data, server_manifest, cli } if (node.universal) { - imports.push( - `import * as universal from '../${ - resolve_symlinks(server_manifest, node.universal).chunk.file - }';` - ); - exports.push('export { universal };'); + const universal_file = resolve_symlinks(server_manifest, node.universal).chunk.file; + const mod = statically_analyse_exports(node.universal); + if (mod) { + exports.push(`const universal_dynamic_exports = new Set(${s(Array.from(mod.dynamic_exports))});`, + 'let universal_cache;', + dedent` + export const universal = new Proxy(${s(Object.fromEntries(mod.static_exports))}, { + async get(target, prop) { + const key = String(prop); + if (universal_dynamic_exports.has(key)) { + try { + return (universal_cache ??= await import('../${universal_file}'))[key]; + } catch (error) { + console.error(\`${node.universal} was loaded because the value of the \\\`\${key}\\\` export could not be statically analysed\`); + throw error; + } + } + return target[key]; + }, + has(target, prop) { + return prop in target || universal_dynamic_exports.has(String(prop)); + }, + ownKeys(target) { + return [...Reflect.ownKeys(target), ...universal_dynamic_exports]; + } + }); + `); + } else { + // TODO: once we can use top-level await on the server we can log why the module was loaded when the import fails + imports.push(`import * as universal from '../${universal_file}';`); + exports.push('export { universal };'); + } exports.push(`export const universal_id = ${s(node.universal)};`); } diff --git a/packages/kit/src/exports/vite/dev/index.js b/packages/kit/src/exports/vite/dev/index.js index ded4685f527c..f3a432f28341 100644 --- a/packages/kit/src/exports/vite/dev/index.js +++ b/packages/kit/src/exports/vite/dev/index.js @@ -15,7 +15,7 @@ import { SVELTE_KIT_ASSETS } from '../../../constants.js'; import * as sync from '../../../core/sync/sync.js'; import { get_mime_lookup, runtime_base } from '../../../core/utils.js'; import { compact } from '../../../utils/array.js'; -import { not_found } from '../utils.js'; +import { not_found, statically_analyse_exports } from '../utils.js'; import { SCHEME } from '../../../utils/url.js'; import { check_feature } from '../../../utils/features.js'; import { escape_html } from '../../../utils/escape.js'; @@ -202,9 +202,58 @@ export async function dev(vite, vite_config, svelte_config) { } if (node.universal) { - const { module, module_node } = await resolve(node.universal); - module_nodes.push(module_node); - result.universal = module; + const mod = statically_analyse_exports(node.universal); + + /** @type {{ module: Record; module_node: import('vite').ModuleNode; }} */ + let resolved; + const load_universal_module = async () => { + if (resolved) return resolved.module; + resolved = await resolve(/** @type {string} */ (node.universal)); + module_nodes.push(resolved.module_node); + return resolved.module; + }; + + if (mod) { + result.universal = new Proxy(Object.fromEntries(mod.static_exports), { + async get(target, prop) { + const key = String(prop); + if (mod.dynamic_exports.has(key)) { + try { + return (await load_universal_module())[key]; + } catch (error) { + console.error( + colors + .bold() + .red( + `${node.universal} was loaded because the value of the \`${key}\` export could not be statically analysed` + ) + ); + throw error; + } + } + return target[key]; + }, + has(target, prop) { + return prop in target || mod.dynamic_exports.has(String(prop)); + }, + ownKeys(target) { + return [...Reflect.ownKeys(target), ...mod.dynamic_exports]; + } + }); + } else { + try { + result.universal = await load_universal_module(); + } catch (error) { + console.error( + colors + .bold() + .red( + `${node.universal} was loaded because it re-exports all named exports from another module` + ) + ); + throw error; + } + } } if (node.server) { diff --git a/packages/kit/src/exports/vite/utils.js b/packages/kit/src/exports/vite/utils.js index 02916e4d85c5..ff37710c6cfc 100644 --- a/packages/kit/src/exports/vite/utils.js +++ b/packages/kit/src/exports/vite/utils.js @@ -1,6 +1,8 @@ import path from 'node:path'; +import { tsPlugin } from '@sveltejs/acorn-typescript'; +import { Parser } from 'acorn'; import { loadEnv } from 'vite'; -import { posixify } from '../../utils/filesystem.js'; +import { posixify, read } from '../../utils/filesystem.js'; import { negotiate } from '../../utils/http.js'; import { filter_private_env, filter_public_env } from '../../utils/env.js'; import { escape_html } from '../../utils/escape.js'; @@ -156,3 +158,99 @@ export function normalize_id(id, lib, cwd) { } export const strip_virtual_prefix = /** @param {string} id */ (id) => id.replace('\0virtual:', ''); + +const parser = Parser.extend(tsPlugin()); + +/** + * Collect all exports from a +page.js/+layout.js file. + * Returns `null` if we bail out from static analysis. E.g., it re-exports all + * named exports from another module and we can't be bothered to go down that rabbit hole. + * @param {string} node_path + */ +export function statically_analyse_exports(node_path) { + const input = read(node_path); + + const node = parser.parse(input, { + sourceType: 'module', + ecmaVersion: 'latest', + locations: true + }); + + /** @type {Map} */ + const static_exports = new Map(); + /** @type {Set} */ + const dynamic_exports = new Set(); + + /** + * @param {import('acorn').Pattern | null} node + */ + const examine = (node) => { + if (!node) return; + + if (node.type === 'Identifier') { + dynamic_exports.add(node.name); + } else if (node.type === 'ArrayPattern') { + node.elements.forEach(examine); + } else if (node.type === 'ObjectPattern') { + node.properties.forEach((property) => { + if (property.type === 'Property') { + examine(property.value); + } else { + examine(property.argument); + } + }); + } + }; + + for (const statement of node.body) { + if (statement.type === 'ExportDefaultDeclaration') { + dynamic_exports.add('default'); + continue; + } else if (statement.type === 'ExportAllDeclaration') { + return null; + } else if (statement.type !== 'ExportNamedDeclaration') { + continue; + } + + // TODO: handle exports referencing constants in the same file? + + // export specifiers + if (statement.specifiers.length) { + for (const specifier of statement.specifiers) { + if (specifier.exported.type === 'Identifier') { + dynamic_exports.add(specifier.exported.name); + } else if (typeof specifier.exported.value === 'string') { + dynamic_exports.add(specifier.exported.value); + } + } + continue; + } + + if (!statement.declaration) { + continue; + } + + // exported classes and functions + if (statement.declaration.type !== 'VariableDeclaration') { + dynamic_exports.add(statement.declaration.id.name); + continue; + } + + for (const declaration of statement.declaration.declarations) { + if (declaration.id.type === 'Identifier') { + if (statement.declaration.kind === 'const' && declaration.init?.type === 'Literal') { + static_exports.set(declaration.id.name, declaration.init.value); + } else { + dynamic_exports.add(declaration.id.name); + } + } else { + examine(declaration.id); + } + } + } + + return { + static_exports, + dynamic_exports + }; +} diff --git a/packages/kit/src/runtime/server/page/index.js b/packages/kit/src/runtime/server/page/index.js index e7b462a74dda..5e38836c6527 100644 --- a/packages/kit/src/runtime/server/page/index.js +++ b/packages/kit/src/runtime/server/page/index.js @@ -71,7 +71,7 @@ export async function render_page(event, page, options, manifest, state, nodes, // it's crucial that we do this before returning the non-SSR response, otherwise // SvelteKit will erroneously believe that the path has been prerendered, // causing functions to be omitted from the manifest generated later - const should_prerender = nodes.prerender(); + const should_prerender = await nodes.prerender(); if (should_prerender) { const mod = leaf_node.server; if (mod?.actions) { @@ -94,8 +94,8 @@ export async function render_page(event, page, options, manifest, state, nodes, /** @type {import('./types.js').Fetched[]} */ const fetched = []; - const ssr = nodes.ssr(); - const csr = nodes.csr(); + const ssr = await nodes.ssr(); + const csr = await nodes.csr(); // renders an empty 'shell' page if SSR is turned off and if there is // no server data to prerender. As a result, the load functions and rendering diff --git a/packages/kit/src/runtime/server/page/load_data.js b/packages/kit/src/runtime/server/page/load_data.js index 2ada2b6ecde6..5746f065a068 100644 --- a/packages/kit/src/runtime/server/page/load_data.js +++ b/packages/kit/src/runtime/server/page/load_data.js @@ -29,6 +29,7 @@ export async function load_server_data({ event, state, node, parent }) { }; const load = node.server.load; + // TODO: shouldn't this be calculated using PageNodes? there could be a trailingSlash option on a layout const slash = node.server.trailingSlash; if (!load) { @@ -196,11 +197,12 @@ export async function load_data({ }) { const server_data_node = await server_data_promise; - if (!node?.universal?.load) { + const universal_load = await node?.universal?.load; + if (!node || !universal_load) { return server_data_node?.data ?? null; } - const result = await node.universal.load.call(null, { + const result = await universal_load.call(null, { url: event.url, params: event.params, data: server_data_node?.data ?? null, diff --git a/packages/kit/src/runtime/server/page/respond_with_error.js b/packages/kit/src/runtime/server/page/respond_with_error.js index f29e91329183..d4c96e9eafbb 100644 --- a/packages/kit/src/runtime/server/page/respond_with_error.js +++ b/packages/kit/src/runtime/server/page/respond_with_error.js @@ -41,8 +41,8 @@ export async function respond_with_error({ const branch = []; const default_layout = await manifest._.nodes[0](); // 0 is always the root layout const nodes = new PageNodes([default_layout]); - const ssr = nodes.ssr(); - const csr = nodes.csr(); + const ssr = await nodes.ssr(); + const csr = await nodes.csr(); if (ssr) { state.error = true; diff --git a/packages/kit/src/runtime/server/respond.js b/packages/kit/src/runtime/server/respond.js index 81b30e0756a5..a58d6a232ff5 100644 --- a/packages/kit/src/runtime/server/respond.js +++ b/packages/kit/src/runtime/server/respond.js @@ -288,7 +288,7 @@ export async function respond(request, options, manifest, state) { let trailing_slash = 'never'; try { - /** @type {PageNodes|undefined} */ + /** @type {PageNodes | undefined} */ const page_nodes = route?.page ? new PageNodes(await load_page_nodes(route.page, manifest)) : undefined; @@ -303,7 +303,7 @@ export async function respond(request, options, manifest, state) { if (DEV) { page_nodes.validate(); } - trailing_slash = page_nodes.trailing_slash(); + trailing_slash = await page_nodes.trailing_slash(); } else if (route.endpoint) { const node = await route.endpoint(); trailing_slash = node.trailingSlash ?? 'never'; @@ -340,8 +340,8 @@ export async function respond(request, options, manifest, state) { config = node.config ?? config; prerender = node.prerender ?? prerender; } else if (page_nodes) { - config = page_nodes.get_config() ?? config; - prerender = page_nodes.prerender(); + config = (await page_nodes.get_config()) ?? config; + prerender = await page_nodes.prerender(); } if (state.before_handle) { diff --git a/packages/kit/src/types/internal.d.ts b/packages/kit/src/types/internal.d.ts index 2d54b37ac145..9010b3885940 100644 --- a/packages/kit/src/types/internal.d.ts +++ b/packages/kit/src/types/internal.d.ts @@ -368,13 +368,13 @@ export interface SSRComponent { export type SSRComponentLoader = () => Promise; export interface UniversalNode { - load?: Load; - prerender?: PrerenderOption; - ssr?: boolean; - csr?: boolean; - trailingSlash?: TrailingSlash; - config?: any; - entries?: PrerenderEntryGenerator; + load?: MaybePromise; + prerender?: MaybePromise; + ssr?: MaybePromise; + csr?: MaybePromise; + trailingSlash?: MaybePromise; + config?: MaybePromise; + entries?: MaybePromise; } export interface ServerNode { @@ -401,7 +401,7 @@ export interface SSRNode { universal_id?: string; server_id?: string; - /** inlined styles. */ + /** inlined styles */ inline_styles?(): MaybePromise>; /** Svelte component */ component?: SSRComponentLoader; diff --git a/packages/kit/src/utils/page_nodes.js b/packages/kit/src/utils/page_nodes.js index f68f1aac0ac9..8f0d31d13c8c 100644 --- a/packages/kit/src/utils/page_nodes.js +++ b/packages/kit/src/utils/page_nodes.js @@ -39,45 +39,50 @@ export class PageNodes { } /** - * @template {'prerender' | 'ssr' | 'csr' | 'trailingSlash' | 'entries'} Option - * @template {(import('types').UniversalNode | import('types').ServerNode)[Option]} Value + * @template {'prerender' | 'ssr' | 'csr' | 'trailingSlash'} Option * @param {Option} option - * @returns {Value | undefined} + * @returns {Promise} */ - #get_option(option) { - return this.data.reduce((value, node) => { - return /** @type {Value} TypeScript's too dumb to understand this */ ( - node?.universal?.[option] ?? node?.server?.[option] ?? value - ); - }, /** @type {Value | undefined} */ (undefined)); + async #get_option(option) { + /** @typedef {(import('types').UniversalNode | import('types').ServerNode)[Option]} Value */ + + /** @type {Value | undefined} */ + let value; + for (const node of this.data) { + // eslint-disable-next-line @typescript-eslint/await-thenable -- the universal node value could be a promise + value = (await node?.universal?.[option]) ?? node?.server?.[option] ?? value; + } + + return value; } - csr() { - return this.#get_option('csr') ?? true; + async csr() { + return (await this.#get_option('csr')) ?? true; } - ssr() { - return this.#get_option('ssr') ?? true; + async ssr() { + return (await this.#get_option('ssr')) ?? true; } - prerender() { - return this.#get_option('prerender') ?? false; + async prerender() { + return (await this.#get_option('prerender')) ?? false; } - trailing_slash() { - return this.#get_option('trailingSlash') ?? 'never'; + async trailing_slash() { + return (await this.#get_option('trailingSlash')) ?? 'never'; } - get_config() { + async get_config() { /** @type {any} */ let current = {}; for (const node of this.data) { - if (!node?.universal?.config && !node?.server?.config) continue; + const universal_config = await node?.universal?.config; + if (!universal_config && !node?.server?.config) continue; current = { ...current, - ...node?.universal?.config, + ...universal_config, ...node?.server?.config }; } diff --git a/packages/kit/test/apps/no-ssr/src/routes/browser-globals/+page.js b/packages/kit/test/apps/no-ssr/src/routes/browser-globals/+page.js new file mode 100644 index 000000000000..ad0eb912db0f --- /dev/null +++ b/packages/kit/test/apps/no-ssr/src/routes/browser-globals/+page.js @@ -0,0 +1,9 @@ +// `document` is only available in the browser and should cause the test to fail +// if this file is imported on the server +const pathname = document.location.pathname; + +export function load() { + return { + pathname + }; +} diff --git a/packages/kit/test/apps/no-ssr/src/routes/browser-globals/+page.svelte b/packages/kit/test/apps/no-ssr/src/routes/browser-globals/+page.svelte new file mode 100644 index 000000000000..378328d35703 --- /dev/null +++ b/packages/kit/test/apps/no-ssr/src/routes/browser-globals/+page.svelte @@ -0,0 +1,5 @@ + + +

pathname: {data.pathname}

diff --git a/packages/kit/test/apps/no-ssr/test/test.js b/packages/kit/test/apps/no-ssr/test/test.js index f4a61d76bf3f..363e9ec32350 100644 --- a/packages/kit/test/apps/no-ssr/test/test.js +++ b/packages/kit/test/apps/no-ssr/test/test.js @@ -12,9 +12,14 @@ test('navigating to a non-existent route renders the default error page', async expect(await page.textContent('h1')).toBe('404'); }); -test('navigating to a non-existent route redirects if redirect in the root layout', async ({ +test('navigating to a non-existent route respects redirect thrown from the root layout', async ({ page }) => { await page.goto('/redirect'); expect(await page.textContent('h1')).toBe('home'); }); + +test('universal pages/layouts are not executed on the server', async ({ page }) => { + await page.goto('/browser-globals'); + await expect(page.locator('p')).toHaveText('pathname: /browser-globals'); +}); diff --git a/packages/kit/types/index.d.ts b/packages/kit/types/index.d.ts index 23bb6d7287a4..fe4ad6f98a45 100644 --- a/packages/kit/types/index.d.ts +++ b/packages/kit/types/index.d.ts @@ -1848,13 +1848,13 @@ declare module '@sveltejs/kit' { type SSRComponentLoader = () => Promise; interface UniversalNode { - load?: Load; - prerender?: PrerenderOption; - ssr?: boolean; - csr?: boolean; - trailingSlash?: TrailingSlash; - config?: any; - entries?: PrerenderEntryGenerator; + load?: MaybePromise; + prerender?: MaybePromise; + ssr?: MaybePromise; + csr?: MaybePromise; + trailingSlash?: MaybePromise; + config?: MaybePromise; + entries?: MaybePromise; } interface ServerNode { @@ -1881,7 +1881,7 @@ declare module '@sveltejs/kit' { universal_id?: string; server_id?: string; - /** inlined styles. */ + /** inlined styles */ inline_styles?(): MaybePromise>; /** Svelte component */ component?: SSRComponentLoader; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a6c6f8084015..e7e9c02769b1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -362,9 +362,15 @@ importers: packages/kit: dependencies: + '@sveltejs/acorn-typescript': + specifier: ^1.0.5 + version: 1.0.5(acorn@8.14.1) '@types/cookie': specifier: ^0.6.0 version: 0.6.0 + acorn: + specifier: ^8.14.1 + version: 8.14.1 cookie: specifier: ^0.6.0 version: 0.6.0