Skip to content

feat: statically analyse universal pages and layouts #13669

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 13 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/polite-bulldogs-swim.md
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions packages/kit/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
12 changes: 6 additions & 6 deletions packages/kit/src/core/postbuild/analyse.js
Original file line number Diff line number Diff line change
Expand Up @@ -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());

Expand Down Expand Up @@ -182,7 +182,7 @@ function analyse_endpoint(route, mod) {
* @param {Array<import('types').SSRNode | undefined>} 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');
Expand All @@ -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()
};
}

Expand Down
40 changes: 34 additions & 6 deletions packages/kit/src/exports/vite/build/build_server.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)};`);
}

Expand Down
57 changes: 53 additions & 4 deletions packages/kit/src/exports/vite/dev/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<string, any>; 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) {
Expand Down
100 changes: 99 additions & 1 deletion packages/kit/src/exports/vite/utils.js
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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<string, any>} */
const static_exports = new Map();
/** @type {Set<string>} */
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
};
}
6 changes: 3 additions & 3 deletions packages/kit/src/runtime/server/page/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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
Expand Down
6 changes: 4 additions & 2 deletions packages/kit/src/runtime/server/page/load_data.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions packages/kit/src/runtime/server/page/respond_with_error.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
8 changes: 4 additions & 4 deletions packages/kit/src/runtime/server/respond.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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';
Expand Down Expand Up @@ -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) {
Expand Down
16 changes: 8 additions & 8 deletions packages/kit/src/types/internal.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -368,13 +368,13 @@ export interface SSRComponent {
export type SSRComponentLoader = () => Promise<SSRComponent>;

export interface UniversalNode {
load?: Load;
prerender?: PrerenderOption;
ssr?: boolean;
csr?: boolean;
trailingSlash?: TrailingSlash;
config?: any;
entries?: PrerenderEntryGenerator;
load?: MaybePromise<Load>;
prerender?: MaybePromise<PrerenderOption>;
ssr?: MaybePromise<boolean>;
csr?: MaybePromise<boolean>;
trailingSlash?: MaybePromise<TrailingSlash>;
config?: MaybePromise<any>;
entries?: MaybePromise<PrerenderEntryGenerator>;
}

export interface ServerNode {
Expand All @@ -401,7 +401,7 @@ export interface SSRNode {
universal_id?: string;
server_id?: string;

/** inlined styles. */
/** inlined styles */
inline_styles?(): MaybePromise<Record<string, string>>;
/** Svelte component */
component?: SSRComponentLoader;
Expand Down
Loading
Loading