From ab702cc8af3e7c8c8ac03f078f0ebb3ccba8133c Mon Sep 17 00:00:00 2001 From: Koen Kivits Date: Wed, 18 Nov 2020 21:36:02 +0100 Subject: [PATCH 1/3] Add support for error middleware --- runtime/index.d.ts | 29 +- runtime/src/internal/manifest-server.d.ts | 15 +- .../server/middleware/get_error_handler.ts | 107 +++++ .../src/server/middleware/get_page_handler.ts | 448 +----------------- .../server/middleware/get_page_renderer.ts | 402 ++++++++++++++++ .../middleware/get_server_route_handler.ts | 22 +- runtime/src/server/middleware/index.ts | 45 +- site/content/docs/02-routing.md | 4 +- src/core/create_app.ts | 9 +- src/core/create_manifest_data.ts | 18 +- src/interfaces.ts | 1 + test/apps/errors/test.ts | 14 +- 12 files changed, 620 insertions(+), 494 deletions(-) create mode 100644 runtime/src/server/middleware/get_error_handler.ts create mode 100644 runtime/src/server/middleware/get_page_renderer.ts diff --git a/runtime/index.d.ts b/runtime/index.d.ts index f41c61204..9ef503599 100644 --- a/runtime/index.d.ts +++ b/runtime/index.d.ts @@ -32,35 +32,35 @@ declare module '@sapper/server' { export type Ignore = string | RegExp | ((uri: string) => boolean) | Ignore[]; /** - * The request object passed to middleware and server-side routes. - * These fields are common to both Polka and Express, but you are free to + * The request object passed to middleware and server-side routes. + * These fields are common to both Polka and Express, but you are free to * instead use the typings that come with the server you use. */ export interface SapperRequest extends IncomingMessage { url: string; - method: string; + method: string; baseUrl: string; - + /** * The originally requested URL, including parent router segments. */ originalUrl: string; - + /** * The path portion of the requested URL. */ path: string; - + /** * The values of named parameters within your route pattern */ params: Record; - + /** * The un-parsed querystring */ search: string | null; - + /** * The parsed querystring */ @@ -75,7 +75,13 @@ declare module '@sapper/server' { name?: string; }; } - + + export type SapperNext = (err?: any) => void; + + export type SapperHandler = (req: SapperRequest, res: SapperResponse, next: SapperNext) => void; + + export type SapperErrorHandler = (err?: any, req: SapperRequest, res: SapperResponse, next: SapperNext) => void; + export interface MiddlewareOptions { session?: (req: SapperRequest, res: SapperResponse) => unknown; ignore?: Ignore; @@ -83,7 +89,7 @@ declare module '@sapper/server' { export function middleware( opts?: MiddlewareOptions - ): (req: SapperRequest, res: SapperResponse, next: () => void) => void; + ): SapperHandler; } declare module '@sapper/service-worker' { @@ -95,7 +101,6 @@ declare module '@sapper/service-worker' { } declare module '@sapper/common' { - import type fetchType from 'node-fetch'; export type FetchResponse = Response | ReturnType; @@ -107,7 +112,7 @@ declare module '@sapper/common' { export type PageParams = Record; export type Query = Record; - + export interface PageContext { host: string; path: string; diff --git a/runtime/src/internal/manifest-server.d.ts b/runtime/src/internal/manifest-server.d.ts index fae257b8d..3a964a003 100644 --- a/runtime/src/internal/manifest-server.d.ts +++ b/runtime/src/internal/manifest-server.d.ts @@ -7,7 +7,7 @@ export const build_dir: string; export const dev: boolean; export const manifest: Manifest; -export { SapperRequest, SapperResponse } from '@sapper/server'; +export { SapperRequest, SapperResponse, SapperNext, SapperHandler, SapperErrorHandler } from '@sapper/server'; export interface SSRComponentModule { default: SSRComponent; @@ -25,9 +25,10 @@ export interface SSRComponent { export interface Manifest { server_routes: ServerRoute[]; ignore: RegExp[]; - root_comp: SSRComponentModule - error: SSRComponent - pages: ManifestPage[] + root_comp: SSRComponentModule; + error: SSRComponent; + error_handler?: SapperErrorHandler; + pages: ManifestPage[]; } export interface ManifestPage { @@ -42,10 +43,12 @@ export interface ManifestPagePart { params?: (match: RegExpMatchArray | null) => Record; } -export type Handler = (req: SapperRequest, res: SapperResponse, next: () => void) => void; +export interface HttpError extends Error { + statusCode?: number; +} export interface ServerRoute { pattern: RegExp; - handlers: Record; + handlers: Record; params: (match: RegExpMatchArray) => Record; } diff --git a/runtime/src/server/middleware/get_error_handler.ts b/runtime/src/server/middleware/get_error_handler.ts new file mode 100644 index 000000000..b56e82253 --- /dev/null +++ b/runtime/src/server/middleware/get_error_handler.ts @@ -0,0 +1,107 @@ +import { sourcemap_stacktrace } from './sourcemap_stacktrace'; +import { + Manifest, + SapperRequest, + SapperResponse, + SapperNext, + SapperErrorHandler, + dev +} from '@sapper/internal/manifest-server'; +import { PageRenderer } from './get_page_renderer'; + +export function get_error_handler( + manifest: Manifest, + page_renderer: PageRenderer +): SapperErrorHandler { + const { error_handler, error: error_route } = manifest; + + function on_error(err) { + if (err instanceof Error && err.stack) { + err.stack = sourcemap_stacktrace(err.stack); + } + + console.error(err); + } + + function render_plain(err, req, res) { + if (!dev) { + if (res.statusCode === 404) { + return res.end('Not found'); + } else { + return res.end('Internal server error'); + } + } + + let errText = err.toString(); + if (err.stack) { + errText += `\n${err.stack}`; + } + + const contentType = res.getHeader('Content-Type'); + const sendsHtml = ( + !contentType || + contentType.toLowerCase().includes('text/html') + ); + const needsHtml = (sendsHtml && res.headersSent); + + if (needsHtml) { + errText = escape_html(errText); + } else { + res.setHeader('Content-Type', 'text/plain'); + } + + res.end(errText); + } + + async function default_error_handler(err, req, res) { + on_error(err); + + res.statusCode = err.status ?? err.statusCode ?? 500; + + try { + await render_page(err, req, res); + } catch (renderErr) { + on_error(renderErr); + await render_plain(err, req, res); + } + } + + function render_page(err, req, res) { + return page_renderer({ + pattern: null, + parts: [ + { name: null, component: { default: error_route } } + ] + }, req, res, err); + } + + return async function handle_error(err: any, req: SapperRequest, res: SapperResponse, next: SapperNext) { + err = err || 'Unknown error'; + + if (error_handler) { + try { + await error_handler(err, req, res, (handler_err?: any) => { + process.nextTick(() => default_error_handler(handler_err || err, req, res)); + }); + } catch (handler_err) { + on_error(handler_err); + + default_error_handler(err, req, res); + } + } else { + default_error_handler(err, req, res); + } + }; +} + +function escape_html(html: string) { + const chars: Record = { + '"' : 'quot', + '\'': '#39', + '&': 'amp', + '<' : 'lt', + '>' : 'gt' + }; + + return html.replace(/["'&<>]/g, c => `&${chars[c]};`); +} diff --git a/runtime/src/server/middleware/get_page_handler.ts b/runtime/src/server/middleware/get_page_handler.ts index 52ae7ad12..8bb642d45 100644 --- a/runtime/src/server/middleware/get_page_handler.ts +++ b/runtime/src/server/middleware/get_page_handler.ts @@ -1,447 +1,31 @@ -import { writable } from 'svelte/store'; -import fs from 'fs'; -import path from 'path'; -import { parse } from 'cookie'; -import devalue from 'devalue'; -import fetch from 'node-fetch'; -import URL from 'url'; -import { sourcemap_stacktrace } from './sourcemap_stacktrace'; import { - Manifest, - ManifestPage, - SapperRequest, - SapperResponse, - build_dir, - dev, - src_dir + Manifest, + HttpError, + SapperHandler } from '@sapper/internal/manifest-server'; -import App from '@sapper/internal/App.svelte'; -import { PageContext, PreloadResult } from '@sapper/common'; -import detectClientOnlyReferences from './detect_client_only_references'; +import { PageRenderer } from './get_page_renderer'; export function get_page_handler( manifest: Manifest, - session_getter: (req: SapperRequest, res: SapperResponse) => Promise -) { - const get_build_info = dev - ? () => JSON.parse(fs.readFileSync(path.join(build_dir, 'build.json'), 'utf-8')) - : (assets => () => assets)(JSON.parse(fs.readFileSync(path.join(build_dir, 'build.json'), 'utf-8'))); + render_page: PageRenderer +): SapperHandler { + const { pages } = manifest; - const template = dev - ? () => read_template(src_dir) - : (str => () => str)(read_template(build_dir)); - - const has_service_worker = fs.existsSync(path.join(build_dir, 'service-worker.js')); - - const { pages, error: error_route } = manifest; - - function bail(res: SapperResponse, err: Error | string) { - console.error(err); - - const message = dev ? escape_html(typeof err === 'string' ? err : err.message) : 'Internal server error'; - - res.statusCode = 500; - res.end(`
${message}
`); - } - - function handle_error(req: SapperRequest, res: SapperResponse, statusCode: number, error: Error | string) { - handle_page({ - pattern: null, - parts: [ - { name: null, component: { default: error_route } } - ] - }, req, res, statusCode, error || 'Unknown error'); - } - - async function handle_page( - page: ManifestPage, - req: SapperRequest, - res: SapperResponse, - status = 200, - error: Error | string = null) { - const is_service_worker_index = req.path === '/service-worker-index.html'; - const build_info: { - bundler: 'rollup' | 'webpack', - shimport: string | null, - assets: Record, - dependencies: Record, - css?: { main: string[] }, - legacy_assets?: Record - } = get_build_info(); - - res.setHeader('Content-Type', 'text/html'); - - // preload main js and css - // TODO detect other stuff we can preload like fonts? - let preload_files = Array.isArray(build_info.assets.main) ? build_info.assets.main : [build_info.assets.main]; - if (build_info?.css?.main) { - preload_files = preload_files.concat(build_info?.css?.main); - } - - let es6_preload = false; - if (build_info.bundler === 'rollup') { - es6_preload = true; - const route = page.parts[page.parts.length - 1].file; - const deps = build_info.dependencies[route]; - if (deps) { - preload_files = preload_files.concat(deps); - } - } else if (!error && !is_service_worker_index) { - page.parts.forEach(part => { - if (!part) return; - // using concat because it could be a string or an array. thanks webpack! - preload_files = preload_files.concat(build_info.assets[part.name]); - }); - } - - const link = preload_files - .filter((v, i, a) => a.indexOf(v) === i) // remove any duplicates - .filter(file => file && !file.match(/\.map$/)) // exclude source maps - .map((file) => { - const as = /\.css$/.test(file) ? 'style' : 'script'; - const rel = es6_preload && as === 'script' ? 'modulepreload' : 'preload'; - return `<${req.baseUrl}/client/${file}>;rel="${rel}";as="${as}"`; - }) - .join(', '); - - res.setHeader('Link', link); - - let session; - try { - session = await session_getter(req, res); - } catch (err) { - return bail(res, err); - } - - let redirect: { statusCode: number, location: string }; - let preload_error: { statusCode: number, message: Error | string }; - - const preload_context = { - redirect: (statusCode: number, location: string) => { - if (redirect && (redirect.statusCode !== statusCode || redirect.location !== location)) { - throw new Error('Conflicting redirects'); - } - location = location.replace(/^\//g, ''); // leading slash (only) - redirect = { statusCode, location }; - }, - error: (statusCode: number, message: Error | string) => { - preload_error = { statusCode, message }; - }, - fetch: (url: string, opts?: any) => { - const protocol = req.socket.encrypted ? 'https' : 'http'; - const parsed = new URL.URL(url, `${protocol}://127.0.0.1:${process.env.PORT}${req.baseUrl ? req.baseUrl + '/' :''}`); - - opts = Object.assign({}, opts); - - const include_credentials = ( - opts.credentials === 'include' || - opts.credentials !== 'omit' && parsed.origin === `${protocol}://127.0.0.1:${process.env.PORT}` - ); - - if (include_credentials) { - opts.headers = Object.assign({}, opts.headers); - - const cookies = Object.assign( - {}, - parse(req.headers.cookie || ''), - parse(opts.headers.cookie || '') - ); - - const set_cookie = res.getHeader('Set-Cookie'); - (Array.isArray(set_cookie) ? set_cookie : [set_cookie]).forEach((s: string) => { - const m = /([^=]+)=([^;]+)/.exec(s); - if (m) cookies[m[1]] = m[2]; - }); - - const str = Object.keys(cookies) - .map(key => `${key}=${cookies[key]}`) - .join('; '); - - opts.headers.cookie = str; - - if (!opts.headers.authorization && req.headers.authorization) { - opts.headers.authorization = req.headers.authorization; - } - } - - return fetch(parsed.href, opts); - } - }; - - let preloaded: object[]; - let match: RegExpExecArray; - let params: Record; - - try { - const root_preload = manifest.root_comp.preload || (() => {}); - const root_preloaded: PreloadResult = detectClientOnlyReferences(() => - root_preload.call( - preload_context, - { - host: req.headers.host, - path: req.path, - query: req.query, - params: {} - }, - session - ) - ); - - match = error ? null : page.pattern.exec(req.path); - - let toPreload: PreloadResult[] = [root_preloaded]; - if (!is_service_worker_index) { - toPreload = toPreload.concat(page.parts.map(part => { - if (!part) return null; - - // the deepest level is used below, to initialise the store - params = part.params ? part.params(match) : {}; - - return part.component.preload - ? detectClientOnlyReferences(() => - part.component.preload.call( - preload_context, - { - host: req.headers.host, - path: req.path, - query: req.query, - params - }, - session - ) - ) - : {}; - })); - } - - preloaded = await Promise.all(toPreload); - } catch (err) { - if (error) { - return bail(res, err); - } - - preload_error = { statusCode: 500, message: err }; - preloaded = []; // appease TypeScript - } - - try { - if (redirect) { - const location = URL.resolve((req.baseUrl || '') + '/', redirect.location); - - res.statusCode = redirect.statusCode; - res.setHeader('Location', location); - res.end(); - - return; - } - - if (preload_error) { - if (!error) { - handle_error(req, res, preload_error.statusCode, preload_error.message); - } else { - bail(res, preload_error.message); - } - - return; - } - - const segments = req.path.split('/').filter(Boolean); - - // TODO make this less confusing - const layout_segments = [segments[0]]; - let l = 1; - - page.parts.forEach((part, i) => { - layout_segments[l] = segments[i + 1]; - if (!part) return null; - l++; - }); - - if (error instanceof Error && error.stack) { - error.stack = sourcemap_stacktrace(error.stack); - } - - const pageContext: PageContext = { - host: req.headers.host, - path: req.path, - query: req.query, - params, - error: error - ? error instanceof Error - ? error - : { message: error, name: 'PreloadError' } - : null - }; - - const props = { - stores: { - page: { - subscribe: writable(pageContext).subscribe - }, - preloading: { - subscribe: writable(null).subscribe - }, - session: writable(session) - }, - segments: layout_segments, - status: error ? status : 200, - error: pageContext.error, - level0: { - props: preloaded[0] - }, - level1: { - segment: segments[0], - props: {} - } - }; - - if (!is_service_worker_index) { - let level_index = 1; - for (let i = 0; i < page.parts.length; i += 1) { - const part = page.parts[i]; - if (!part) continue; - - props[`level${level_index++}`] = { - component: part.component.default, - props: preloaded[i + 1] || {}, - segment: segments[i] - }; - } - } - - const { html, head, css } = detectClientOnlyReferences(() => App.render(props)); - - const serialized = { - preloaded: `[${preloaded.map(data => try_serialize(data, err => { - console.error(`Failed to serialize preloaded data to transmit to the client at the /${segments.join('/')} route: ${err.message}`); - console.warn('The client will re-render over the server-rendered page fresh instead of continuing where it left off. See https://sapper.svelte.dev/docs#Return_value for more information'); - })).join(',')}]`, - session: session && try_serialize(session, err => { - throw new Error(`Failed to serialize session data: ${err.message}`); - }), - error: error && serialize_error(props.error) - }; - - let script = `__SAPPER__={${[ - error && `error:${serialized.error},status:${status}`, - `baseUrl:"${req.baseUrl}"`, - serialized.preloaded && `preloaded:${serialized.preloaded}`, - serialized.session && `session:${serialized.session}` - ].filter(Boolean).join(',')}};`; - - if (has_service_worker) { - script += `if('serviceWorker' in navigator)navigator.serviceWorker.register('${req.baseUrl}/service-worker.js');`; - } - - const file = [].concat(build_info.assets.main).filter(f => f && /\.js$/.test(f))[0]; - const main = `${req.baseUrl}/client/${file}`; - - // users can set a CSP nonce using res.locals.nonce - const nonce_value = (res.locals && res.locals.nonce) ? res.locals.nonce : ''; - const nonce_attr = nonce_value ? ` nonce="${nonce_value}"` : ''; - - if (build_info.bundler === 'rollup') { - if (build_info.legacy_assets) { - const legacy_main = `${req.baseUrl}/client/legacy/${build_info.legacy_assets.main}`; - script += `(function(){try{eval("async function x(){}");var main="${main}"}catch(e){main="${legacy_main}"};var s=document.createElement("script");try{new Function("if(0)import('')")();s.src=main;s.type="module";s.crossOrigin="use-credentials";}catch(e){s.src="${req.baseUrl}/client/shimport@${build_info.shimport}.js";s.setAttribute("data-main",main);}document.head.appendChild(s);}());`; - } else { - script += `var s=document.createElement("script");try{new Function("if(0)import('')")();s.src="${main}";s.type="module";s.crossOrigin="use-credentials";}catch(e){s.src="${req.baseUrl}/client/shimport@${build_info.shimport}.js";s.setAttribute("data-main","${main}")}document.head.appendChild(s)`; - } - } else { - script += ``; - } - - let styles: string; - - // TODO make this consistent across apps - // TODO embed build_info in placeholder.ts - if (build_info.css && build_info.css.main) { - const css_chunks = new Set(build_info.css.main); - page.parts.forEach(part => { - if (!part || !build_info.dependencies) return; - const deps_for_part = build_info.dependencies[part.file]; - - if (deps_for_part) { - deps_for_part.filter(d => d.endsWith('.css')).forEach(chunk => { - css_chunks.add(chunk); - }); - } - }); - - styles = Array.from(css_chunks) - .map(href => ``) - .join(''); - } else { - styles = (css && css.code ? `${css.code}` : ''); - } - - const body = template() - .replace('%sapper.base%', () => ``) - .replace('%sapper.scripts%', () => `${script}`) - .replace('%sapper.html%', () => html) - .replace('%sapper.head%', () => head) - .replace('%sapper.styles%', () => styles) - .replace(/%sapper\.cspnonce%/g, () => nonce_value); - - res.statusCode = status; - res.end(body); - } catch (err) { - if (error) { - bail(res, err); - } else { - handle_error(req, res, 500, err); - } - } - } - - return function find_route(req: SapperRequest, res: SapperResponse, next: () => void) { + return async function find_route(req, res, next) { const req_path = req.path === '/service-worker-index.html' ? '/' : req.path; const page = pages.find(p => p.pattern.test(req_path)); if (page) { - handle_page(page, req, res); + try { + await render_page(page, req, res); + } catch (err) { + next(err); + } } else { - handle_error(req, res, 404, 'Not found'); + const err: HttpError = new Error('Not found'); + err.statusCode = 404; + next(err); } }; } - -function read_template(dir = build_dir) { - return fs.readFileSync(`${dir}/template.html`, 'utf-8'); -} - -function try_serialize(data: any, fail?: (err: Error) => void) { - 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 -function serialize_error(error: Error) { - if (!error) return null; - let serialized = try_serialize(error); - if (!serialized) { - const { name, message, stack } = error as Error; - serialized = try_serialize({ name, message, stack }); - } - if (!serialized) { - serialized = '{}'; - } - return serialized; -} - -function escape_html(html: string) { - const chars: Record = { - '"' : 'quot', - '\'': '#39', - '&': 'amp', - '<' : 'lt', - '>' : 'gt' - }; - - return html.replace(/["'&<>]/g, c => `&${chars[c]};`); -} diff --git a/runtime/src/server/middleware/get_page_renderer.ts b/runtime/src/server/middleware/get_page_renderer.ts new file mode 100644 index 000000000..1ad427da3 --- /dev/null +++ b/runtime/src/server/middleware/get_page_renderer.ts @@ -0,0 +1,402 @@ +import { writable } from 'svelte/store'; +import fs from 'fs'; +import path from 'path'; +import { parse } from 'cookie'; +import devalue from 'devalue'; +import fetch from 'node-fetch'; +import URL from 'url'; +import { + Manifest, + ManifestPage, + SapperRequest, + SapperResponse, + HttpError, + build_dir, + dev, + src_dir +} from '@sapper/internal/manifest-server'; +import App from '@sapper/internal/App.svelte'; +import { PageContext, PreloadResult } from '@sapper/common'; +import detectClientOnlyReferences from './detect_client_only_references'; + +export interface PageRenderer { + ( + page: ManifestPage, + req: SapperRequest, + res: SapperResponse, + error?: any + ): Promise; +} + +export function get_page_renderer( + manifest: Manifest, + session_getter: (req: SapperRequest, res: SapperResponse) => Promise +): PageRenderer { + const get_build_info = dev + ? () => JSON.parse(fs.readFileSync(path.join(build_dir, 'build.json'), 'utf-8')) + : (assets => () => assets)(JSON.parse(fs.readFileSync(path.join(build_dir, 'build.json'), 'utf-8'))); + + const template = dev + ? () => read_template(src_dir) + : (str => () => str)(read_template(build_dir)); + + const has_service_worker = fs.existsSync(path.join(build_dir, 'service-worker.js')); + + return async function render_page( + page: ManifestPage, + req: SapperRequest, + res: SapperResponse, + error: any = null + ) { + const statusCode = error ? (error.status ?? error.statusCode ?? 500) : 200; + + const is_service_worker_index = req.path === '/service-worker-index.html'; + const build_info: { + bundler: 'rollup' | 'webpack', + shimport: string | null, + assets: Record, + dependencies: Record, + css?: { main: string[] }, + legacy_assets?: Record + } = get_build_info(); + + res.setHeader('Content-Type', 'text/html'); + + // preload main js and css + // TODO detect other stuff we can preload like fonts? + let preload_files = Array.isArray(build_info.assets.main) ? build_info.assets.main : [build_info.assets.main]; + if (build_info?.css?.main) { + preload_files = preload_files.concat(build_info?.css?.main); + } + + let es6_preload = false; + if (build_info.bundler === 'rollup') { + es6_preload = true; + const route = page.parts[page.parts.length - 1].file; + const deps = build_info.dependencies[route]; + if (deps) { + preload_files = preload_files.concat(deps); + } + } else if (!error && !is_service_worker_index) { + page.parts.forEach(part => { + if (!part) return; + // using concat because it could be a string or an array. thanks webpack! + preload_files = preload_files.concat(build_info.assets[part.name]); + }); + } + + const link = preload_files + .filter((v, i, a) => a.indexOf(v) === i) // remove any duplicates + .filter(file => file && !file.match(/\.map$/)) // exclude source maps + .map((file) => { + const as = /\.css$/.test(file) ? 'style' : 'script'; + const rel = es6_preload && as === 'script' ? 'modulepreload' : 'preload'; + return `<${req.baseUrl}/client/${file}>;rel="${rel}";as="${as}"`; + }) + .join(', '); + + res.setHeader('Link', link); + + const session = await session_getter(req, res); + + let redirect: { statusCode: number, location: string }; + let preload_error: HttpError; + + const preload_context = { + redirect: (statusCode: number, location: string) => { + if (redirect && (redirect.statusCode !== statusCode || redirect.location !== location)) { + throw new Error('Conflicting redirects'); + } + location = location.replace(/^\//g, ''); // leading slash (only) + redirect = { statusCode, location }; + }, + error: (statusCode: number, error: any) => { + preload_error = http_error(statusCode, error); + }, + fetch: (url: string, opts?: any) => { + const protocol = req.socket.encrypted ? 'https' : 'http'; + const parsed = new URL.URL(url, `${protocol}://127.0.0.1:${process.env.PORT}${req.baseUrl ? req.baseUrl + '/' :''}`); + + opts = Object.assign({}, opts); + + const include_credentials = ( + opts.credentials === 'include' || + opts.credentials !== 'omit' && parsed.origin === `${protocol}://127.0.0.1:${process.env.PORT}` + ); + + if (include_credentials) { + opts.headers = Object.assign({}, opts.headers); + + const cookies = Object.assign( + {}, + parse(req.headers.cookie || ''), + parse(opts.headers.cookie || '') + ); + + const set_cookie = res.getHeader('Set-Cookie'); + (Array.isArray(set_cookie) ? set_cookie : [set_cookie]).forEach((s: string) => { + const m = /([^=]+)=([^;]+)/.exec(s); + if (m) cookies[m[1]] = m[2]; + }); + + const str = Object.keys(cookies) + .map(key => `${key}=${cookies[key]}`) + .join('; '); + + opts.headers.cookie = str; + + if (!opts.headers.authorization && req.headers.authorization) { + opts.headers.authorization = req.headers.authorization; + } + } + + return fetch(parsed.href, opts); + } + }; + + let preloaded: object[]; + let match: RegExpExecArray; + let params: Record; + + try { + const root_preload = manifest.root_comp.preload || (() => {}); + const root_preloaded: PreloadResult = detectClientOnlyReferences(() => + root_preload.call( + preload_context, + { + host: req.headers.host, + path: req.path, + query: req.query, + params: {} + }, + session + ) + ); + + match = error ? null : page.pattern.exec(req.path); + + let toPreload: PreloadResult[] = [root_preloaded]; + if (!is_service_worker_index) { + toPreload = toPreload.concat(page.parts.map(part => { + if (!part) return null; + + // the deepest level is used below, to initialise the store + params = part.params ? part.params(match) : {}; + + return part.component.preload + ? detectClientOnlyReferences(() => + part.component.preload.call( + preload_context, + { + host: req.headers.host, + path: req.path, + query: req.query, + params + }, + session + ) + ) + : {}; + })); + } + + preloaded = await Promise.all(toPreload); + } catch (err) { + if (error) { + // encountering a new error while rendering an error page, bounce + throw err; + } + + preload_error = http_error(500, err); + preloaded = []; // appease TypeScript + } + + if (redirect) { + const location = URL.resolve((req.baseUrl || '') + '/', redirect.location); + + res.statusCode = redirect.statusCode; + res.setHeader('Location', location); + res.end(); + + return; + } + + if (preload_error) { + throw preload_error; + } + + const segments = req.path.split('/').filter(Boolean); + + // TODO make this less confusing + const layout_segments = [segments[0]]; + let l = 1; + + page.parts.forEach((part, i) => { + layout_segments[l] = segments[i + 1]; + if (!part) return null; + l++; + }); + + let contextError: Error; + if (error instanceof Error) { + contextError = error; + } else if (error) { + contextError = new Error(error); + contextError.name = 'PreloadError'; + } + + const pageContext: PageContext = { + host: req.headers.host, + path: req.path, + query: req.query, + params, + error: contextError + }; + + const props = { + stores: { + page: { + subscribe: writable(pageContext).subscribe + }, + preloading: { + subscribe: writable(null).subscribe + }, + session: writable(session) + }, + segments: layout_segments, + status: statusCode, + error: pageContext.error, + level0: { + props: preloaded[0] + }, + level1: { + segment: segments[0], + props: {} + } + }; + + if (!is_service_worker_index) { + let level_index = 1; + for (let i = 0; i < page.parts.length; i += 1) { + const part = page.parts[i]; + if (!part) continue; + + props[`level${level_index++}`] = { + component: part.component.default, + props: preloaded[i + 1] || {}, + segment: segments[i] + }; + } + } + + const { html, head, css } = detectClientOnlyReferences(() => App.render(props)); + + const serialized = { + preloaded: `[${preloaded.map(data => try_serialize(data, err => { + console.error(`Failed to serialize preloaded data to transmit to the client at the /${segments.join('/')} route: ${err.message}`); + console.warn('The client will re-render over the server-rendered page fresh instead of continuing where it left off. See https://sapper.svelte.dev/docs#Return_value for more information'); + })).join(',')}]`, + session: session && try_serialize(session, err => { + throw new Error(`Failed to serialize session data: ${err.message}`); + }), + error: error && serialize_error(props.error) + }; + + let script = `__SAPPER__={${[ + error && `error:${serialized.error},status:${statusCode}`, + `baseUrl:"${req.baseUrl}"`, + serialized.preloaded && `preloaded:${serialized.preloaded}`, + serialized.session && `session:${serialized.session}` + ].filter(Boolean).join(',')}};`; + + if (has_service_worker) { + script += `if('serviceWorker' in navigator)navigator.serviceWorker.register('${req.baseUrl}/service-worker.js');`; + } + + const file = [].concat(build_info.assets.main).filter(f => f && /\.js$/.test(f))[0]; + const main = `${req.baseUrl}/client/${file}`; + + // users can set a CSP nonce using res.locals.nonce + const nonce_value = (res.locals && res.locals.nonce) ? res.locals.nonce : ''; + const nonce_attr = nonce_value ? ` nonce="${nonce_value}"` : ''; + + if (build_info.bundler === 'rollup') { + if (build_info.legacy_assets) { + const legacy_main = `${req.baseUrl}/client/legacy/${build_info.legacy_assets.main}`; + script += `(function(){try{eval("async function x(){}");var main="${main}"}catch(e){main="${legacy_main}"};var s=document.createElement("script");try{new Function("if(0)import('')")();s.src=main;s.type="module";s.crossOrigin="use-credentials";}catch(e){s.src="${req.baseUrl}/client/shimport@${build_info.shimport}.js";s.setAttribute("data-main",main);}document.head.appendChild(s);}());`; + } else { + script += `var s=document.createElement("script");try{new Function("if(0)import('')")();s.src="${main}";s.type="module";s.crossOrigin="use-credentials";}catch(e){s.src="${req.baseUrl}/client/shimport@${build_info.shimport}.js";s.setAttribute("data-main","${main}")}document.head.appendChild(s)`; + } + } else { + script += ``; + } + + let styles: string; + + // TODO make this consistent across apps + // TODO embed build_info in placeholder.ts + if (build_info.css && build_info.css.main) { + const css_chunks = new Set(build_info.css.main); + page.parts.forEach(part => { + if (!part || !build_info.dependencies) return; + const deps_for_part = build_info.dependencies[part.file]; + + if (deps_for_part) { + deps_for_part.filter(d => d.endsWith('.css')).forEach(chunk => { + css_chunks.add(chunk); + }); + } + }); + + styles = Array.from(css_chunks) + .map(href => ``) + .join(''); + } else { + styles = (css && css.code ? `${css.code}` : ''); + } + + const body = template() + .replace('%sapper.base%', () => ``) + .replace('%sapper.scripts%', () => `${script}`) + .replace('%sapper.html%', () => html) + .replace('%sapper.head%', () => head) + .replace('%sapper.styles%', () => styles) + .replace(/%sapper\.cspnonce%/g, () => nonce_value); + + res.statusCode = statusCode; + res.end(body); + }; +} + +function http_error(statusCode: number, err: any) { + const http_err: HttpError = err instanceof Error ? err : new Error(err); + http_err.statusCode = statusCode; + return http_err; +} + +function read_template(dir = build_dir) { + return fs.readFileSync(`${dir}/template.html`, 'utf-8'); +} + +function try_serialize(data: any, fail?: (err: Error) => void) { + 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 +function serialize_error(error: Error) { + if (!error) return null; + let serialized = try_serialize(error); + if (!serialized) { + const { name, message, stack } = error as Error; + serialized = try_serialize({ name, message, stack }); + } + if (!serialized) { + serialized = '{}'; + } + return serialized; +} diff --git a/runtime/src/server/middleware/get_server_route_handler.ts b/runtime/src/server/middleware/get_server_route_handler.ts index 1aa30e64c..b03cdfdf3 100644 --- a/runtime/src/server/middleware/get_server_route_handler.ts +++ b/runtime/src/server/middleware/get_server_route_handler.ts @@ -1,7 +1,11 @@ -import { SapperRequest, SapperResponse, ServerRoute } from '@sapper/internal/manifest-server'; +import { SapperRequest, SapperResponse, SapperNext, ServerRoute } from '@sapper/internal/manifest-server'; export function get_server_route_handler(routes: ServerRoute[]) { - async function handle_route(route: ServerRoute, req: SapperRequest, res: SapperResponse, next: () => void) { + async function handle_route(route: ServerRoute, req: SapperRequest, res: SapperResponse, next: SapperNext) { + const handle_next = (err?: Error) => { + process.nextTick(() => next(err)); + }; + req.params = route.params(route.pattern.exec(req.path)); const method = req.method.toLowerCase(); @@ -42,28 +46,18 @@ export function get_server_route_handler(routes: ServerRoute[]) { }; } - const handle_next = (err?: Error) => { - if (err) { - res.statusCode = 500; - res.end(err.message); - } else { - process.nextTick(next); - } - }; - try { await handle_method(req, res, handle_next); } catch (err) { - console.error(err); handle_next(err); } } else { // no matching handler for method - process.nextTick(next); + handle_next(); } } - return function find_route(req: SapperRequest, res: SapperResponse, next: () => void) { + return function find_route(req: SapperRequest, res: SapperResponse, next: SapperNext) { for (const route of routes) { if (route.pattern.test(req.path)) { handle_route(route, req, res, next); diff --git a/runtime/src/server/middleware/index.ts b/runtime/src/server/middleware/index.ts index 743d95c80..7ce8a7cb3 100644 --- a/runtime/src/server/middleware/index.ts +++ b/runtime/src/server/middleware/index.ts @@ -1,22 +1,29 @@ import fs from 'fs'; import path from 'path'; import mime from 'mime/lite'; -import { Handler, SapperRequest, SapperResponse, build_dir, dev, manifest } from '@sapper/internal/manifest-server'; +import { SapperRequest, SapperResponse, SapperNext, SapperHandler, SapperErrorHandler, build_dir, dev, manifest } from '@sapper/internal/manifest-server'; +import { get_page_renderer } from './get_page_renderer'; import { get_server_route_handler } from './get_server_route_handler'; import { get_page_handler } from './get_page_handler'; +import { get_error_handler } from './get_error_handler'; type IgnoreValue = IgnoreValue[] | RegExp | ((uri: string) => boolean) | string; export default function middleware(opts: { session?: (req: SapperRequest, res: SapperResponse) => any, - ignore?: IgnoreValue + ignore?: IgnoreValue, + catchErrors?: boolean } = {}) { const { session, ignore } = opts; let emitted_basepath = false; + const page_renderer = get_page_renderer(manifest, session || noop); + const page_handler = get_page_handler(manifest, page_renderer); + const error_handler = get_error_handler(manifest, page_renderer); + return compose_handlers(ignore, [ - (req: SapperRequest, res: SapperResponse, next: () => void) => { + (req: SapperRequest, res: SapperResponse, next: SapperNext) => { if (req.baseUrl === undefined) { let originalUrl = req.originalUrl || req.url; if (req.url === '/' && originalUrl[originalUrl.length - 1] !== '/') { @@ -62,28 +69,37 @@ export default function middleware(opts: { get_server_route_handler(manifest.server_routes), - get_page_handler(manifest, session || noop) - ].filter(Boolean)); + page_handler + ].filter(Boolean), error_handler); } -export function compose_handlers(ignore: IgnoreValue, handlers: Handler[]): Handler { +export function compose_handlers(ignore: IgnoreValue, handlers: SapperHandler[], error_handler: SapperErrorHandler): SapperHandler { const total = handlers.length; - function nth_handler(n: number, req: SapperRequest, res: SapperResponse, next: () => void) { + function nth_handler(n: number, req: SapperRequest, res: SapperResponse, next: SapperNext, error_next: SapperNext) { if (n >= total) { return next(); } - handlers[n](req, res, () => nth_handler(n+1, req, res, next)); + const handler = handlers[n]; + const handler_next: SapperNext = (err) => { + if (err) { + error_next(err); + } else { + nth_handler(n+1, req, res, next, error_next); + } + }; + + handler(req, res, handler_next); } return !ignore - ? (req, res, next) => nth_handler(0, req, res, next) + ? (req, res, next) => nth_handler(0, req, res, next, (err) => error_handler(err, req, res, next)) : (req, res, next) => { if (should_ignore(req.path, ignore)) { next(); } else { - nth_handler(0, req, res, next); + nth_handler(0, req, res, next, (err) => error_handler(err, req, res, next)); } }; } @@ -99,7 +115,7 @@ export function serve({ prefix, pathname, cache_control }: { prefix?: string, pathname?: string, cache_control: string -}) { +}): SapperHandler { const filter = pathname ? (req: SapperRequest) => req.path === pathname : (req: SapperRequest) => req.path.startsWith(prefix); @@ -110,7 +126,7 @@ export function serve({ prefix, pathname, cache_control }: { ? (file: string) => fs.readFileSync(path.join(build_dir, file)) : (file: string) => (cache.has(file) ? cache : cache.set(file, fs.readFileSync(path.join(build_dir, file)))).get(file); - return (req: SapperRequest, res: SapperResponse, next: () => void) => { + return (req: SapperRequest, res: SapperResponse, next: SapperNext) => { if (filter(req)) { const type = mime.getType(req.path); @@ -125,10 +141,7 @@ export function serve({ prefix, pathname, cache_control }: { if (err.code === 'ENOENT') { next(); } else { - console.error(err); - - res.statusCode = 500; - res.end('an error occurred while reading a static file from disk'); + next(err); } } } else { diff --git a/site/content/docs/02-routing.md b/site/content/docs/02-routing.md index cffccdc15..25cd94da5 100644 --- a/site/content/docs/02-routing.md +++ b/site/content/docs/02-routing.md @@ -118,9 +118,9 @@ export async function get(req, res, next) { If you are using TypeScript, use the following types: ```js -import { SapperRequest, SapperResponse } from '@sapper/server'; +import { SapperRequest, SapperResponse, SapperNext } from '@sapper/server'; -function get(req: SapperRequest, res: SapperResponse, next: () => void) { ... } +function get(req: SapperRequest, res: SapperResponse, next: SapperNext) { ... } ``` `SapperRequest` and `SapperResponse` will work with both Polka and Express. You can replace them with the types specific to your server, which are `polka.Request` / `http.ServerResponse` and `express.Request` / `express.Response`, respectively. diff --git a/src/core/create_app.ts b/src/core/create_app.ts index 7a33bcb29..cc59c2f75 100644 --- a/src/core/create_app.ts +++ b/src/core/create_app.ts @@ -172,6 +172,12 @@ function generate_server_manifest( `import error from ${stringify(get_file(path_to_routes, manifest_data.error))};` ); + if (manifest_data.error_handler_file) { + imports.push(`import error_handler from ${stringify(posixify(`${path_to_routes}/${manifest_data.error_handler_file}`))};`); + } else { + imports.push('const error_handler = null;'); + } + const component_lookup: Record = {}; manifest_data.components.forEach((component, i) => { component_lookup[component.name] = i; @@ -224,7 +230,8 @@ function generate_server_manifest( ], root_comp, - error + error, + error_handler }; export const build_dir = ${JSON.stringify(build_dir)}; diff --git a/src/core/create_manifest_data.ts b/src/core/create_manifest_data.ts index 00f0ac110..3eab90115 100644 --- a/src/core/create_manifest_data.ts +++ b/src/core/create_manifest_data.ts @@ -24,6 +24,19 @@ export default function create_manifest_data(cwd: string, extensions = '.svelte : null; } + function find_handler(file_name: string, dir = '') { + const handler_basename = fs.readdirSync(path.join(cwd, dir)).find(basename => { + const ext = path.extname(basename); + basename = path.basename(basename, ext); + + return (basename === file_name && !component_extensions.includes(ext)); + }); + + if (handler_basename) { + return posixify(path.join(dir, handler_basename)); + } + } + const components: PageComponent[] = []; const pages: Page[] = []; const server_routes: ServerRoute[] = []; @@ -198,12 +211,15 @@ export default function create_manifest_data(cwd: string, extensions = '.svelte seen_routes.set(pattern, route); }); + const error_handler_file = find_handler('_error'); + return { root, error, components, pages, - server_routes + server_routes, + error_handler_file }; } diff --git a/src/interfaces.ts b/src/interfaces.ts index 02144b5d1..59c419a9a 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -59,6 +59,7 @@ export type ManifestData = { components: PageComponent[]; pages: Page[]; server_routes: ServerRoute[]; + error_handler_file?: string; }; export type ReadyEvent = { diff --git a/test/apps/errors/test.ts b/test/apps/errors/test.ts index 1ccd6e891..cfdf79443 100644 --- a/test/apps/errors/test.ts +++ b/test/apps/errors/test.ts @@ -127,13 +127,10 @@ describe('errors', function() { assert.equal(didLayoutGetError, false); }); - it('does not serve error page for thrown non-page errors', async () => { + it('does serve error page for thrown non-page errors', async () => { await r.load('/throw.json'); - assert.strictEqual( - await r.text('body'), - 'oops' - ); + await assertErrorPageRenders(500); }); it('execute error page hooks', async () => { @@ -146,13 +143,10 @@ describe('errors', function() { ); }); - it('does not serve error page for async non-page error', async () => { + it('does serve error page for async non-page error', async () => { await r.load('/async-throw.json'); - assert.strictEqual( - await r.text('body'), - 'oops' - ); + await assertErrorPageRenders(500); }); it('clears props.error on successful render', async () => { From 74f81e851a3ad6eddc975f8815920bbb01ac3c00 Mon Sep 17 00:00:00 2001 From: Koen Kivits Date: Fri, 20 Nov 2020 21:37:29 +0100 Subject: [PATCH 2/3] Merge get_page_renderer back into get_page_handler --- .../server/middleware/get_error_handler.ts | 2 +- .../src/server/middleware/get_page_handler.ts | 402 +++++++++++++++++- .../server/middleware/get_page_renderer.ts | 402 ------------------ runtime/src/server/middleware/index.ts | 3 +- 4 files changed, 402 insertions(+), 407 deletions(-) delete mode 100644 runtime/src/server/middleware/get_page_renderer.ts diff --git a/runtime/src/server/middleware/get_error_handler.ts b/runtime/src/server/middleware/get_error_handler.ts index b56e82253..fec2291c4 100644 --- a/runtime/src/server/middleware/get_error_handler.ts +++ b/runtime/src/server/middleware/get_error_handler.ts @@ -7,7 +7,7 @@ import { SapperErrorHandler, dev } from '@sapper/internal/manifest-server'; -import { PageRenderer } from './get_page_renderer'; +import { PageRenderer } from './get_page_handler'; export function get_error_handler( manifest: Manifest, diff --git a/runtime/src/server/middleware/get_page_handler.ts b/runtime/src/server/middleware/get_page_handler.ts index 8bb642d45..05fbf62c1 100644 --- a/runtime/src/server/middleware/get_page_handler.ts +++ b/runtime/src/server/middleware/get_page_handler.ts @@ -1,9 +1,374 @@ import { Manifest, + ManifestPage, + SapperRequest, + SapperResponse, + SapperHandler, HttpError, - SapperHandler + build_dir, + dev, + src_dir } from '@sapper/internal/manifest-server'; -import { PageRenderer } from './get_page_renderer'; + +import { writable } from 'svelte/store'; +import fs from 'fs'; +import path from 'path'; +import { parse } from 'cookie'; +import devalue from 'devalue'; +import fetch from 'node-fetch'; +import URL from 'url'; +import App from '@sapper/internal/App.svelte'; +import { PageContext, PreloadResult } from '@sapper/common'; +import detectClientOnlyReferences from './detect_client_only_references'; + +export interface PageRenderer { + ( + page: ManifestPage, + req: SapperRequest, + res: SapperResponse, + error?: any + ): Promise; +} + +export function get_page_renderer( + manifest: Manifest, + session_getter: (req: SapperRequest, res: SapperResponse) => Promise +): PageRenderer { + const get_build_info = dev + ? () => JSON.parse(fs.readFileSync(path.join(build_dir, 'build.json'), 'utf-8')) + : (assets => () => assets)(JSON.parse(fs.readFileSync(path.join(build_dir, 'build.json'), 'utf-8'))); + + const template = dev + ? () => read_template(src_dir) + : (str => () => str)(read_template(build_dir)); + + const has_service_worker = fs.existsSync(path.join(build_dir, 'service-worker.js')); + + return async function render_page( + page: ManifestPage, + req: SapperRequest, + res: SapperResponse, + error: any = null + ) { + const statusCode = error ? (error.status ?? error.statusCode ?? 500) : 200; + + const is_service_worker_index = req.path === '/service-worker-index.html'; + const build_info: { + bundler: 'rollup' | 'webpack', + shimport: string | null, + assets: Record, + dependencies: Record, + css?: { main: string[] }, + legacy_assets?: Record + } = get_build_info(); + + res.setHeader('Content-Type', 'text/html'); + + // preload main js and css + // TODO detect other stuff we can preload like fonts? + let preload_files = Array.isArray(build_info.assets.main) ? build_info.assets.main : [build_info.assets.main]; + if (build_info?.css?.main) { + preload_files = preload_files.concat(build_info?.css?.main); + } + + let es6_preload = false; + if (build_info.bundler === 'rollup') { + es6_preload = true; + const route = page.parts[page.parts.length - 1].file; + const deps = build_info.dependencies[route]; + if (deps) { + preload_files = preload_files.concat(deps); + } + } else if (!error && !is_service_worker_index) { + page.parts.forEach(part => { + if (!part) return; + // using concat because it could be a string or an array. thanks webpack! + preload_files = preload_files.concat(build_info.assets[part.name]); + }); + } + + const link = preload_files + .filter((v, i, a) => a.indexOf(v) === i) // remove any duplicates + .filter(file => file && !file.match(/\.map$/)) // exclude source maps + .map((file) => { + const as = /\.css$/.test(file) ? 'style' : 'script'; + const rel = es6_preload && as === 'script' ? 'modulepreload' : 'preload'; + return `<${req.baseUrl}/client/${file}>;rel="${rel}";as="${as}"`; + }) + .join(', '); + + res.setHeader('Link', link); + + const session = await session_getter(req, res); + + let redirect: { statusCode: number, location: string }; + let preload_error: HttpError; + + const preload_context = { + redirect: (statusCode: number, location: string) => { + if (redirect && (redirect.statusCode !== statusCode || redirect.location !== location)) { + throw new Error('Conflicting redirects'); + } + location = location.replace(/^\//g, ''); // leading slash (only) + redirect = { statusCode, location }; + }, + error: (statusCode: number, error: any) => { + preload_error = http_error(statusCode, error); + }, + fetch: (url: string, opts?: any) => { + const protocol = req.socket.encrypted ? 'https' : 'http'; + const parsed = new URL.URL(url, `${protocol}://127.0.0.1:${process.env.PORT}${req.baseUrl ? req.baseUrl + '/' :''}`); + + opts = Object.assign({}, opts); + + const include_credentials = ( + opts.credentials === 'include' || + opts.credentials !== 'omit' && parsed.origin === `${protocol}://127.0.0.1:${process.env.PORT}` + ); + + if (include_credentials) { + opts.headers = Object.assign({}, opts.headers); + + const cookies = Object.assign( + {}, + parse(req.headers.cookie || ''), + parse(opts.headers.cookie || '') + ); + + const set_cookie = res.getHeader('Set-Cookie'); + (Array.isArray(set_cookie) ? set_cookie : [set_cookie]).forEach((s: string) => { + const m = /([^=]+)=([^;]+)/.exec(s); + if (m) cookies[m[1]] = m[2]; + }); + + const str = Object.keys(cookies) + .map(key => `${key}=${cookies[key]}`) + .join('; '); + + opts.headers.cookie = str; + + if (!opts.headers.authorization && req.headers.authorization) { + opts.headers.authorization = req.headers.authorization; + } + } + + return fetch(parsed.href, opts); + } + }; + + let preloaded: object[]; + let match: RegExpExecArray; + let params: Record; + + try { + const root_preload = manifest.root_comp.preload || (() => {}); + const root_preloaded: PreloadResult = detectClientOnlyReferences(() => + root_preload.call( + preload_context, + { + host: req.headers.host, + path: req.path, + query: req.query, + params: {} + }, + session + ) + ); + + match = error ? null : page.pattern.exec(req.path); + + let toPreload: PreloadResult[] = [root_preloaded]; + if (!is_service_worker_index) { + toPreload = toPreload.concat(page.parts.map(part => { + if (!part) return null; + + // the deepest level is used below, to initialise the store + params = part.params ? part.params(match) : {}; + + return part.component.preload + ? detectClientOnlyReferences(() => + part.component.preload.call( + preload_context, + { + host: req.headers.host, + path: req.path, + query: req.query, + params + }, + session + ) + ) + : {}; + })); + } + + preloaded = await Promise.all(toPreload); + } catch (err) { + if (error) { + // encountering a new error while rendering an error page, bounce + throw err; + } + + preload_error = http_error(500, err); + preloaded = []; // appease TypeScript + } + + if (redirect) { + const location = URL.resolve((req.baseUrl || '') + '/', redirect.location); + + res.statusCode = redirect.statusCode; + res.setHeader('Location', location); + res.end(); + + return; + } + + if (preload_error) { + throw preload_error; + } + + const segments = req.path.split('/').filter(Boolean); + + // TODO make this less confusing + const layout_segments = [segments[0]]; + let l = 1; + + page.parts.forEach((part, i) => { + layout_segments[l] = segments[i + 1]; + if (!part) return null; + l++; + }); + + let contextError: Error; + if (error instanceof Error) { + contextError = error; + } else if (error) { + contextError = new Error(error); + contextError.name = 'PreloadError'; + } + + const pageContext: PageContext = { + host: req.headers.host, + path: req.path, + query: req.query, + params, + error: contextError + }; + + const props = { + stores: { + page: { + subscribe: writable(pageContext).subscribe + }, + preloading: { + subscribe: writable(null).subscribe + }, + session: writable(session) + }, + segments: layout_segments, + status: statusCode, + error: pageContext.error, + level0: { + props: preloaded[0] + }, + level1: { + segment: segments[0], + props: {} + } + }; + + if (!is_service_worker_index) { + let level_index = 1; + for (let i = 0; i < page.parts.length; i += 1) { + const part = page.parts[i]; + if (!part) continue; + + props[`level${level_index++}`] = { + component: part.component.default, + props: preloaded[i + 1] || {}, + segment: segments[i] + }; + } + } + + const { html, head, css } = detectClientOnlyReferences(() => App.render(props)); + + const serialized = { + preloaded: `[${preloaded.map(data => try_serialize(data, err => { + console.error(`Failed to serialize preloaded data to transmit to the client at the /${segments.join('/')} route: ${err.message}`); + console.warn('The client will re-render over the server-rendered page fresh instead of continuing where it left off. See https://sapper.svelte.dev/docs#Return_value for more information'); + })).join(',')}]`, + session: session && try_serialize(session, err => { + throw new Error(`Failed to serialize session data: ${err.message}`); + }), + error: error && serialize_error(props.error) + }; + + let script = `__SAPPER__={${[ + error && `error:${serialized.error},status:${statusCode}`, + `baseUrl:"${req.baseUrl}"`, + serialized.preloaded && `preloaded:${serialized.preloaded}`, + serialized.session && `session:${serialized.session}` + ].filter(Boolean).join(',')}};`; + + if (has_service_worker) { + script += `if('serviceWorker' in navigator)navigator.serviceWorker.register('${req.baseUrl}/service-worker.js');`; + } + + const file = [].concat(build_info.assets.main).filter(f => f && /\.js$/.test(f))[0]; + const main = `${req.baseUrl}/client/${file}`; + + // users can set a CSP nonce using res.locals.nonce + const nonce_value = (res.locals && res.locals.nonce) ? res.locals.nonce : ''; + const nonce_attr = nonce_value ? ` nonce="${nonce_value}"` : ''; + + if (build_info.bundler === 'rollup') { + if (build_info.legacy_assets) { + const legacy_main = `${req.baseUrl}/client/legacy/${build_info.legacy_assets.main}`; + script += `(function(){try{eval("async function x(){}");var main="${main}"}catch(e){main="${legacy_main}"};var s=document.createElement("script");try{new Function("if(0)import('')")();s.src=main;s.type="module";s.crossOrigin="use-credentials";}catch(e){s.src="${req.baseUrl}/client/shimport@${build_info.shimport}.js";s.setAttribute("data-main",main);}document.head.appendChild(s);}());`; + } else { + script += `var s=document.createElement("script");try{new Function("if(0)import('')")();s.src="${main}";s.type="module";s.crossOrigin="use-credentials";}catch(e){s.src="${req.baseUrl}/client/shimport@${build_info.shimport}.js";s.setAttribute("data-main","${main}")}document.head.appendChild(s)`; + } + } else { + script += ``; + } + + let styles: string; + + // TODO make this consistent across apps + // TODO embed build_info in placeholder.ts + if (build_info.css && build_info.css.main) { + const css_chunks = new Set(build_info.css.main); + page.parts.forEach(part => { + if (!part || !build_info.dependencies) return; + const deps_for_part = build_info.dependencies[part.file]; + + if (deps_for_part) { + deps_for_part.filter(d => d.endsWith('.css')).forEach(chunk => { + css_chunks.add(chunk); + }); + } + }); + + styles = Array.from(css_chunks) + .map(href => ``) + .join(''); + } else { + styles = (css && css.code ? `${css.code}` : ''); + } + + const body = template() + .replace('%sapper.base%', () => ``) + .replace('%sapper.scripts%', () => `${script}`) + .replace('%sapper.html%', () => html) + .replace('%sapper.head%', () => head) + .replace('%sapper.styles%', () => styles) + .replace(/%sapper\.cspnonce%/g, () => nonce_value); + + res.statusCode = statusCode; + res.end(body); + }; +} export function get_page_handler( manifest: Manifest, @@ -29,3 +394,36 @@ export function get_page_handler( } }; } + +function http_error(statusCode: number, err: any) { + const http_err: HttpError = err instanceof Error ? err : new Error(err); + http_err.statusCode = statusCode; + return http_err; +} + +function read_template(dir = build_dir) { + return fs.readFileSync(`${dir}/template.html`, 'utf-8'); +} + +function try_serialize(data: any, fail?: (err: Error) => void) { + 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 +function serialize_error(error: Error) { + if (!error) return null; + let serialized = try_serialize(error); + if (!serialized) { + const { name, message, stack } = error as Error; + serialized = try_serialize({ name, message, stack }); + } + if (!serialized) { + serialized = '{}'; + } + return serialized; +} diff --git a/runtime/src/server/middleware/get_page_renderer.ts b/runtime/src/server/middleware/get_page_renderer.ts deleted file mode 100644 index 1ad427da3..000000000 --- a/runtime/src/server/middleware/get_page_renderer.ts +++ /dev/null @@ -1,402 +0,0 @@ -import { writable } from 'svelte/store'; -import fs from 'fs'; -import path from 'path'; -import { parse } from 'cookie'; -import devalue from 'devalue'; -import fetch from 'node-fetch'; -import URL from 'url'; -import { - Manifest, - ManifestPage, - SapperRequest, - SapperResponse, - HttpError, - build_dir, - dev, - src_dir -} from '@sapper/internal/manifest-server'; -import App from '@sapper/internal/App.svelte'; -import { PageContext, PreloadResult } from '@sapper/common'; -import detectClientOnlyReferences from './detect_client_only_references'; - -export interface PageRenderer { - ( - page: ManifestPage, - req: SapperRequest, - res: SapperResponse, - error?: any - ): Promise; -} - -export function get_page_renderer( - manifest: Manifest, - session_getter: (req: SapperRequest, res: SapperResponse) => Promise -): PageRenderer { - const get_build_info = dev - ? () => JSON.parse(fs.readFileSync(path.join(build_dir, 'build.json'), 'utf-8')) - : (assets => () => assets)(JSON.parse(fs.readFileSync(path.join(build_dir, 'build.json'), 'utf-8'))); - - const template = dev - ? () => read_template(src_dir) - : (str => () => str)(read_template(build_dir)); - - const has_service_worker = fs.existsSync(path.join(build_dir, 'service-worker.js')); - - return async function render_page( - page: ManifestPage, - req: SapperRequest, - res: SapperResponse, - error: any = null - ) { - const statusCode = error ? (error.status ?? error.statusCode ?? 500) : 200; - - const is_service_worker_index = req.path === '/service-worker-index.html'; - const build_info: { - bundler: 'rollup' | 'webpack', - shimport: string | null, - assets: Record, - dependencies: Record, - css?: { main: string[] }, - legacy_assets?: Record - } = get_build_info(); - - res.setHeader('Content-Type', 'text/html'); - - // preload main js and css - // TODO detect other stuff we can preload like fonts? - let preload_files = Array.isArray(build_info.assets.main) ? build_info.assets.main : [build_info.assets.main]; - if (build_info?.css?.main) { - preload_files = preload_files.concat(build_info?.css?.main); - } - - let es6_preload = false; - if (build_info.bundler === 'rollup') { - es6_preload = true; - const route = page.parts[page.parts.length - 1].file; - const deps = build_info.dependencies[route]; - if (deps) { - preload_files = preload_files.concat(deps); - } - } else if (!error && !is_service_worker_index) { - page.parts.forEach(part => { - if (!part) return; - // using concat because it could be a string or an array. thanks webpack! - preload_files = preload_files.concat(build_info.assets[part.name]); - }); - } - - const link = preload_files - .filter((v, i, a) => a.indexOf(v) === i) // remove any duplicates - .filter(file => file && !file.match(/\.map$/)) // exclude source maps - .map((file) => { - const as = /\.css$/.test(file) ? 'style' : 'script'; - const rel = es6_preload && as === 'script' ? 'modulepreload' : 'preload'; - return `<${req.baseUrl}/client/${file}>;rel="${rel}";as="${as}"`; - }) - .join(', '); - - res.setHeader('Link', link); - - const session = await session_getter(req, res); - - let redirect: { statusCode: number, location: string }; - let preload_error: HttpError; - - const preload_context = { - redirect: (statusCode: number, location: string) => { - if (redirect && (redirect.statusCode !== statusCode || redirect.location !== location)) { - throw new Error('Conflicting redirects'); - } - location = location.replace(/^\//g, ''); // leading slash (only) - redirect = { statusCode, location }; - }, - error: (statusCode: number, error: any) => { - preload_error = http_error(statusCode, error); - }, - fetch: (url: string, opts?: any) => { - const protocol = req.socket.encrypted ? 'https' : 'http'; - const parsed = new URL.URL(url, `${protocol}://127.0.0.1:${process.env.PORT}${req.baseUrl ? req.baseUrl + '/' :''}`); - - opts = Object.assign({}, opts); - - const include_credentials = ( - opts.credentials === 'include' || - opts.credentials !== 'omit' && parsed.origin === `${protocol}://127.0.0.1:${process.env.PORT}` - ); - - if (include_credentials) { - opts.headers = Object.assign({}, opts.headers); - - const cookies = Object.assign( - {}, - parse(req.headers.cookie || ''), - parse(opts.headers.cookie || '') - ); - - const set_cookie = res.getHeader('Set-Cookie'); - (Array.isArray(set_cookie) ? set_cookie : [set_cookie]).forEach((s: string) => { - const m = /([^=]+)=([^;]+)/.exec(s); - if (m) cookies[m[1]] = m[2]; - }); - - const str = Object.keys(cookies) - .map(key => `${key}=${cookies[key]}`) - .join('; '); - - opts.headers.cookie = str; - - if (!opts.headers.authorization && req.headers.authorization) { - opts.headers.authorization = req.headers.authorization; - } - } - - return fetch(parsed.href, opts); - } - }; - - let preloaded: object[]; - let match: RegExpExecArray; - let params: Record; - - try { - const root_preload = manifest.root_comp.preload || (() => {}); - const root_preloaded: PreloadResult = detectClientOnlyReferences(() => - root_preload.call( - preload_context, - { - host: req.headers.host, - path: req.path, - query: req.query, - params: {} - }, - session - ) - ); - - match = error ? null : page.pattern.exec(req.path); - - let toPreload: PreloadResult[] = [root_preloaded]; - if (!is_service_worker_index) { - toPreload = toPreload.concat(page.parts.map(part => { - if (!part) return null; - - // the deepest level is used below, to initialise the store - params = part.params ? part.params(match) : {}; - - return part.component.preload - ? detectClientOnlyReferences(() => - part.component.preload.call( - preload_context, - { - host: req.headers.host, - path: req.path, - query: req.query, - params - }, - session - ) - ) - : {}; - })); - } - - preloaded = await Promise.all(toPreload); - } catch (err) { - if (error) { - // encountering a new error while rendering an error page, bounce - throw err; - } - - preload_error = http_error(500, err); - preloaded = []; // appease TypeScript - } - - if (redirect) { - const location = URL.resolve((req.baseUrl || '') + '/', redirect.location); - - res.statusCode = redirect.statusCode; - res.setHeader('Location', location); - res.end(); - - return; - } - - if (preload_error) { - throw preload_error; - } - - const segments = req.path.split('/').filter(Boolean); - - // TODO make this less confusing - const layout_segments = [segments[0]]; - let l = 1; - - page.parts.forEach((part, i) => { - layout_segments[l] = segments[i + 1]; - if (!part) return null; - l++; - }); - - let contextError: Error; - if (error instanceof Error) { - contextError = error; - } else if (error) { - contextError = new Error(error); - contextError.name = 'PreloadError'; - } - - const pageContext: PageContext = { - host: req.headers.host, - path: req.path, - query: req.query, - params, - error: contextError - }; - - const props = { - stores: { - page: { - subscribe: writable(pageContext).subscribe - }, - preloading: { - subscribe: writable(null).subscribe - }, - session: writable(session) - }, - segments: layout_segments, - status: statusCode, - error: pageContext.error, - level0: { - props: preloaded[0] - }, - level1: { - segment: segments[0], - props: {} - } - }; - - if (!is_service_worker_index) { - let level_index = 1; - for (let i = 0; i < page.parts.length; i += 1) { - const part = page.parts[i]; - if (!part) continue; - - props[`level${level_index++}`] = { - component: part.component.default, - props: preloaded[i + 1] || {}, - segment: segments[i] - }; - } - } - - const { html, head, css } = detectClientOnlyReferences(() => App.render(props)); - - const serialized = { - preloaded: `[${preloaded.map(data => try_serialize(data, err => { - console.error(`Failed to serialize preloaded data to transmit to the client at the /${segments.join('/')} route: ${err.message}`); - console.warn('The client will re-render over the server-rendered page fresh instead of continuing where it left off. See https://sapper.svelte.dev/docs#Return_value for more information'); - })).join(',')}]`, - session: session && try_serialize(session, err => { - throw new Error(`Failed to serialize session data: ${err.message}`); - }), - error: error && serialize_error(props.error) - }; - - let script = `__SAPPER__={${[ - error && `error:${serialized.error},status:${statusCode}`, - `baseUrl:"${req.baseUrl}"`, - serialized.preloaded && `preloaded:${serialized.preloaded}`, - serialized.session && `session:${serialized.session}` - ].filter(Boolean).join(',')}};`; - - if (has_service_worker) { - script += `if('serviceWorker' in navigator)navigator.serviceWorker.register('${req.baseUrl}/service-worker.js');`; - } - - const file = [].concat(build_info.assets.main).filter(f => f && /\.js$/.test(f))[0]; - const main = `${req.baseUrl}/client/${file}`; - - // users can set a CSP nonce using res.locals.nonce - const nonce_value = (res.locals && res.locals.nonce) ? res.locals.nonce : ''; - const nonce_attr = nonce_value ? ` nonce="${nonce_value}"` : ''; - - if (build_info.bundler === 'rollup') { - if (build_info.legacy_assets) { - const legacy_main = `${req.baseUrl}/client/legacy/${build_info.legacy_assets.main}`; - script += `(function(){try{eval("async function x(){}");var main="${main}"}catch(e){main="${legacy_main}"};var s=document.createElement("script");try{new Function("if(0)import('')")();s.src=main;s.type="module";s.crossOrigin="use-credentials";}catch(e){s.src="${req.baseUrl}/client/shimport@${build_info.shimport}.js";s.setAttribute("data-main",main);}document.head.appendChild(s);}());`; - } else { - script += `var s=document.createElement("script");try{new Function("if(0)import('')")();s.src="${main}";s.type="module";s.crossOrigin="use-credentials";}catch(e){s.src="${req.baseUrl}/client/shimport@${build_info.shimport}.js";s.setAttribute("data-main","${main}")}document.head.appendChild(s)`; - } - } else { - script += ``; - } - - let styles: string; - - // TODO make this consistent across apps - // TODO embed build_info in placeholder.ts - if (build_info.css && build_info.css.main) { - const css_chunks = new Set(build_info.css.main); - page.parts.forEach(part => { - if (!part || !build_info.dependencies) return; - const deps_for_part = build_info.dependencies[part.file]; - - if (deps_for_part) { - deps_for_part.filter(d => d.endsWith('.css')).forEach(chunk => { - css_chunks.add(chunk); - }); - } - }); - - styles = Array.from(css_chunks) - .map(href => ``) - .join(''); - } else { - styles = (css && css.code ? `${css.code}` : ''); - } - - const body = template() - .replace('%sapper.base%', () => ``) - .replace('%sapper.scripts%', () => `${script}`) - .replace('%sapper.html%', () => html) - .replace('%sapper.head%', () => head) - .replace('%sapper.styles%', () => styles) - .replace(/%sapper\.cspnonce%/g, () => nonce_value); - - res.statusCode = statusCode; - res.end(body); - }; -} - -function http_error(statusCode: number, err: any) { - const http_err: HttpError = err instanceof Error ? err : new Error(err); - http_err.statusCode = statusCode; - return http_err; -} - -function read_template(dir = build_dir) { - return fs.readFileSync(`${dir}/template.html`, 'utf-8'); -} - -function try_serialize(data: any, fail?: (err: Error) => void) { - 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 -function serialize_error(error: Error) { - if (!error) return null; - let serialized = try_serialize(error); - if (!serialized) { - const { name, message, stack } = error as Error; - serialized = try_serialize({ name, message, stack }); - } - if (!serialized) { - serialized = '{}'; - } - return serialized; -} diff --git a/runtime/src/server/middleware/index.ts b/runtime/src/server/middleware/index.ts index 7ce8a7cb3..a5a3fa77c 100644 --- a/runtime/src/server/middleware/index.ts +++ b/runtime/src/server/middleware/index.ts @@ -2,9 +2,8 @@ import fs from 'fs'; import path from 'path'; import mime from 'mime/lite'; import { SapperRequest, SapperResponse, SapperNext, SapperHandler, SapperErrorHandler, build_dir, dev, manifest } from '@sapper/internal/manifest-server'; -import { get_page_renderer } from './get_page_renderer'; import { get_server_route_handler } from './get_server_route_handler'; -import { get_page_handler } from './get_page_handler'; +import { get_page_handler, get_page_renderer } from './get_page_handler'; import { get_error_handler } from './get_error_handler'; type IgnoreValue = IgnoreValue[] | RegExp | ((uri: string) => boolean) | string; From 876c2f3a94878eed5f302f924c4decbc816e96b5 Mon Sep 17 00:00:00 2001 From: Koen Kivits Date: Fri, 20 Nov 2020 21:50:34 +0100 Subject: [PATCH 3/3] Minor cleanup (also remove extra parameter from an earlier idea) --- runtime/src/server/middleware/index.ts | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/runtime/src/server/middleware/index.ts b/runtime/src/server/middleware/index.ts index a5a3fa77c..8a569dbca 100644 --- a/runtime/src/server/middleware/index.ts +++ b/runtime/src/server/middleware/index.ts @@ -10,8 +10,7 @@ type IgnoreValue = IgnoreValue[] | RegExp | ((uri: string) => boolean) | string; export default function middleware(opts: { session?: (req: SapperRequest, res: SapperResponse) => any, - ignore?: IgnoreValue, - catchErrors?: boolean + ignore?: IgnoreValue } = {}) { const { session, ignore } = opts; @@ -80,16 +79,13 @@ export function compose_handlers(ignore: IgnoreValue, handlers: SapperHandler[], return next(); } - const handler = handlers[n]; - const handler_next: SapperNext = (err) => { + handlers[n](req, res, (err) => { if (err) { error_next(err); } else { nth_handler(n+1, req, res, next, error_next); } - }; - - handler(req, res, handler_next); + }); } return !ignore