diff --git a/documentation/docs/11-ssr-and-javascript.md b/documentation/docs/11-ssr-and-javascript.md index fe9b3e0721a7c..b0675a0d844bb 100644 --- a/documentation/docs/11-ssr-and-javascript.md +++ b/documentation/docs/11-ssr-and-javascript.md @@ -6,15 +6,17 @@ By default, SvelteKit will render any component first on the server and send it You can control each of these on a per-app or per-page basis. Note that each of the per-page settings use [`context="module"`](https://svelte.dev/docs#script_context_module), and only apply to page components, _not_ [layout](#layouts) components. -If both are specified, per-page settings override per-app settings in case of conflicts. Each setting can be controlled independently, but `ssr` and `hydrate` cannot both be `false` since that would result in nothing being rendered at all. +The app-wide config options take a function, which lets you set configure the option in an advanced manner on a per-request and per-page basis. E.g. you could disable SSR for `/admin` or enable SSR only for search engine crawlers (aka [dynamic rendering](https://developers.google.com/search/docs/advanced/javascript/dynamic-rendering)). + + Each setting can be controlled independently, but `ssr` and `hydrate` cannot both be `false` since that would result in nothing being rendered at all. ### ssr -Disabling [server-side rendering](#appendix-ssr) effectively turns your SvelteKit app into a [**single-page app** or SPA](#appendix-csr-and-spa). +Disabling [server-side rendering](#appendix-ssr) effectively turns your SvelteKit app into a [**single-page app** or SPA](#appendix-csr-and-spa). The default app-wide config option value is a function which reads the page value. Reading the page value causes the page to be loaded on the server. If you'd like to avoid this because you're building a SPA, you will need to set a value such as a boolean for each of the four rendering options which does not access the page-level settings. > In most situations this is not recommended: see [the discussion in the appendix](#appendix-ssr). Consider whether it's truly appropriate to disable and don't simply disable SSR because you've hit an issue with it. -You can disable SSR app-wide with the [`ssr` config option](#configuration-ssr), or a page-level `ssr` export: +SSR can be configured with app-wide [`ssr` config option](#configuration-ssr), or a page-level `ssr` export: ```html ``` diff --git a/packages/kit/src/core/adapt/prerender.js b/packages/kit/src/core/adapt/prerender.js index 9721c91db0fa3..8110da75b23ef 100644 --- a/packages/kit/src/core/adapt/prerender.js +++ b/packages/kit/src/core/adapt/prerender.js @@ -76,13 +76,14 @@ export async function prerender({ cwd, out, log, config, build_data, fallback, a }); /** @type {(status: number, path: string, parent: string | null, verb: string) => void} */ - const error = config.kit.prerender.force - ? (status, path, parent, verb) => { - log.error(`${status} ${path}${parent ? ` (${verb} from ${parent})` : ''}`); - } - : (status, path, parent, verb) => { - throw new Error(`${status} ${path}${parent ? ` (${verb} from ${parent})` : ''}`); - }; + const error = + config.kit.prerender.enabled === true + ? (status, path, parent, verb) => { + log.error(`${status} ${path}${parent ? ` (${verb} from ${parent})` : ''}`); + } + : (status, path, parent, verb) => { + throw new Error(`${status} ${path}${parent ? ` (${verb} from ${parent})` : ''}`); + }; const files = new Set([...build_data.static, ...build_data.client]); diff --git a/packages/kit/src/core/build/index.js b/packages/kit/src/core/build/index.js index b960d3f14cdfa..0c90fb1cfd6cc 100644 --- a/packages/kit/src/core/build/index.js +++ b/packages/kit/src/core/build/index.js @@ -312,8 +312,9 @@ async function build_server( set_paths(settings.paths); set_prerendering(settings.prerendering || false); + const config = settings.config; options = { - amp: ${config.kit.amp}, + amp: config.kit.amp, dev: false, entry: { file: ${s(prefix + client_manifest[client_entry_file].file)}, @@ -321,7 +322,7 @@ async function build_server( js: ${s(Array.from(entry_js).map(dep => prefix + dep))} }, fetched: undefined, - floc: ${config.kit.floc}, + floc: config.kit.floc, get_component_path: id => ${s(`${config.kit.paths.assets}/${config.kit.appDir}/`)} + entry_lookup[id], get_stack: error => String(error), // for security handle_error: /** @param {Error & {frame?: string}} error */ (error) => { @@ -332,19 +333,20 @@ async function build_server( error.stack = options.get_stack(error); }, hooks: get_hooks(user_hooks), - hydrate: ${s(config.kit.hydrate)}, + hydrate: config.kit.hydrate, initiator: undefined, load_component, manifest, paths: settings.paths, + prerender: config.kit.prerender && config.kit.prerender.enabled, read: settings.read, root, service_worker: ${service_worker_entry_file ? "'/service-worker.js'" : 'null'}, - router: ${s(config.kit.router)}, - ssr: ${s(config.kit.ssr)}, - target: ${s(config.kit.target)}, + router: config.kit.router, + ssr: config.kit.ssr, + target: config.kit.target, template, - trailing_slash: ${s(config.kit.trailingSlash)} + trailing_slash: config.kit.trailingSlash }; } diff --git a/packages/kit/src/core/config/index.spec.js b/packages/kit/src/core/config/index.spec.js index f0954248b95d2..4432d455fe36b 100644 --- a/packages/kit/src/core/config/index.spec.js +++ b/packages/kit/src/core/config/index.spec.js @@ -4,9 +4,7 @@ import { deep_merge, validate_config } from './index.js'; test('fills in defaults', () => { const validated = validate_config({}); - - // @ts-expect-error - delete validated.kit.vite; + delete_complex_opts(validated); assert.equal(validated, { compilerOptions: null, @@ -27,7 +25,6 @@ test('fills in defaults', () => { floc: false, host: null, hostHeader: null, - hydrate: true, package: { dir: 'package', exports: { @@ -49,12 +46,8 @@ test('fills in defaults', () => { }, prerender: { crawl: true, - enabled: true, - force: false, pages: ['*'] }, - router: true, - ssr: true, target: null, trailingSlash: 'never' }, @@ -105,8 +98,7 @@ test('fills in partial blanks', () => { assert.equal(validated.kit.vite(), {}); - // @ts-expect-error - delete validated.kit.vite; + delete_complex_opts(validated); assert.equal(validated, { compilerOptions: null, @@ -127,7 +119,6 @@ test('fills in partial blanks', () => { floc: false, host: null, hostHeader: null, - hydrate: true, package: { dir: 'package', exports: { @@ -149,12 +140,8 @@ test('fills in partial blanks', () => { }, prerender: { crawl: true, - enabled: true, - force: false, pages: ['*'] }, - router: true, - ssr: true, target: null, trailingSlash: 'never' }, @@ -513,3 +500,17 @@ deepMergeSuite('merge including toString', () => { }); deepMergeSuite.run(); + +/** @param {import('types/config').ValidatedConfig} validated */ +function delete_complex_opts(validated) { + // @ts-expect-error + delete validated.kit.vite; + // @ts-expect-error + delete validated.kit.hydrate; + // @ts-expect-error + delete validated.kit.prerender.enabled; + // @ts-expect-error + delete validated.kit.router; + // @ts-expect-error + delete validated.kit.ssr; +} diff --git a/packages/kit/src/core/config/options.js b/packages/kit/src/core/config/options.js index 633c3ad75c843..a1eea6393c4b6 100644 --- a/packages/kit/src/core/config/options.js +++ b/packages/kit/src/core/config/options.js @@ -78,7 +78,10 @@ const options = { hostHeader: expect_string(null), - hydrate: expect_boolean(true), + hydrate: expect_page_scriptable(async ({ page }) => { + const leaf = await page; + return 'hydrate' in leaf ? !!leaf.hydrate : true; + }), serviceWorker: { type: 'branch', children: { @@ -120,8 +123,10 @@ const options = { type: 'branch', children: { crawl: expect_boolean(true), - enabled: expect_boolean(true), - force: expect_boolean(false), + enabled: expect_page_scriptable(async ({ page }) => { + const leaf = await page; + return 'prerender' in leaf ? !!leaf.prerender : true; + }), pages: { type: 'leaf', default: ['*'], @@ -144,9 +149,15 @@ const options = { } }, - router: expect_boolean(true), + router: expect_page_scriptable(async ({ page }) => { + const leaf = await page; + return 'router' in leaf ? !!leaf.router : true; + }), - ssr: expect_boolean(true), + ssr: expect_page_scriptable(async ({ page }) => { + const leaf = await page; + return 'ssr' in leaf ? !!leaf.ssr : true; + }), target: expect_string(null), @@ -233,6 +244,23 @@ function expect_boolean(boolean) { }; } +/** + * @param {import('types/config').ScriptablePageOpt} value + * @returns {ConfigDefinition} + */ +function expect_page_scriptable(value) { + return { + type: 'leaf', + default: value, + validate: (option, keypath) => { + if (typeof option !== 'boolean' && typeof option !== 'function') { + throw new Error(`${keypath} should be a boolean or function that returns one`); + } + return option; + } + }; +} + /** * @param {string[]} options * @returns {ConfigDefinition} diff --git a/packages/kit/src/core/config/test/index.js b/packages/kit/src/core/config/test/index.js index daa1c8b400b75..a3d3cc0cf2c24 100644 --- a/packages/kit/src/core/config/test/index.js +++ b/packages/kit/src/core/config/test/index.js @@ -14,9 +14,7 @@ async function testLoadDefaultConfig(path) { const cwd = join(__dirname, 'fixtures', path); const config = await load_config({ cwd }); - - // @ts-expect-error - delete config.kit.vite; // can't test equality of a function + delete_complex_opts(config); assert.equal(config, { compilerOptions: null, @@ -37,7 +35,6 @@ async function testLoadDefaultConfig(path) { floc: false, host: null, hostHeader: null, - hydrate: true, package: { dir: 'package', exports: { @@ -54,9 +51,7 @@ async function testLoadDefaultConfig(path) { exclude: [] }, paths: { base: '', assets: '/.' }, - prerender: { crawl: true, enabled: true, force: false, pages: ['*'] }, - router: true, - ssr: true, + prerender: { crawl: true, pages: ['*'] }, target: null, trailingSlash: 'never' }, @@ -103,3 +98,17 @@ test('errors on loading config with incorrect default export', async () => { }); test.run(); + +/** @param {import('types/config').ValidatedConfig} validated */ +function delete_complex_opts(validated) { + // @ts-expect-error + delete validated.kit.vite; + // @ts-expect-error + delete validated.kit.hydrate; + // @ts-expect-error + delete validated.kit.prerender.enabled; + // @ts-expect-error + delete validated.kit.router; + // @ts-expect-error + delete validated.kit.ssr; +} diff --git a/packages/kit/src/core/dev/index.js b/packages/kit/src/core/dev/index.js index 039c040e134bc..7949a32f2d0bc 100644 --- a/packages/kit/src/core/dev/index.js +++ b/packages/kit/src/core/dev/index.js @@ -392,6 +392,7 @@ async function create_handler(vite, config, dir, cwd, manifest) { }; }, manifest, + prerender: config.kit.prerender.enabled, read: (file) => fs.readFileSync(path.join(config.kit.files.assets, file)), root, router: config.kit.router, diff --git a/packages/kit/src/core/preview/index.js b/packages/kit/src/core/preview/index.js index 2d671e6ca106c..88d4d3e66e3df 100644 --- a/packages/kit/src/core/preview/index.js +++ b/packages/kit/src/core/preview/index.js @@ -23,7 +23,13 @@ const mutable = (dir) => * cwd?: string; * }} opts */ -export async function preview({ port, host, config, https: use_https = false, cwd = process.cwd() }) { +export async function preview({ + port, + host, + config, + https: use_https = false, + cwd = process.cwd() +}) { __fetch_polyfill(); const app_file = resolve(cwd, `${SVELTE_KIT}/output/server/app.js`); @@ -45,6 +51,7 @@ export async function preview({ port, host, config, https: use_https = false, cw }); app.init({ + config, paths: { base: '', assets: '/.' diff --git a/packages/kit/src/runtime/server/index.js b/packages/kit/src/runtime/server/index.js index 24c8cbb477964..f785475ce3060 100644 --- a/packages/kit/src/runtime/server/index.js +++ b/packages/kit/src/runtime/server/index.js @@ -48,7 +48,7 @@ export async function respond(incoming, options, state = {}) { return await render_response({ options, $session: await options.hooks.getSession(request), - page_config: { ssr: false, router: true, hydrate: true }, + page_config: { ssr: false, router: true, hydrate: true, prerender: true }, status: 200, branch: [] }); diff --git a/packages/kit/src/runtime/server/page/render.js b/packages/kit/src/runtime/server/page/render.js index 492d302398fcb..7ecf620e155d5 100644 --- a/packages/kit/src/runtime/server/page/render.js +++ b/packages/kit/src/runtime/server/page/render.js @@ -10,7 +10,7 @@ const s = JSON.stringify; * @param {{ * options: import('types/internal').SSRRenderOptions; * $session: any; - * page_config: { hydrate: boolean, router: boolean, ssr: boolean }; + * page_config: import('types/config').PageOpts; * status: number; * error?: Error, * branch?: Array; diff --git a/packages/kit/src/runtime/server/page/respond.js b/packages/kit/src/runtime/server/page/respond.js index 59a2a361c6a22..1fe77799644e6 100644 --- a/packages/kit/src/runtime/server/page/respond.js +++ b/packages/kit/src/runtime/server/page/respond.js @@ -1,7 +1,7 @@ import { render_response } from './render.js'; import { load_node } from './load_node.js'; import { respond_with_error } from './respond_with_error.js'; -import { coalesce_to_error } from '../utils.js'; +import { coalesce_to_error, resolve_option } from '../utils.js'; /** @typedef {import('./types.js').Loaded} Loaded */ @@ -27,36 +27,19 @@ export async function respond({ request, options, state, $session, route }) { params }; - let nodes; - - try { - nodes = await Promise.all(route.a.map((id) => options.load_component(id))); - } catch (/** @type {unknown} */ err) { - const error = coalesce_to_error(err); - - options.handle_error(error); - - return await respond_with_error({ - request, - options, - state, - $session, - status: 500, - error - }); - } - - const leaf = nodes[nodes.length - 1].module; + const leaf_promise = async () => { + return (await options.load_component(route.a[route.a.length - 1])).module; + }; const page_config = { - ssr: 'ssr' in leaf ? !!leaf.ssr : options.ssr, - router: 'router' in leaf ? !!leaf.router : options.router, - hydrate: 'hydrate' in leaf ? !!leaf.hydrate : options.hydrate + ssr: resolve_option(options.ssr, { request, page: leaf_promise() }), + router: resolve_option(options.router, { request, page: leaf_promise() }), + hydrate: resolve_option(options.hydrate, { request, page: leaf_promise() }), + prerender: resolve_option(options.prerender, { request, page: leaf_promise() }) }; - if (!leaf.prerender && state.prerender && !state.prerender.all) { - // if the page has `export const prerender = true`, continue, - // otherwise bail out at this point + // if prerendering some pages, but not this one + if (state.prerender && !state.prerender.all && !page_config.prerender) { return { status: 204, headers: {}, @@ -74,6 +57,29 @@ export async function respond({ request, options, state, $session, route }) { let error; ssr: if (page_config.ssr) { + /** + * The layout components and page components for a page + * @type {import('types/internal').SSRNode[]} + */ + let nodes; + + try { + nodes = await Promise.all(route.a.map((id) => options.load_component(id))); + } catch (/** @type {unknown} */ err) { + const error = coalesce_to_error(err); + + options.handle_error(error); + + return await respond_with_error({ + request, + options, + state, + $session, + status: 500, + error + }); + } + let context = {}; branch = []; 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 1bdaa0e201174..c5f84ff05a8f6 100644 --- a/packages/kit/src/runtime/server/page/respond_with_error.js +++ b/packages/kit/src/runtime/server/page/respond_with_error.js @@ -1,6 +1,6 @@ import { render_response } from './render.js'; import { load_node } from './load_node.js'; -import { coalesce_to_error } from '../utils.js'; +import { coalesce_to_error, resolve_option } from '../utils.js'; /** * @param {{ @@ -55,15 +55,20 @@ export async function respond_with_error({ request, options, state, $session, st })) ]; + const leaf_promise = async () => branch[branch.length - 1].node.module; + + const page_config = { + ssr: resolve_option(options.ssr, { request, page: leaf_promise() }), + router: resolve_option(options.router, { request, page: leaf_promise() }), + hydrate: resolve_option(options.hydrate, { request, page: leaf_promise() }), + prerender: resolve_option(options.prerender, { request, page: leaf_promise() }) + }; + try { return await render_response({ options, $session, - page_config: { - hydrate: options.hydrate, - router: options.router, - ssr: options.ssr - }, + page_config, status, error, branch, diff --git a/packages/kit/src/runtime/server/utils.js b/packages/kit/src/runtime/server/utils.js index 74fde8c68a70c..dfd11f1263c28 100644 --- a/packages/kit/src/runtime/server/utils.js +++ b/packages/kit/src/runtime/server/utils.js @@ -17,3 +17,15 @@ export function lowercase_keys(obj) { export function coalesce_to_error(err) { return err instanceof Error ? err : new Error(JSON.stringify(err)); } + +/** + * @param {any} opt + * @param {object} ctx + * @returns + */ +export function resolve_option(opt, ctx) { + if (typeof opt === 'function') { + return opt(ctx); + } + return opt; +} diff --git a/packages/kit/test/test.js b/packages/kit/test/test.js index 12781f4af3fd8..0ea6d6a45fd24 100644 --- a/packages/kit/test/test.js +++ b/packages/kit/test/test.js @@ -170,7 +170,6 @@ function duplicate(test_fn, config, is_build) { if (process.env.FILTER && !name.includes(process.env.FILTER)) return; test_fn(name, async (context) => { - let response; if (start) { @@ -194,7 +193,6 @@ function duplicate(test_fn, config, is_build) { if (process.env.FILTER && !name.includes(process.env.FILTER)) return; test_fn(name, async (context) => { - let response; if (start) { diff --git a/packages/kit/types/config.d.ts b/packages/kit/types/config.d.ts index 45482dddb12fe..2907aaff4fd38 100644 --- a/packages/kit/types/config.d.ts +++ b/packages/kit/types/config.d.ts @@ -1,7 +1,8 @@ +import { ServerRequest } from './hooks'; import { Logger, TrailingSlash } from './internal'; import { UserConfig as ViteConfig } from 'vite'; -export type AdapterUtils = { +export interface AdapterUtils { log: Logger; rimraf: (dir: string) => void; mkdirp: (dir: string) => void; @@ -18,14 +19,28 @@ export type AdapterUtils = { dest: string; fallback?: string; }) => Promise; -}; +} -export type Adapter = { +export interface Adapter { name: string; adapt: ({ utils, config }: { utils: AdapterUtils; config: ValidatedConfig }) => Promise; -}; +} -export type Config = { +export interface PageOpts { + ssr: boolean; + router: boolean; + hydrate: boolean; + prerender: boolean; +} + +export interface PageOptsContext { + request: ServerRequest; + page: Promise; +} + +export type ScriptablePageOpt = T | (({ request, page }: PageOptsContext) => Promise); + +export interface Config { compilerOptions?: any; extensions?: string[]; kit?: { @@ -43,7 +58,7 @@ export type Config = { floc?: boolean; host?: string; hostHeader?: string; - hydrate?: boolean; + hydrate?: ScriptablePageOpt; package?: { dir?: string; emitTypes?: boolean; @@ -62,23 +77,22 @@ export type Config = { }; prerender?: { crawl?: boolean; - enabled?: boolean; - force?: boolean; + enabled?: ScriptablePageOpt; pages?: string[]; }; - router?: boolean; + router?: ScriptablePageOpt; serviceWorker?: { exclude?: string[]; }; - ssr?: boolean; + ssr?: ScriptablePageOpt; target?: string; trailingSlash?: TrailingSlash; vite?: ViteConfig | (() => ViteConfig); }; preprocess?: any; -}; +} -export type ValidatedConfig = { +export interface ValidatedConfig { compilerOptions: any; extensions: string[]; kit: { @@ -97,7 +111,7 @@ export type ValidatedConfig = { floc: boolean; host: string; hostHeader: string; - hydrate: boolean; + hydrate: ScriptablePageOpt; package: { dir: string; emitTypes: boolean; @@ -116,18 +130,17 @@ export type ValidatedConfig = { }; prerender: { crawl: boolean; - enabled: boolean; - force: boolean; + enabled: ScriptablePageOpt; pages: string[]; }; - router: boolean; + router: ScriptablePageOpt; serviceWorker: { exclude: string[]; }; - ssr: boolean; + ssr: ScriptablePageOpt; target: string; trailingSlash: TrailingSlash; vite: () => ViteConfig; }; preprocess: any; -}; +} diff --git a/packages/kit/types/endpoint.d.ts b/packages/kit/types/endpoint.d.ts index 2687562aebf67..1b4df7f6aed5e 100644 --- a/packages/kit/types/endpoint.d.ts +++ b/packages/kit/types/endpoint.d.ts @@ -12,11 +12,11 @@ type JSONValue = type DefaultBody = JSONValue | Uint8Array; -export type EndpointOutput = { +export interface EndpointOutput { status?: number; headers?: Headers; body?: Body; -}; +} export type RequestHandler< Locals = Record, diff --git a/packages/kit/types/hooks.d.ts b/packages/kit/types/hooks.d.ts index d50776a133f76..c80787f58a2d3 100644 --- a/packages/kit/types/hooks.d.ts +++ b/packages/kit/types/hooks.d.ts @@ -2,23 +2,23 @@ import { Headers, Location, MaybePromise, ParameterizedBody } from './helper'; export type StrictBody = string | Uint8Array; -export type ServerRequest, Body = unknown> = Location & { +export interface ServerRequest, Body = unknown> extends Location { method: string; headers: Headers; rawBody: StrictBody; body: ParameterizedBody; locals: Locals; -}; +} -export type ServerResponse = { +export interface ServerResponse { status: number; headers: Headers; body?: StrictBody; -}; +} -export type GetSession, Session = any> = { +export interface GetSession, Session = any> { (request: ServerRequest): MaybePromise; -}; +} export type Handle> = (input: { request: ServerRequest; diff --git a/packages/kit/types/internal.d.ts b/packages/kit/types/internal.d.ts index 599c481ae1fcf..24abb74e3a62c 100644 --- a/packages/kit/types/internal.d.ts +++ b/packages/kit/types/internal.d.ts @@ -1,3 +1,4 @@ +import { PageOpts, ScriptablePageOpt, ValidatedConfig } from './config'; import { RequestHandler } from './endpoint'; import { Headers, Location, ParameterizedBody } from './helper'; import { GetSession, Handle, ServerResponse, ServerFetch, StrictBody } from './hooks'; @@ -23,10 +24,12 @@ export type Logger = { export type App = { init: ({ + config, paths, prerendering, read }: { + config: ValidatedConfig; paths: { base: string; assets: string; @@ -46,11 +49,7 @@ export type App = { ) => Promise; }; -export type SSRComponent = { - ssr?: boolean; - router?: boolean; - hydrate?: boolean; - prerender?: boolean; +export interface SSRComponent extends PageOpts { preload?: any; // TODO remove for 1.0 load: Load; default: { @@ -65,7 +64,7 @@ export type SSRComponent = { }; }; }; -}; +} export type SSRComponentLoader = () => Promise; @@ -142,18 +141,19 @@ export type SSRRenderOptions = { get_stack: (error: Error) => string | undefined; handle_error: (error: Error) => void; hooks: Hooks; - hydrate: boolean; + hydrate: ScriptablePageOpt; load_component: (id: PageId) => Promise; manifest: SSRManifest; paths: { base: string; assets: string; }; + prerender: ScriptablePageOpt; read: (file: string) => Buffer; root: SSRComponent['default']; - router: boolean; + router: ScriptablePageOpt; service_worker?: string; - ssr: boolean; + ssr: ScriptablePageOpt; target: string; template: ({ head, body }: { head: string; body: string }) => string; trailing_slash: TrailingSlash;