import { writable } from 'svelte/store.mjs'; import fs from 'fs'; import path from 'path'; import cookie from 'cookie'; import devalue from 'devalue'; import fetch from 'node-fetch'; import URL from 'url'; import { IGNORE } from '../constants'; import { Manifest, Page, Props, Req, Res } from './types'; import { build_dir, dev, src_dir } from '@sapper/internal/manifest-server'; import { stores } from '@sapper/internal/shared'; import App from '@sapper/internal/App.svelte'; export function get_page_handler( manifest: Manifest, session_getter: (req: Req, res: Res) => any ) { 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')); const { server_routes, pages } = manifest; const error_route = manifest.error; function handle_error(req: Req, res: Res, statusCode: number, error: Error | string) { handle_page({ pattern: null, parts: [ { name: null, component: error_route } ] }, req, res, statusCode, error || new Error('Unknown error in preload function')); } async function handle_page(page: Page, req: Req, res: Res, 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, legacy_assets?: Record } = get_build_info(); res.setHeader('Content-Type', 'text/html'); res.setHeader('Cache-Control', dev ? 'no-cache' : 'max-age=600'); // preload main.js and current route // TODO detect other stuff we can preload? images, CSS, fonts? let preloaded_chunks = Array.isArray(build_info.assets.main) ? build_info.assets.main : [build_info.assets.main]; 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! preloaded_chunks = preloaded_chunks.concat(build_info.assets[part.name]); }); } if (build_info.bundler === 'rollup') { // TODO add dependencies and CSS const link = preloaded_chunks .filter(file => file && !file.match(/\.map$/)) .map(file => `<${req.baseUrl}/client/${file}>;rel="modulepreload"`) .join(', '); res.setHeader('Link', link); } else { const link = preloaded_chunks .filter(file => file && !file.match(/\.map$/)) .map((file) => { const as = /\.css$/.test(file) ? 'style' : 'script'; return `<${req.baseUrl}/client/${file}>;rel="preload";as="${as}"`; }) .join(', '); res.setHeader('Link', link); } const session = session_getter(req, res); 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 parsed = new URL.URL(url, `http://127.0.0.1:${process.env.PORT}${req.baseUrl ? req.baseUrl + '/' :''}`); if (opts) { opts = Object.assign({}, opts); const include_cookies = ( opts.credentials === 'include' || opts.credentials === 'same-origin' && parsed.origin === `http://127.0.0.1:${process.env.PORT}` ); if (include_cookies) { opts.headers = Object.assign({}, opts.headers); const cookies = Object.assign( {}, cookie.parse(req.headers.cookie || ''), cookie.parse(opts.headers.cookie || '') ); const set_cookie = res.getHeader('Set-Cookie'); (Array.isArray(set_cookie) ? set_cookie : [set_cookie]).forEach(str => { const match = /([^=]+)=([^;]+)/.exec(str); if (match) cookies[match[1]] = match[2]; }); const str = Object.keys(cookies) .map(key => `${key}=${cookies[key]}`) .join('; '); opts.headers.cookie = str; } } return fetch(parsed.href, opts); } }; let preloaded; let match; let params; try { const root_preloaded = manifest.root_preload ? manifest.root_preload.call(preload_context, { path: req.path, query: req.query, params: {} }) : {}; match = error ? null : page.pattern.exec(req.path); let toPreload = [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.preload ? part.preload.call(preload_context, { path: req.path, query: req.query, params }, session) : {}; })) } preloaded = await Promise.all(toPreload); } catch (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) { handle_error(req, res, preload_error.statusCode, 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++; }); const props = { segments: layout_segments, status: error ? status : 200, error: error ? error instanceof Error ? error : { message: error } : null, session: writable(session), level0: { props: preloaded[0] }, level1: { segment: segments[0], props: {} } }; if (!is_service_worker_index) { let l = 1; for (let i = 0; i < page.parts.length; i += 1) { const part = page.parts[i]; if (!part) continue; props[`level${l++}`] = { component: part.component, props: preloaded[i + 1] || {}, segment: segments[i] }; } } stores.page.set({ path: req.path, query: req.query, params: params }); const { html, head, css } = App.render(props); const serialized = { preloaded: `[${preloaded.map(data => try_serialize(data)).join(',')}]`, session: session && try_serialize(session, err => { throw new Error(`Failed to serialize session data: ${err.message}`); }), error: error && try_serialize(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(file => file && /\.js$/.test(file))[0]; const main = `${req.baseUrl}/client/${file}`; 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 += ``) .replace('%sapper.html%', () => html) .replace('%sapper.head%', () => `${head}`) .replace('%sapper.styles%', () => styles); res.statusCode = status; res.end(body); } catch(err) { console.log(err); if (error) { // we encountered an error while rendering the error page — oops res.statusCode = 500; res.end(`
${escape_html(err.message)}
`); } else { handle_error(req, res, 500, err); } } } return function find_route(req: Req, res: Res, next: () => void) { if (req[IGNORE]) return next(); if (req.path === '/service-worker-index.html') { const homePage = pages.find(page => page.pattern.test('/')); handle_page(homePage, req, res); return; } for (const page of pages) { if (page.pattern.test(req.path)) { handle_page(page, req, res); return; } } handle_error(req, res, 404, 'Not found'); }; } function read_template(dir = build_dir) { return fs.readFileSync(`${dir}/template.html`, 'utf-8'); } function try_serialize(data: any, fail?: (err) => void) { try { return devalue(data); } catch (err) { if (fail) fail(err); return null; } } function escape_html(html: string) { const chars: Record = { '"' : 'quot', "'": '#39', '&': 'amp', '<' : 'lt', '>' : 'gt' }; return html.replace(/["'&<>]/g, c => `&${chars[c]};`); }