From 8404d2d23efac1f923a4638cacfcd0bb9aa0779e Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Sun, 4 Apr 2021 16:38:10 -0400 Subject: [PATCH 1/9] load all components up-front --- packages/kit/src/runtime/server/page/index.js | 2 +- .../src/runtime/server/page/load_branch.js | 0 .../kit/src/runtime/server/page/load_node.js | 0 .../src/runtime/server/page/render_error.js | 15 +++++++ .../kit/src/runtime/server/page/respond.js | 42 +++++++++---------- 5 files changed, 37 insertions(+), 22 deletions(-) create mode 100644 packages/kit/src/runtime/server/page/load_branch.js create mode 100644 packages/kit/src/runtime/server/page/load_node.js create mode 100644 packages/kit/src/runtime/server/page/render_error.js diff --git a/packages/kit/src/runtime/server/page/index.js b/packages/kit/src/runtime/server/page/index.js index 927b37fbed12..81f70a05dd22 100644 --- a/packages/kit/src/runtime/server/page/index.js +++ b/packages/kit/src/runtime/server/page/index.js @@ -48,7 +48,7 @@ export default async function render_page(request, route, options) { request, options, $session, - route, + route: null, status: 404, error: new Error(`Not found: ${request.path}`) }); diff --git a/packages/kit/src/runtime/server/page/load_branch.js b/packages/kit/src/runtime/server/page/load_branch.js new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/packages/kit/src/runtime/server/page/load_node.js b/packages/kit/src/runtime/server/page/load_node.js new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/packages/kit/src/runtime/server/page/render_error.js b/packages/kit/src/runtime/server/page/render_error.js new file mode 100644 index 000000000000..44a20729b11d --- /dev/null +++ b/packages/kit/src/runtime/server/page/render_error.js @@ -0,0 +1,15 @@ +/** + * @param {{ + * request: import('types').Request; + * options: import('types.internal').SSRRenderOptions; + * $session: any; + * status: number; + * error: Error; + * }} opts + */ +export async function render_error({ request, options, $session, status, error }) { + const default_layout = await options.load_component(options.manifest.layout); + const default_error = await options.load_component(options.manifest.error); + + const branch = [await load_node(default_layout), {}]; +} diff --git a/packages/kit/src/runtime/server/page/respond.js b/packages/kit/src/runtime/server/page/respond.js index a540e31a1463..c2762245eca6 100644 --- a/packages/kit/src/runtime/server/page/respond.js +++ b/packages/kit/src/runtime/server/page/respond.js @@ -203,25 +203,17 @@ export async function respond({ request, options, $session, route, status = 200, ); }; - const component_promises = error - ? [options.load_component(options.manifest.layout)] - : [ - options.load_component(options.manifest.layout), - ...route.parts.map((id) => options.load_component(id)) - ]; - - const components = []; - const props_promises = []; - - let context = {}; - let maxage; - - let page_component; + let nodes; try { - page_component = error - ? { ssr: options.ssr, router: options.router, hydrate: options.hydrate } - : (await component_promises[component_promises.length - 1]).module; + nodes = await Promise.all( + error + ? [options.load_component(options.manifest.layout)] + : [ + options.load_component(options.manifest.layout), + ...route.parts.map((id) => options.load_component(id)) + ] + ); } catch (e) { return await respond({ request, @@ -233,6 +225,16 @@ export async function respond({ request, options, $session, route, status = 200, }); } + const components = []; + const props_promises = []; + + let context = {}; + let maxage; + + const page_component = error + ? { ssr: options.ssr, router: options.router, hydrate: options.hydrate } + : nodes[nodes.length - 1].module; + const page_config = { ssr: 'ssr' in page_component ? page_component.ssr : options.ssr, router: 'router' in page_component ? page_component.router : options.router, @@ -263,11 +265,11 @@ export async function respond({ request, options, $session, route, status = 200, let rendered; if (page_config.ssr) { - for (let i = 0; i < component_promises.length; i += 1) { + for (let i = 0; i < nodes.length; i += 1) { let loaded; try { - const { module } = await component_promises[i]; + const { module } = nodes[i]; components[i] = module.default; if (module.load) { @@ -395,8 +397,6 @@ export async function respond({ request, options, $session, route, status = 200, const js = new Set(); const styles = new Set(); - const nodes = await Promise.all(component_promises); - if (page_config.ssr) { nodes.forEach((part) => { if (part.css) part.css.forEach((url) => css.add(url)); From 4fa80856d00ce39fc0d42df7c9982f9b1a4432c7 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Sun, 4 Apr 2021 16:38:46 -0400 Subject: [PATCH 2/9] typechecking --- packages/kit/src/runtime/server/page/respond.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/kit/src/runtime/server/page/respond.js b/packages/kit/src/runtime/server/page/respond.js index c2762245eca6..04181c1b0c0a 100644 --- a/packages/kit/src/runtime/server/page/respond.js +++ b/packages/kit/src/runtime/server/page/respond.js @@ -232,7 +232,7 @@ export async function respond({ request, options, $session, route, status = 200, let maxage; const page_component = error - ? { ssr: options.ssr, router: options.router, hydrate: options.hydrate } + ? { ssr: options.ssr, router: options.router, hydrate: options.hydrate, prerender: false } : nodes[nodes.length - 1].module; const page_config = { From 0c11fe8c45954fc77b5f75b41f83ebb99a0e8a70 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Sun, 4 Apr 2021 16:51:41 -0400 Subject: [PATCH 3/9] invoke router on startup if no SSR --- packages/kit/src/runtime/client/start.js | 5 ++++- packages/kit/src/runtime/server/page/respond.js | 3 ++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/kit/src/runtime/client/start.js b/packages/kit/src/runtime/client/start.js index 1f0467f18a70..4aa8d6d352f2 100644 --- a/packages/kit/src/runtime/client/start.js +++ b/packages/kit/src/runtime/client/start.js @@ -18,9 +18,10 @@ import { set_paths } from '../paths.js'; * status: number; * host: string; * route: boolean; + * spa: boolean; * hydrate: import('./types').NavigationCandidate; * }} opts */ -export async function start({ paths, target, session, host, route, hydrate }) { +export async function start({ paths, target, session, host, route, spa, hydrate }) { const router = route && new Router({ @@ -42,6 +43,8 @@ export async function start({ paths, target, session, host, route, hydrate }) { if (hydrate) await renderer.start(hydrate); if (route) router.init(renderer); + if (spa) router.goto(location.href, { replaceState: true }, []); + dispatchEvent(new CustomEvent('sveltekit:start')); } diff --git a/packages/kit/src/runtime/server/page/respond.js b/packages/kit/src/runtime/server/page/respond.js index 04181c1b0c0a..97ec2dc3d029 100644 --- a/packages/kit/src/runtime/server/page/respond.js +++ b/packages/kit/src/runtime/server/page/respond.js @@ -434,7 +434,8 @@ export async function respond({ request, options, $session, route, status = 200, session: ${serialized_session}, host: ${request.host ? s(request.host) : 'location.host'}, route: ${!!page_config.router}, - hydrate: ${page_config.hydrate? `{ + spa: ${!page_config.ssr}, + hydrate: ${page_config.ssr && page_config.hydrate? `{ status: ${status}, error: ${serialize_error(error)}, nodes: ${route ? `[ From f6102910c71d62c4dbda27e80b06b9ed8c4a0212 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Sun, 4 Apr 2021 21:58:09 -0400 Subject: [PATCH 4/9] getting closer --- package.json | 3 +- packages/kit/src/core/dev/index.js | 2 - packages/kit/src/runtime/server/page/html.js | 203 ++++++ packages/kit/src/runtime/server/page/index.js | 8 +- .../src/runtime/server/page/load_branch.js | 0 .../kit/src/runtime/server/page/load_node.js | 273 ++++++++ .../src/runtime/server/page/render_error.js | 15 - .../kit/src/runtime/server/page/respond.js | 589 +++--------------- .../runtime/server/page/respond_with_error.js | 67 ++ .../kit/src/runtime/server/page/types.d.ts | 8 + packages/kit/types.internal.d.ts | 18 +- 11 files changed, 649 insertions(+), 537 deletions(-) create mode 100644 packages/kit/src/runtime/server/page/html.js delete mode 100644 packages/kit/src/runtime/server/page/load_branch.js delete mode 100644 packages/kit/src/runtime/server/page/render_error.js create mode 100644 packages/kit/src/runtime/server/page/respond_with_error.js create mode 100644 packages/kit/src/runtime/server/page/types.d.ts diff --git a/package.json b/package.json index 36a35c0d50a1..7f992f37bb1b 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,8 @@ "check": "pnpm -r check", "lint": "pnpm -r lint", "format": "pnpm -r format", - "release": "pnpm publish --tag=next --filter=\"@sveltejs/*\" --filter=\"create-svelte\"" + "release": "pnpm publish --tag=next --filter=\"@sveltejs/*\" --filter=\"create-svelte\"", + "postinstall": "echo here" }, "repository": { "type": "git", diff --git a/packages/kit/src/core/dev/index.js b/packages/kit/src/core/dev/index.js index fc1ba8f5902f..f7aaebd1ce99 100644 --- a/packages/kit/src/core/dev/index.js +++ b/packages/kit/src/core/dev/index.js @@ -35,8 +35,6 @@ class Watcher extends EventEmitter { this.https = https; this.config = config; - process.env.NODE_ENV = 'development'; - process.on('exit', () => { this.close(); }); diff --git a/packages/kit/src/runtime/server/page/html.js b/packages/kit/src/runtime/server/page/html.js new file mode 100644 index 000000000000..8e659cdae2ca --- /dev/null +++ b/packages/kit/src/runtime/server/page/html.js @@ -0,0 +1,203 @@ +import devalue from 'devalue'; +import { writable } from 'svelte/store'; + +const s = JSON.stringify; + +// TODO rename this function/module + +/** + * @param {{ + * request: import('types').Request; + * options: import('types.internal').SSRRenderOptions; + * $session: any; + * page_config: { hydrate: boolean, router: boolean, ssr: boolean }; + * status: number; + * error: Error, + * branch: import('./types').Loaded[]; + * page: import('types.internal').Page + * }} opts + */ +export async function get_html({ options, $session, page_config, status, error, branch, page }) { + const css = new Set(); + const js = new Set(); + const styles = new Set(); + + /** @type {Array<{ url: string, json: string }>} */ + const serialized_data = []; + + let rendered; + + let is_private = false; + let maxage; + + if (branch) { + branch.forEach(({ node, loaded, fetched, uses_credentials }) => { + node.css.forEach((url) => css.add(url)); + node.js.forEach((url) => js.add(url)); + node.styles.forEach((content) => styles.add(content)); + + if (fetched) serialized_data.push(...fetched); + + if (uses_credentials) is_private = true; + + maxage = loaded.maxage; + }); + + const session = writable($session); + let session_tracking_active = false; + const unsubscribe = session.subscribe(() => { + if (session_tracking_active) is_private = true; + }); + session_tracking_active = true; + + if (error) { + if (options.dev) { + error.stack = await options.get_stack(error); + } else { + // remove error.stack in production + error.stack = String(error); + } + } + + /** @type {Record} */ + const props = { + status, + error, + stores: { + page: writable(null), + navigating: writable(null), + session + }, + page, + components: branch.map(({ node }) => node.module.default) + }; + + // leveln (instead of levels[n]) makes it easy to avoid + // unnecessary updates for layout components + for (let i = 0; i < branch.length; i += 1) { + props[`props_${i}`] = await branch[i].loaded.props; + } + + try { + rendered = options.root.render(props); + } catch (e) { + unsubscribe(); + throw e; + } + } else { + rendered = { head: '', html: '', css: '' }; + } + + // TODO strip the AMP stuff out of the build if not relevant + const links = options.amp + ? styles.size > 0 + ? `` + : '' + : [ + ...Array.from(js).map((dep) => ``), + ...Array.from(css).map((dep) => ``) + ].join('\n\t\t\t'); + + /** @type {string} */ + let init = ''; + + if (options.amp) { + init = ` + + + `; + } else if (page_config.router || page_config.hydrate) { + // prettier-ignore + init = ` + `; + } + + const head = [ + rendered.head, + styles.size && !options.amp + ? `` + : '', + links, + init + ].join('\n\n'); + + const body = options.amp + ? rendered.html + : `${rendered.html} + + ${serialized_data + .map(({ url, json }) => ``) + .join('\n\n\t\t\t')} + `.replace(/^\t{2}/gm, ''); + + /** @type {import('types.internal').Headers} */ + const headers = { + 'content-type': 'text/html' + }; + + if (maxage) { + headers['cache-control'] = `${is_private ? 'private' : 'public'}, max-age=${maxage}`; + } + + return { + status, + headers, + body: options.template({ head, body }) + }; +} + +/** + * @param {any} data + * @param {(error: Error) => void} [fail] + */ +function try_serialize(data, fail) { + try { + return devalue(data); + } catch (err) { + if (fail) fail(err); + return null; + } +} + +// Ensure we return something truthy so the client will not re-render the page over the error + +/** @param {Error} error */ +function serialize_error(error) { + if (!error) return null; + let serialized = try_serialize(error); + if (!serialized) { + const { name, message, stack } = error; + serialized = try_serialize({ name, message, stack }); + } + if (!serialized) { + serialized = '{}'; + } + return serialized; +} diff --git a/packages/kit/src/runtime/server/page/index.js b/packages/kit/src/runtime/server/page/index.js index 81f70a05dd22..dfdcea07a09a 100644 --- a/packages/kit/src/runtime/server/page/index.js +++ b/packages/kit/src/runtime/server/page/index.js @@ -1,4 +1,5 @@ import { respond } from './respond.js'; +import { respond_with_error } from './respond_with_error.js'; /** * @param {import('types').Request} request @@ -23,9 +24,7 @@ export default async function render_page(request, route, options) { request, options, $session, - route, - status: 200, - error: null + route }); if (response) { @@ -44,11 +43,10 @@ export default async function render_page(request, route, options) { }; } } else { - return await respond({ + return await respond_with_error({ request, options, $session, - route: null, status: 404, error: new Error(`Not found: ${request.path}`) }); diff --git a/packages/kit/src/runtime/server/page/load_branch.js b/packages/kit/src/runtime/server/page/load_branch.js deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/packages/kit/src/runtime/server/page/load_node.js b/packages/kit/src/runtime/server/page/load_node.js index e69de29bb2d1..8901270f0fe6 100644 --- a/packages/kit/src/runtime/server/page/load_node.js +++ b/packages/kit/src/runtime/server/page/load_node.js @@ -0,0 +1,273 @@ +import fetch, { Response } from 'node-fetch'; +import { parse, resolve, URLSearchParams } from 'url'; +import { ssr } from '../index.js'; + +const s = JSON.stringify; + +/** + * + * @param {{ + * request: import('types').Request; + * options: import('types.internal').SSRRenderOptions; + * route: import('types.internal').SSRPage; + * page: import('types.internal').Page; + * node: import('types.internal').SSRNode; + * $session: any; + * context: Record; + * is_leaf: boolean; + * }} opts + * @returns {Promise} + */ +export async function load_node({ + request, + options, + route, + page, + node, + $session, + context, + is_leaf +}) { + const { module } = node; + + let uses_credentials = false; + + /** @type {Array<{ + * url: string; + * json: string; + * }>} */ + const fetched = []; + + const loaded = module.load + ? await module.load.call(null, { + page, + get session() { + uses_credentials = true; + return $session; + }, + /** + * @param {RequestInfo} resource + * @param {RequestInit} opts + */ + fetch: async (resource, opts = {}) => { + /** @type {string} */ + let url; + + if (typeof resource === 'string') { + url = resource; + } else { + url = resource.url; + + opts = { + method: resource.method, + headers: resource.headers, + body: resource.body, + mode: resource.mode, + credentials: resource.credentials, + cache: resource.cache, + redirect: resource.redirect, + referrer: resource.referrer, + integrity: resource.integrity, + ...opts + }; + } + + if (options.local && url.startsWith(options.paths.assets)) { + // when running `start`, or prerendering, `assets` should be + // config.kit.paths.assets, but we should still be able to fetch + // assets directly from `static` + url = url.replace(options.paths.assets, ''); + } + + const parsed = parse(url); + + let response; + + if (parsed.protocol) { + // external fetch + response = await fetch( + parsed.href, + /** @type {import('node-fetch').RequestInit} */ (opts) + ); + } else { + // otherwise we're dealing with an internal fetch + const resolved = resolve(request.path, parsed.pathname); + + // handle fetch requests for static assets. e.g. prebaked data, etc. + // we need to support everything the browser's fetch supports + const filename = resolved.slice(1); + const filename_html = `${filename}/index.html`; // path may also match path/index.html + const asset = options.manifest.assets.find( + (d) => d.file === filename || d.file === filename_html + ); + + if (asset) { + // we don't have a running server while prerendering because jumping between + // processes would be inefficient so we have get_static_file instead + if (options.get_static_file) { + response = new Response(options.get_static_file(asset.file), { + headers: { + 'content-type': asset.type + } + }); + } else { + // TODO we need to know what protocol to use + response = await fetch( + `http://${page.host}/${asset.file}`, + /** @type {import('node-fetch').RequestInit} */ (opts) + ); + } + } + + if (!response) { + const headers = /** @type {import('types.internal').Headers} */ ({ ...opts.headers }); + + // TODO: fix type https://github.com/node-fetch/node-fetch/issues/1113 + if (opts.credentials !== 'omit') { + uses_credentials = true; + + headers.cookie = request.headers.cookie; + + if (!headers.authorization) { + headers.authorization = request.headers.authorization; + } + } + + const rendered = await ssr( + { + host: request.host, + method: opts.method || 'GET', + headers, + path: resolved, + body: /** @type {any} */ (opts.body), + query: new URLSearchParams(parsed.query || '') + }, + { + ...options, + fetched: url, + initiator: route + } + ); + + if (rendered) { + if (options.dependencies) { + options.dependencies.set(resolved, rendered); + } + + response = new Response(rendered.body, { + status: rendered.status, + headers: rendered.headers + }); + } + } + } + + if (response) { + const proxy = new Proxy(response, { + get(response, key, receiver) { + async function text() { + const body = await response.text(); + + /** @type {import('types.internal').Headers} */ + const headers = {}; + response.headers.forEach((value, key) => { + if (key !== 'etag' && key !== 'set-cookie') headers[key] = value; + }); + + // prettier-ignore + fetched.push({ + url, + json: `{"status":${response.status},"statusText":${s(response.statusText)},"headers":${s(headers)},"body":${escape(body)}}` + }); + + return body; + } + + if (key === 'text') { + return text; + } + + if (key === 'json') { + return async () => { + return JSON.parse(await text()); + }; + } + + // TODO arrayBuffer? + + return Reflect.get(response, key, receiver); + } + }); + + return proxy; + } + + return ( + response || + new Response('Not found', { + status: 404 + }) + ); + }, + context: { ...context } + }) + : {}; + + // if leaf node (i.e. page component) has a load function + // that returns nothing, we fall through to the next one + if (!loaded && is_leaf) return; + + return { + node, + loaded, + fetched, + uses_credentials + }; +} + +/** @type {Record} */ +const escaped = { + '<': '\\u003C', + '>': '\\u003E', + '/': '\\u002F', + '\\': '\\\\', + '\b': '\\b', + '\f': '\\f', + '\n': '\\n', + '\r': '\\r', + '\t': '\\t', + '\0': '\\0', + '\u2028': '\\u2028', + '\u2029': '\\u2029' +}; + +/** @param {string} str */ +function escape(str) { + let result = '"'; + + for (let i = 0; i < str.length; i += 1) { + const char = str.charAt(i); + const code = char.charCodeAt(0); + + if (char === '"') { + result += '\\"'; + } else if (char in escaped) { + result += escaped[char]; + } else if (code >= 0xd800 && code <= 0xdfff) { + const next = str.charCodeAt(i + 1); + + // If this is the beginning of a [high, low] surrogate pair, + // add the next two characters, otherwise escape + if (code <= 0xdbff && next >= 0xdc00 && next <= 0xdfff) { + result += char + str[++i]; + } else { + result += `\\u${code.toString(16).toUpperCase()}`; + } + } else { + result += char; + } + } + + result += '"'; + return result; +} diff --git a/packages/kit/src/runtime/server/page/render_error.js b/packages/kit/src/runtime/server/page/render_error.js deleted file mode 100644 index 44a20729b11d..000000000000 --- a/packages/kit/src/runtime/server/page/render_error.js +++ /dev/null @@ -1,15 +0,0 @@ -/** - * @param {{ - * request: import('types').Request; - * options: import('types.internal').SSRRenderOptions; - * $session: any; - * status: number; - * error: Error; - * }} opts - */ -export async function render_error({ request, options, $session, status, error }) { - const default_layout = await options.load_component(options.manifest.layout); - const default_error = await options.load_component(options.manifest.error); - - const branch = [await load_node(default_layout), {}]; -} diff --git a/packages/kit/src/runtime/server/page/respond.js b/packages/kit/src/runtime/server/page/respond.js index 97ec2dc3d029..7ce6a5164a72 100644 --- a/packages/kit/src/runtime/server/page/respond.js +++ b/packages/kit/src/runtime/server/page/respond.js @@ -1,9 +1,6 @@ -import devalue from 'devalue'; -import fetch, { Response } from 'node-fetch'; -import { writable } from 'svelte/store'; -import { parse, resolve, URLSearchParams } from 'url'; -import { normalize } from '../../load.js'; -import { ssr } from '../index.js'; +import { get_html } from './html.js'; +import { load_node } from './load_node.js'; +import { respond_with_error } from './respond_with_error.js'; const s = JSON.stringify; @@ -13,24 +10,12 @@ const s = JSON.stringify; * options: import('types.internal').SSRRenderOptions; * $session: any; * route: import('types.internal').SSRPage; - * status: number; - * error: Error * }} opts * @returns {Promise} */ -export async function respond({ request, options, $session, route, status = 200, error }) { - const serialized_session = try_serialize($session, (error) => { - throw new Error(`Failed to serialize session data: ${error.message}`); - }); - - /** @type {Array<{ - * url: string; - * json: string; - * }>} */ - const serialized_data = []; - - const match = error ? null : route.pattern.exec(request.path); - const params = error ? {} : route.params(match); +export async function respond({ request, options, $session, route }) { + const match = route.pattern.exec(request.path); + const params = route.params(match); const page = { host: request.host, @@ -39,527 +24,121 @@ export async function respond({ request, options, $session, route, status = 200, params }; - let uses_credentials = false; - - /** - * @param {RequestInfo} resource - * @param {RequestInit} opts - */ - const fetcher = async (resource, opts = {}) => { - /** @type {string} */ - let url; - - if (typeof resource === 'string') { - url = resource; - } else { - url = resource.url; - - opts = { - method: resource.method, - headers: resource.headers, - body: resource.body, - mode: resource.mode, - credentials: resource.credentials, - cache: resource.cache, - redirect: resource.redirect, - referrer: resource.referrer, - integrity: resource.integrity, - ...opts - }; - } - - if (options.local && url.startsWith(options.paths.assets)) { - // when running `start`, or prerendering, `assets` should be - // config.kit.paths.assets, but we should still be able to fetch - // assets directly from `static` - url = url.replace(options.paths.assets, ''); - } - - const parsed = parse(url); - - let response; - - if (parsed.protocol) { - // external fetch - response = await fetch(parsed.href, /** @type {import('node-fetch').RequestInit} */ (opts)); - } else { - // otherwise we're dealing with an internal fetch - const resolved = resolve(request.path, parsed.pathname); - - // handle fetch requests for static assets. e.g. prebaked data, etc. - // we need to support everything the browser's fetch supports - const filename = resolved.slice(1); - const filename_html = `${filename}/index.html`; // path may also match path/index.html - const asset = options.manifest.assets.find( - (d) => d.file === filename || d.file === filename_html - ); - - if (asset) { - // we don't have a running server while prerendering because jumping between - // processes would be inefficient so we have get_static_file instead - if (options.get_static_file) { - response = new Response(options.get_static_file(asset.file), { - headers: { - 'content-type': asset.type - } - }); - } else { - // TODO we need to know what protocol to use - response = await fetch( - `http://${page.host}/${asset.file}`, - /** @type {import('node-fetch').RequestInit} */ (opts) - ); - } - } - - if (!response) { - const headers = /** @type {import('types.internal').Headers} */ ({ ...opts.headers }); - - // TODO: fix type https://github.com/node-fetch/node-fetch/issues/1113 - if (opts.credentials !== 'omit') { - uses_credentials = true; - - headers.cookie = request.headers.cookie; - - if (!headers.authorization) { - headers.authorization = request.headers.authorization; - } - } - - const rendered = await ssr( - { - host: request.host, - method: opts.method || 'GET', - headers, - path: resolved, - body: /** @type {any} */ (opts.body), - query: new URLSearchParams(parsed.query || '') - }, - { - ...options, - fetched: url, - initiator: route - } - ); - - if (rendered) { - if (options.dependencies) { - options.dependencies.set(resolved, rendered); - } - - response = new Response(rendered.body, { - status: rendered.status, - headers: rendered.headers - }); - } - } - } - - if (response && page_config.hydrate) { - const proxy = new Proxy(response, { - get(response, key, receiver) { - async function text() { - const body = await response.text(); - - /** @type {import('types.internal').Headers} */ - const headers = {}; - response.headers.forEach((value, key) => { - if (key !== 'etag' && key !== 'set-cookie') headers[key] = value; - }); - - // prettier-ignore - serialized_data.push({ - url, - json: `{"status":${response.status},"statusText":${s(response.statusText)},"headers":${s(headers)},"body":${escape(body)}}` - }); - - return body; - } - - if (key === 'text') { - return text; - } - - if (key === 'json') { - return async () => { - return JSON.parse(await text()); - }; - } - - // TODO arrayBuffer? - - return Reflect.get(response, key, receiver); - } - }); - - return proxy; - } - - return ( - response || - new Response('Not found', { - status: 404 - }) - ); - }; - let nodes; try { - nodes = await Promise.all( - error - ? [options.load_component(options.manifest.layout)] - : [ - options.load_component(options.manifest.layout), - ...route.parts.map((id) => options.load_component(id)) - ] - ); + nodes = await Promise.all([ + options.load_component(options.manifest.layout), + ...route.parts.map((id) => options.load_component(id)) + ]); } catch (e) { - return await respond({ + return await respond_with_error({ request, options, $session, - route: null, status: 500, - error: e instanceof Error ? e : { name: 'Error', message: e.toString() } + error: e }); } - const components = []; - const props_promises = []; - - let context = {}; - let maxage; - - const page_component = error - ? { ssr: options.ssr, router: options.router, hydrate: options.hydrate, prerender: false } - : nodes[nodes.length - 1].module; + const leaf = nodes[nodes.length - 1].module; const page_config = { - ssr: 'ssr' in page_component ? page_component.ssr : options.ssr, - router: 'router' in page_component ? page_component.router : options.router, - hydrate: 'hydrate' in page_component ? page_component.hydrate : options.hydrate + ssr: 'ssr' in leaf ? leaf.ssr : options.ssr, + router: 'router' in leaf ? leaf.router : options.router, + hydrate: 'hydrate' in leaf ? leaf.hydrate : options.hydrate }; - if (options.only_render_prerenderable_pages) { - if (error) { - return { - status, - headers: {}, - body: error.message - }; - } - + if (options.only_render_prerenderable_pages && !leaf.prerender) { // if the page has `export const prerender = true`, continue, // otherwise bail out at this point - if (!page_component.prerender) { - return { - status: 204, - headers: {}, - body: null - }; - } + return { + status: 204, + headers: {}, + body: null + }; } - /** @type {{ head: string, html: string, css: string }} */ - let rendered; + /** @type {import('./types.js').Loaded[]} */ + let branch; if (page_config.ssr) { + let context = {}; + branch = []; + for (let i = 0; i < nodes.length; i += 1) { + const node = nodes[i]; + + /** @type {import('./types.js').Loaded} */ let loaded; try { - const { module } = nodes[i]; - components[i] = module.default; - - if (module.load) { - loaded = await module.load.call(null, { - page, - get session() { - uses_credentials = true; - return $session; - }, - fetch: fetcher, - context: { ...context } - }); - - if (!loaded && module === page_component) return; - } + loaded = await load_node({ + request, + options, + route, + page, + node, + $session, + context, + is_leaf: i === nodes.length - 1 + }); } catch (e) { - // if load fails when we're already rendering the - // error page, there's not a lot we can do - if (error) throw e instanceof Error ? e : new Error(e); - + // TODO loaded = { - error: e instanceof Error ? e : { name: 'Error', message: e.toString() }, - status: 500 + node: null, + loaded: { + status: 500, + error: e + }, + fetched: null, + uses_credentials: null }; } - if (loaded) { - loaded = normalize(loaded); - - // TODO there's some logic that's duplicated in the client runtime, - // it would be nice to DRY it out if possible - if (loaded.error) { - return await respond({ - request, - options, - $session, - route: null, - status: loaded.status, - error: loaded.error - }); - } - - if (loaded.redirect) { - return { - status: loaded.status, - headers: { - location: loaded.redirect - } - }; - } - - if (loaded.context) { - context = { - ...context, - ...loaded.context - }; - } - - maxage = loaded.maxage || 0; - - props_promises[i] = loaded.props; + if (loaded.loaded.error) { + // TODO backtrack until we find an $error.svelte component + // that we can use as the leaf node + // for now just return regular error page + return await respond_with_error({ + request, + options, + $session, + status: loaded.loaded.status, + error: loaded.loaded.error + }); } - } - const session = writable($session); - let session_tracking_active = false; - const unsubscribe = session.subscribe(() => { - if (session_tracking_active) uses_credentials = true; - }); - session_tracking_active = true; + branch.push(loaded); - if (error) { - if (options.dev) { - error.stack = await options.get_stack(error); - } else { - // remove error.stack in production - error.stack = String(error); + if (loaded.loaded.context) { + // TODO come up with better names for stuff + context = { + ...context, + ...loaded.loaded.context + }; } } - - /** @type {Record} */ - const props = { - status, - error, - stores: { - page: writable(null), - navigating: writable(null), - session - }, - page, - components - }; - - // leveln (instead of levels[n]) makes it easy to avoid - // unnecessary updates for layout components - for (let i = 0; i < props_promises.length; i += 1) { - props[`props_${i}`] = await props_promises[i]; - } - - try { - rendered = options.root.render(props); - } catch (e) { - if (error) throw e instanceof Error ? e : new Error(e); - - return await respond({ - request, - options, - $session, - route: null, - status: 500, - error: e instanceof Error ? e : { name: 'Error', message: e.toString() } - }); - } finally { - unsubscribe(); - } - } else { - rendered = { - head: '', - html: '', - css: '' - }; - } - - const css = new Set(); - const js = new Set(); - const styles = new Set(); - - if (page_config.ssr) { - nodes.forEach((part) => { - if (part.css) part.css.forEach((url) => css.add(url)); - if (part.js) part.js.forEach((url) => js.add(url)); - if (part.styles) part.styles.forEach((content) => styles.add(content)); - }); - } - - // TODO strip the AMP stuff out of the build if not relevant - const links = options.amp - ? styles.size > 0 - ? `` - : '' - : [ - ...Array.from(js).map((dep) => ``), - ...Array.from(css).map((dep) => ``) - ].join('\n\t\t\t'); - - /** @type {string} */ - let init = ''; - - if (options.amp) { - init = ` - - - `; - } else if (page_config.router || page_config.hydrate) { - // prettier-ignore - init = ` - `; - } - - const head = [ - rendered.head, - styles.size && !options.amp - ? `` - : '', - links, - init - ].join('\n\n'); - - const body = options.amp - ? rendered.html - : `${rendered.html} - - ${serialized_data - .map(({ url, json }) => ``) - .join('\n\n\t\t\t')} - `.replace(/^\t{2}/gm, ''); - - /** @type {import('types.internal').Headers} */ - const headers = { - 'content-type': 'text/html' - }; - - if (maxage) { - headers['cache-control'] = `${uses_credentials ? 'private' : 'public'}, max-age=${maxage}`; } - return { - status, - headers, - body: options.template({ head, body }) - }; -} - -/** - * @param {any} data - * @param {(error: Error) => void} [fail] - */ -function try_serialize(data, fail) { try { - return devalue(data); - } catch (err) { - if (fail) fail(err); - return null; - } -} - -// Ensure we return something truthy so the client will not re-render the page over the error - -/** @param {Error} error */ -function serialize_error(error) { - if (!error) return null; - let serialized = try_serialize(error); - if (!serialized) { - const { name, message, stack } = error; - serialized = try_serialize({ name, message, stack }); - } - if (!serialized) { - serialized = '{}'; - } - return serialized; -} - -/** @type {Record} */ -const escaped = { - '<': '\\u003C', - '>': '\\u003E', - '/': '\\u002F', - '\\': '\\\\', - '\b': '\\b', - '\f': '\\f', - '\n': '\\n', - '\r': '\\r', - '\t': '\\t', - '\0': '\\0', - '\u2028': '\\u2028', - '\u2029': '\\u2029' -}; - -/** @param {string} str */ -function escape(str) { - let result = '"'; - - for (let i = 0; i < str.length; i += 1) { - const char = str.charAt(i); - const code = char.charCodeAt(0); - - if (char === '"') { - result += '\\"'; - } else if (char in escaped) { - result += escaped[char]; - } else if (code >= 0xd800 && code <= 0xdfff) { - const next = str.charCodeAt(i + 1); - - // If this is the beginning of a [high, low] surrogate pair, - // add the next two characters, otherwise escape - if (code <= 0xdbff && next >= 0xdc00 && next <= 0xdfff) { - result += char + str[++i]; - } else { - result += `\\u${code.toString(16).toUpperCase()}`; - } - } else { - result += char; - } + return await get_html({ + request, + options, + $session, + page_config, + status: 200, + error: null, + branch, + page + }); + } catch (error) { + return await respond_with_error({ + request, + options, + $session, + status: 500, + error + }); } - - result += '"'; - return result; } diff --git a/packages/kit/src/runtime/server/page/respond_with_error.js b/packages/kit/src/runtime/server/page/respond_with_error.js new file mode 100644 index 000000000000..bc4fcdcf2075 --- /dev/null +++ b/packages/kit/src/runtime/server/page/respond_with_error.js @@ -0,0 +1,67 @@ +import { get_html } from './html.js'; +import { load_node } from './load_node.js'; + +/** + * @param {{ + * request: import('types').Request; + * options: import('types.internal').SSRRenderOptions; + * $session: any; + * status: number; + * error: Error; + * }} opts + */ +export async function respond_with_error({ request, options, $session, status, error }) { + const default_layout = await options.load_component(options.manifest.layout); + const default_error = await options.load_component(options.manifest.error); + + const page = { + host: request.host, + path: request.path, + query: request.query, + params: {} + }; + + const branch = [ + await load_node({ + request, + options, + route: null, + page, + node: default_layout, + $session, + context: {}, + is_leaf: false + }), + { + node: default_error, + loaded: { + maxage: 0 + }, + fetched: null, + uses_credentials: null + } + ]; + + try { + return await get_html({ + request, + options, + $session, + page_config: { + hydrate: options.hydrate, + router: options.router, + ssr: options.ssr + }, + status, + error, + branch, + page + }); + } catch (error) { + return { + status: 500, + headers: {}, + body: options.dev ? error.stack : error.message + }; + } +} diff --git a/packages/kit/src/runtime/server/page/types.d.ts b/packages/kit/src/runtime/server/page/types.d.ts new file mode 100644 index 000000000000..6e0c7de4bef9 --- /dev/null +++ b/packages/kit/src/runtime/server/page/types.d.ts @@ -0,0 +1,8 @@ +import { LoadOutput, SSRNode } from 'types.internal'; + +export type Loaded = { + node: SSRNode; + loaded: LoadOutput; + fetched: Array<{ url: string; json: string }>; + uses_credentials: boolean; +}; diff --git a/packages/kit/types.internal.d.ts b/packages/kit/types.internal.d.ts index 55ae8d56d1d3..cd3b292aa6b3 100644 --- a/packages/kit/types.internal.d.ts +++ b/packages/kit/types.internal.d.ts @@ -176,6 +176,14 @@ export type Hooks = { handle?: Handle; }; +export type SSRNode = { + module: SSRComponent; + entry: string; // client-side module corresponding to this component + css: string[]; + js: string[]; + styles: string[]; +}; + // TODO separate out runtime options from the ones fixed in dev/build export type SSRRenderOptions = { paths?: { @@ -185,15 +193,7 @@ export type SSRRenderOptions = { local?: boolean; template?: ({ head, body }: { head: string; body: string }) => string; manifest?: SSRManifest; - load_component?: ( - id: PageId - ) => Promise<{ - module: SSRComponent; - entry: string; // client-side module corresponding to this component - css: string[]; - js: string[]; - styles: string[]; - }>; + load_component?: (id: PageId) => Promise; target?: string; entry?: string; root?: SSRComponent['default']; From 528bd8ba384bd5a06bb0abfaf749d8bed31f0860 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Sun, 4 Apr 2021 22:45:38 -0400 Subject: [PATCH 5/9] tests passing --- packages/kit/src/runtime/server/page/html.js | 9 +++++---- packages/kit/src/runtime/server/page/load_node.js | 3 ++- packages/kit/src/runtime/server/page/respond.js | 11 +++++++++++ 3 files changed, 18 insertions(+), 5 deletions(-) diff --git a/packages/kit/src/runtime/server/page/html.js b/packages/kit/src/runtime/server/page/html.js index 8e659cdae2ca..a6c1a7e0a0f8 100644 --- a/packages/kit/src/runtime/server/page/html.js +++ b/packages/kit/src/runtime/server/page/html.js @@ -32,11 +32,12 @@ export async function get_html({ options, $session, page_config, status, error, if (branch) { branch.forEach(({ node, loaded, fetched, uses_credentials }) => { - node.css.forEach((url) => css.add(url)); - node.js.forEach((url) => js.add(url)); - node.styles.forEach((content) => styles.add(content)); + if (node.css) node.css.forEach((url) => css.add(url)); + if (node.js) node.js.forEach((url) => js.add(url)); + if (node.styles) node.styles.forEach((content) => styles.add(content)); - if (fetched) serialized_data.push(...fetched); + // TODO probably better if `fetched` wasn't populated unless `hydrate` + if (fetched && page_config.hydrate) serialized_data.push(...fetched); if (uses_credentials) is_private = true; diff --git a/packages/kit/src/runtime/server/page/load_node.js b/packages/kit/src/runtime/server/page/load_node.js index 8901270f0fe6..49d97091a527 100644 --- a/packages/kit/src/runtime/server/page/load_node.js +++ b/packages/kit/src/runtime/server/page/load_node.js @@ -1,5 +1,6 @@ import fetch, { Response } from 'node-fetch'; import { parse, resolve, URLSearchParams } from 'url'; +import { normalize } from '../../load.js'; import { ssr } from '../index.js'; const s = JSON.stringify; @@ -219,7 +220,7 @@ export async function load_node({ return { node, - loaded, + loaded: normalize(loaded), fetched, uses_credentials }; diff --git a/packages/kit/src/runtime/server/page/respond.js b/packages/kit/src/runtime/server/page/respond.js index 7ce6a5164a72..5481c8b15833 100644 --- a/packages/kit/src/runtime/server/page/respond.js +++ b/packages/kit/src/runtime/server/page/respond.js @@ -83,6 +83,17 @@ export async function respond({ request, options, $session, route }) { context, is_leaf: i === nodes.length - 1 }); + + if (!loaded) return; + + if (loaded.loaded.redirect) { + return { + status: loaded.loaded.status, + headers: { + location: loaded.loaded.redirect + } + }; + } } catch (e) { // TODO loaded = { From 8fcb7ec51aa9d946dcf663ac625cd24091877dd1 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Sun, 4 Apr 2021 22:47:31 -0400 Subject: [PATCH 6/9] remove trial script --- package.json | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/package.json b/package.json index 7f992f37bb1b..36a35c0d50a1 100644 --- a/package.json +++ b/package.json @@ -9,8 +9,7 @@ "check": "pnpm -r check", "lint": "pnpm -r lint", "format": "pnpm -r format", - "release": "pnpm publish --tag=next --filter=\"@sveltejs/*\" --filter=\"create-svelte\"", - "postinstall": "echo here" + "release": "pnpm publish --tag=next --filter=\"@sveltejs/*\" --filter=\"create-svelte\"" }, "repository": { "type": "git", From ba671951c55d78e8170826e18f05820b07a49292 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Sun, 4 Apr 2021 22:50:56 -0400 Subject: [PATCH 7/9] fix unsubscription --- packages/kit/src/runtime/server/page/html.js | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/packages/kit/src/runtime/server/page/html.js b/packages/kit/src/runtime/server/page/html.js index a6c1a7e0a0f8..834dd29182bd 100644 --- a/packages/kit/src/runtime/server/page/html.js +++ b/packages/kit/src/runtime/server/page/html.js @@ -44,13 +44,6 @@ export async function get_html({ options, $session, page_config, status, error, maxage = loaded.maxage; }); - const session = writable($session); - let session_tracking_active = false; - const unsubscribe = session.subscribe(() => { - if (session_tracking_active) is_private = true; - }); - session_tracking_active = true; - if (error) { if (options.dev) { error.stack = await options.get_stack(error); @@ -60,6 +53,8 @@ export async function get_html({ options, $session, page_config, status, error, } } + const session = writable($session); + /** @type {Record} */ const props = { status, @@ -79,11 +74,18 @@ export async function get_html({ options, $session, page_config, status, error, props[`props_${i}`] = await branch[i].loaded.props; } + let session_tracking_active = false; + const unsubscribe = session.subscribe(() => { + if (session_tracking_active) is_private = true; + }); + session_tracking_active = true; + try { rendered = options.root.render(props); } catch (e) { - unsubscribe(); throw e; + } finally { + unsubscribe(); } } else { rendered = { head: '', html: '', css: '' }; From 8a41e0adc54a2ea40d78bfe275c99524dd29c30a Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Sun, 4 Apr 2021 22:54:16 -0400 Subject: [PATCH 8/9] lint --- packages/kit/src/runtime/server/page/html.js | 2 -- packages/kit/src/runtime/server/page/respond.js | 2 -- packages/kit/src/runtime/server/page/types.d.ts | 2 +- 3 files changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/kit/src/runtime/server/page/html.js b/packages/kit/src/runtime/server/page/html.js index 834dd29182bd..2b3303514340 100644 --- a/packages/kit/src/runtime/server/page/html.js +++ b/packages/kit/src/runtime/server/page/html.js @@ -82,8 +82,6 @@ export async function get_html({ options, $session, page_config, status, error, try { rendered = options.root.render(props); - } catch (e) { - throw e; } finally { unsubscribe(); } diff --git a/packages/kit/src/runtime/server/page/respond.js b/packages/kit/src/runtime/server/page/respond.js index 5481c8b15833..81d869a00541 100644 --- a/packages/kit/src/runtime/server/page/respond.js +++ b/packages/kit/src/runtime/server/page/respond.js @@ -2,8 +2,6 @@ import { get_html } from './html.js'; import { load_node } from './load_node.js'; import { respond_with_error } from './respond_with_error.js'; -const s = JSON.stringify; - /** * @param {{ * request: import('types').Request; diff --git a/packages/kit/src/runtime/server/page/types.d.ts b/packages/kit/src/runtime/server/page/types.d.ts index 6e0c7de4bef9..f43ffa315402 100644 --- a/packages/kit/src/runtime/server/page/types.d.ts +++ b/packages/kit/src/runtime/server/page/types.d.ts @@ -1,4 +1,4 @@ -import { LoadOutput, SSRNode } from 'types.internal'; +import { LoadOutput, SSRNode } from '../../../../types.internal'; export type Loaded = { node: SSRNode; From af9749f8282281384bca57d80b75a662bfa26192 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Sun, 4 Apr 2021 22:56:45 -0400 Subject: [PATCH 9/9] rename some stuff --- .../src/runtime/server/page/{html.js => render.js} | 12 ++++++++++-- packages/kit/src/runtime/server/page/respond.js | 8 ++++---- .../src/runtime/server/page/respond_with_error.js | 4 ++-- 3 files changed, 16 insertions(+), 8 deletions(-) rename packages/kit/src/runtime/server/page/{html.js => render.js} (97%) diff --git a/packages/kit/src/runtime/server/page/html.js b/packages/kit/src/runtime/server/page/render.js similarity index 97% rename from packages/kit/src/runtime/server/page/html.js rename to packages/kit/src/runtime/server/page/render.js index 2b3303514340..d5626a3a16e8 100644 --- a/packages/kit/src/runtime/server/page/html.js +++ b/packages/kit/src/runtime/server/page/render.js @@ -17,7 +17,15 @@ const s = JSON.stringify; * page: import('types.internal').Page * }} opts */ -export async function get_html({ options, $session, page_config, status, error, branch, page }) { +export async function render_response({ + options, + $session, + page_config, + status, + error, + branch, + page +}) { const css = new Set(); const js = new Set(); const styles = new Set(); @@ -68,7 +76,7 @@ export async function get_html({ options, $session, page_config, status, error, components: branch.map(({ node }) => node.module.default) }; - // leveln (instead of levels[n]) makes it easy to avoid + // props_n (instead of props[n]) makes it easy to avoid // unnecessary updates for layout components for (let i = 0; i < branch.length; i += 1) { props[`props_${i}`] = await branch[i].loaded.props; diff --git a/packages/kit/src/runtime/server/page/respond.js b/packages/kit/src/runtime/server/page/respond.js index 81d869a00541..f275612dae5d 100644 --- a/packages/kit/src/runtime/server/page/respond.js +++ b/packages/kit/src/runtime/server/page/respond.js @@ -1,4 +1,4 @@ -import { get_html } from './html.js'; +import { render_response } from './render.js'; import { load_node } from './load_node.js'; import { respond_with_error } from './respond_with_error.js'; @@ -29,13 +29,13 @@ export async function respond({ request, options, $session, route }) { options.load_component(options.manifest.layout), ...route.parts.map((id) => options.load_component(id)) ]); - } catch (e) { + } catch (error) { return await respond_with_error({ request, options, $session, status: 500, - error: e + error }); } @@ -131,7 +131,7 @@ export async function respond({ request, options, $session, route }) { } try { - return await get_html({ + return await render_response({ request, options, $session, 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 bc4fcdcf2075..ee6a2fddd75a 100644 --- a/packages/kit/src/runtime/server/page/respond_with_error.js +++ b/packages/kit/src/runtime/server/page/respond_with_error.js @@ -1,4 +1,4 @@ -import { get_html } from './html.js'; +import { render_response } from './render.js'; import { load_node } from './load_node.js'; /** @@ -43,7 +43,7 @@ export async function respond_with_error({ request, options, $session, status, e ]; try { - return await get_html({ + return await render_response({ request, options, $session,