diff --git a/package.json b/package.json index 1cd808f..90bb823 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,6 @@ "name": "sapper", "version": "0.21.1", "description": "Military-grade apps, engineered by Svelte", - "main": "dist/middleware.js", "bin": { "sapper": "./sapper" }, diff --git a/rollup.config.js b/rollup.config.js index c74d63d..6972fad 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -4,6 +4,7 @@ import json from 'rollup-plugin-json'; import resolve from 'rollup-plugin-node-resolve'; import commonjs from 'rollup-plugin-commonjs'; import pkg from './package.json'; +import { builtinModules } from 'module'; const external = [].concat( Object.keys(pkg.dependencies), @@ -11,27 +12,37 @@ const external = [].concat( 'sapper/core.js' ); -export default [ - { - input: `src/runtime/index.ts`, +function template(kind, external) { + return { + input: `templates/src/${kind}/index.ts`, output: { - file: `runtime.js`, + file: `templates/dist/${kind}.js`, format: 'es' }, + external, plugins: [ + resolve(), + commonjs(), + string({ + include: '**/*.md' + }), typescript({ typescript: require('typescript'), target: "ES2017" }) ] - }, + }; +} + +export default [ + template('client', []), + template('server', builtinModules), { input: [ `src/api.ts`, `src/cli.ts`, `src/core.ts`, - `src/middleware.ts`, `src/rollup.ts`, `src/webpack.ts` ], @@ -42,9 +53,6 @@ export default [ }, external, plugins: [ - string({ - include: '**/*.md' - }), json(), resolve(), commonjs(), diff --git a/src/core/create_manifests.ts b/src/core/create_manifests.ts index c7c52d1..e2035f9 100644 --- a/src/core/create_manifests.ts +++ b/src/core/create_manifests.ts @@ -10,7 +10,7 @@ export function create_main_manifests({ bundler, manifest_data, dev_port }: { manifest_data: ManifestData; dev_port?: number; }) { - const manifest_dir = path.join(locations.src(), 'manifest'); + const manifest_dir = path.join(locations.src(), '__sapper__'); if (!fs.existsSync(manifest_dir)) fs.mkdirSync(manifest_dir); const path_to_routes = path.relative(manifest_dir, locations.routes()); @@ -55,7 +55,7 @@ export function create_serviceworker_manifest({ manifest_data, client_files }: { export const routes = [\n\t${manifest_data.pages.map((r: Page) => `{ pattern: ${r.pattern} }`).join(',\n\t')}\n]; `.replace(/^\t\t/gm, '').trim(); - write_if_changed(`${locations.src()}/manifest/service-worker.js`, code); + write_if_changed(`${locations.src()}/__sapper__/service-worker.js`, code); } function generate_client( @@ -64,6 +64,9 @@ function generate_client( bundler: string, dev_port?: number ) { + const template_file = path.resolve(__dirname, '../templates/dist/client.js'); + const template = fs.readFileSync(template_file, 'utf-8'); + const page_ids = new Set(manifest_data.pages.map(page => page.pattern.toString())); @@ -71,7 +74,6 @@ function generate_client( !page_ids.has(route.pattern.toString())); let code = ` - // This file is generated by Sapper — do not edit it! import root from ${stringify(get_file(path_to_routes, manifest_data.root))}; import error from ${stringify(posixify(`${path_to_routes}/_error.html`))}; @@ -90,7 +92,7 @@ function generate_client( };`; }).join('\n')} - export const manifest = { + const manifest = { ignore: [${server_routes_to_ignore.map(route => route.pattern).join(', ')}], pages: [ @@ -115,10 +117,7 @@ function generate_client( root, error - }; - - // this is included for legacy reasons - export const routes = {};`.replace(/^\t\t/gm, '').trim(); + };`.replace(/^\t\t/gm, '').trim(); if (dev()) { const sapper_dev_client = posixify( @@ -132,13 +131,17 @@ function generate_client( });`.replace(/^\t{3}/gm, ''); } - return code; + return `// This file is generated by Sapper — do not edit it!\n` + template + .replace(/const manifest = __MANIFEST__;/, code); } function generate_server( manifest_data: ManifestData, path_to_routes: string ) { + const template_file = path.resolve(__dirname, '../templates/dist/server.js'); + const template = fs.readFileSync(template_file, 'utf-8'); + const imports = [].concat( manifest_data.server_routes.map(route => `import * as ${route.name} from ${stringify(posixify(`${path_to_routes}/${route.file}`))};`), @@ -149,7 +152,6 @@ function generate_server( ); let code = ` - // This file is generated by Sapper — do not edit it! ${imports.join('\n')} const d = decodeURIComponent; @@ -199,7 +201,11 @@ function generate_server( // this is included for legacy reasons export const routes = {};`.replace(/^\t\t/gm, '').trim(); - return code; + return `// This file is generated by Sapper — do not edit it!\n` + template + .replace('__BUILD__DIR__', JSON.stringify(locations.dest())) + .replace('__SRC__DIR__', JSON.stringify(locations.src())) + .replace('__DEV__', dev() ? 'true' : 'false') + .replace(/const manifest = __MANIFEST__;/, code); } function get_file(path_to_routes: string, component: PageComponent) { diff --git a/src/runtime/utils.ts b/src/runtime/utils.ts deleted file mode 100644 index cec5f69..0000000 --- a/src/runtime/utils.ts +++ /dev/null @@ -1,19 +0,0 @@ -export function detach(node: Node) { - node.parentNode.removeChild(node); -} - -export function findAnchor(node: Node) { - while (node && node.nodeName.toUpperCase() !== 'A') node = node.parentNode; // SVG elements have a lowercase name - return node; -} - -export function which(event: MouseEvent) { - return event.which === null ? event.button : event.which; -} - -export function scroll_state() { - return { - x: window.scrollX, - y: window.scrollY - }; -} \ No newline at end of file diff --git a/src/runtime/index.ts b/templates/src/client/app.ts similarity index 54% rename from src/runtime/index.ts rename to templates/src/client/app.ts index 9e07bad..f8e97d0 100644 --- a/src/runtime/index.ts +++ b/templates/src/client/app.ts @@ -1,26 +1,14 @@ -import { detach, findAnchor, scroll_state, which } from './utils'; -import { Component, ComponentConstructor, Params, Query, Redirect, Manifest, RouteData, ScrollPosition, Store, Target, ComponentLoader } from './interfaces'; +import { Manifest, Target, ScrollPosition, Component, Redirect, ComponentLoader, ComponentConstructor, RootProps } from './types'; +import goto from './goto'; -const initial_data = typeof window !== 'undefined' && window.__SAPPER__; +export const manifest: Manifest = __MANIFEST__; -export let root: Component; -let target: Node; -let store: Store; -let manifest: Manifest; +let ready = false; +let root_component: Component; let segments: string[] = []; - -type RootProps = { - path: string; - params: Record; - query: Record; - child: Child; -}; - -type Child = { - segment?: string; - props?: any; - component?: Component; -}; +let current_token: {}; +let root_preload: Promise; +let root_data: any; const root_props: RootProps = { path: null, @@ -33,23 +21,45 @@ const root_props: RootProps = { } }; -export { root as component }; // legacy reasons — drop in a future version +export let prefetching: { + href: string; + promise: Promise<{ redirect?: Redirect, data?: any, nullable_depth?: number }>; +} = null; +export function set_prefetching(href, promise) { + prefetching = { href, promise }; +} -const history = typeof window !== 'undefined' ? window.history : { +export let store; +export function set_store(fn) { + store = fn(initial_data.store); +} + +export let target: Node; +export function set_target(element) { + target = element; +} + +export let uid = 1; +export function set_uid(n) { + uid = n; +} + +export let cid: number; +export function set_cid(n) { + cid = n; +} + +export const initial_data = typeof window !== 'undefined' && window.__SAPPER__; + +export const history = typeof window !== 'undefined' ? window.history : { pushState: (state: any, title: string, href: string) => {}, replaceState: (state: any, title: string, href: string) => {}, scrollRestoration: '' }; -const scroll_history: Record = {}; -let uid = 1; -let cid: number; +export const scroll_history: Record = {}; -if ('scrollRestoration' in history) { - history.scrollRestoration = 'manual'; -} - -function select_route(url: URL): Target { +export function select_route(url: URL): Target { if (url.origin !== window.location.origin) return null; if (!url.pathname.startsWith(initial_data.baseUrl)) return null; @@ -75,12 +85,51 @@ function select_route(url: URL): Target { } } -let current_token: {}; +export function scroll_state() { + return { + x: window.scrollX, + y: window.scrollY + }; +} + +export async function navigate(target: Target, id: number): Promise { + if (id) { + // popstate or initial navigation + cid = id; + } else { + // clicked on a link. preserve scroll state + scroll_history[cid] = scroll_state(); + + id = cid = ++uid; + scroll_history[cid] = { x: 0, y: 0 }; + } + + cid = id; + + if (root_component) { + root_component.set({ preloading: true }); + } + const loaded = prefetching && prefetching.href === target.url.href ? + prefetching.promise : + prepare_page(target); + + prefetching = null; + + const token = current_token = {}; + const { redirect, data, nullable_depth } = await loaded; + + if (redirect) { + await goto(redirect.location, { replaceState: true }); + } else { + render(data, nullable_depth, scroll_history[id], token); + if (document.activeElement) document.activeElement.blur(); + } +} function render(data: any, nullable_depth: number, scroll: ScrollPosition, token: {}) { if (current_token !== token) return; - if (root) { + if (root_component) { // first, clear out highest-level root component let level = data.child; for (let i = 0; i < nullable_depth; i += 1) { @@ -90,11 +139,11 @@ function render(data: any, nullable_depth: number, scroll: ScrollPosition, token const { component } = level; level.component = null; - root.set({ child: data.child }); + root_component.set({ child: data.child }); // then render new stuff level.component = component; - root.set(data); + root_component.set(data); } else { // first load — remove SSR'd contents const start = document.querySelector('#sapper-head-start'); @@ -108,7 +157,7 @@ function render(data: any, nullable_depth: number, scroll: ScrollPosition, token Object.assign(data, root_data); - root = new manifest.root({ + root_component = new manifest.root({ target, data, store, @@ -124,38 +173,7 @@ function render(data: any, nullable_depth: number, scroll: ScrollPosition, token ready = true; } -function changed(a: Record, b: Record) { - return JSON.stringify(a) !== JSON.stringify(b); -} - -let root_preload: Promise; -let root_data: any; - -function load_css(chunk: string) { - const href = `${initial_data.baseUrl}client/${chunk}`; - if (document.querySelector(`link[href="${href}"]`)) return; - - return new Promise((fulfil, reject) => { - const link = document.createElement('link'); - link.rel = 'stylesheet'; - link.href = href; - - link.onload = () => fulfil(); - link.onerror = reject; - - document.head.appendChild(link); - }); -} - -function load_component(component: ComponentLoader): Promise { - // TODO this is temporary — once placeholders are - // always rewritten, scratch the ternary - const promises: Array> = (typeof component.css === 'string' ? [] : component.css.map(load_css)); - promises.unshift(component.js()); - return Promise.all(promises).then(values => values[0].default); -} - -function prepare_page(target: Target): Promise<{ +export function prepare_page(target: Target): Promise<{ redirect?: Redirect; data?: any; nullable_depth?: number; @@ -292,213 +310,34 @@ function prepare_page(target: Target): Promise<{ }); } -async function navigate(target: Target, id: number): Promise { - if (id) { - // popstate or initial navigation - cid = id; - } else { - // clicked on a link. preserve scroll state - scroll_history[cid] = scroll_state(); +function load_css(chunk: string) { + const href = `${initial_data.baseUrl}client/${chunk}`; + if (document.querySelector(`link[href="${href}"]`)) return; - id = cid = ++uid; - scroll_history[cid] = { x: 0, y: 0 }; - } + return new Promise((fulfil, reject) => { + const link = document.createElement('link'); + link.rel = 'stylesheet'; + link.href = href; - cid = id; + link.onload = () => fulfil(); + link.onerror = reject; - if (root) { - root.set({ preloading: true }); - } - const loaded = prefetching && prefetching.href === target.url.href ? - prefetching.promise : - prepare_page(target); - - prefetching = null; - - const token = current_token = {}; - const { redirect, data, nullable_depth } = await loaded; - - if (redirect) { - await goto(redirect.location, { replaceState: true }); - } else { - render(data, nullable_depth, scroll_history[id], token); - if (document.activeElement) document.activeElement.blur(); - } -} - -function handle_click(event: MouseEvent) { - // Adapted from https://github.com/visionmedia/page.js - // MIT license https://github.com/visionmedia/page.js#license - if (which(event) !== 1) return; - if (event.metaKey || event.ctrlKey || event.shiftKey) return; - if (event.defaultPrevented) return; - - const a: HTMLAnchorElement | SVGAElement = findAnchor(event.target); - if (!a) return; - - if (!a.href) return; - - // check if link is inside an svg - // in this case, both href and target are always inside an object - const svg = typeof a.href === 'object' && a.href.constructor.name === 'SVGAnimatedString'; - const href = String(svg ? (a).href.baseVal : a.href); - - if (href === window.location.href) { - event.preventDefault(); - return; - } - - // Ignore if tag has - // 1. 'download' attribute - // 2. rel='external' attribute - if (a.hasAttribute('download') || a.getAttribute('rel') === 'external') return; - - // Ignore if has a target - if (svg ? (a).target.baseVal : a.target) return; - - const url = new URL(href); - - // Don't handle hash changes - if (url.pathname === window.location.pathname && url.search === window.location.search) return; - - const target = select_route(url); - if (target) { - navigate(target, null); - event.preventDefault(); - history.pushState({ id: cid }, '', url.href); - } -} - -function handle_popstate(event: PopStateEvent) { - scroll_history[cid] = scroll_state(); - - if (event.state) { - const url = new URL(window.location.href); - const target = select_route(url); - if (target) { - navigate(target, event.state.id); - } else { - window.location.href = window.location.href; - } - } else { - // hashchange - cid = ++uid; - history.replaceState({ id: cid }, '', window.location.href); - } -} - -let prefetching: { - href: string; - promise: Promise<{ redirect?: Redirect, data?: any, nullable_depth?: number }>; -} = null; - -export function prefetch(href: string) { - const target: Target = select_route(new URL(href, document.baseURI)); - - if (target && (!prefetching || href !== prefetching.href)) { - prefetching = { - href, - promise: prepare_page(target) - }; - } -} - -let mousemove_timeout: NodeJS.Timer; - -function handle_mousemove(event: MouseEvent) { - clearTimeout(mousemove_timeout); - mousemove_timeout = setTimeout(() => { - trigger_prefetch(event); - }, 20); -} - -function trigger_prefetch(event: MouseEvent | TouchEvent) { - const a: HTMLAnchorElement = findAnchor(event.target); - if (!a || a.rel !== 'prefetch') return; - - prefetch(a.href); -} - -let inited: boolean; -let ready = false; - -export function init(opts: { - App: ComponentConstructor, - target: Node, - manifest: Manifest, - store?: (data: any) => Store, - routes?: any // legacy -}) { - if (opts instanceof HTMLElement) { - throw new Error(`The signature of init(...) has changed — see https://sapper.svelte.technology/guide#0-11-to-0-12 for more information`); - } - - if (opts.routes) { - throw new Error(`As of Sapper 0.15, opts.routes should be opts.manifest`); - } - - target = opts.target; - manifest = opts.manifest; - - if (opts && opts.store) { - store = opts.store(initial_data.store); - } - - if (!inited) { // this check makes HMR possible - window.addEventListener('click', handle_click); - window.addEventListener('popstate', handle_popstate); - - // prefetch - window.addEventListener('touchstart', trigger_prefetch); - window.addEventListener('mousemove', handle_mousemove); - - inited = true; - } - - return Promise.resolve().then(() => { - const { hash, href } = window.location; - - const deep_linked = hash && document.getElementById(hash.slice(1)); - scroll_history[uid] = deep_linked ? - { x: 0, y: deep_linked.getBoundingClientRect().top } : - scroll_state(); - - history.replaceState({ id: uid }, '', href); - - if (!initial_data.error) { - const target = select_route(new URL(window.location.href)); - if (target) return navigate(target, uid); - } + document.head.appendChild(link); }); } -export function goto(href: string, opts = { replaceState: false }) { - const target = select_route(new URL(href, document.baseURI)); - let promise; - - if (target) { - promise = navigate(target, null); - if (history) history[opts.replaceState ? 'replaceState' : 'pushState']({ id: cid }, '', href); - } else { - window.location.href = href; - promise = new Promise(f => {}); // never resolves - } - - return promise; +export function load_component(component: ComponentLoader): Promise { + // TODO this is temporary — once placeholders are + // always rewritten, scratch the ternary + const promises: Array> = (typeof component.css === 'string' ? [] : component.css.map(load_css)); + promises.unshift(component.js()); + return Promise.all(promises).then(values => values[0].default); } -export function prefetchRoutes(pathnames: string[]) { - if (!manifest) throw new Error(`You must call init() first`); - - return manifest.pages - .filter(route => { - if (!pathnames) return true; - return pathnames.some(pathname => route.pattern.test(pathname)); - }) - .reduce((promise: Promise, route) => promise.then(() => { - return Promise.all(route.parts.map(part => part && load_component(part.component))); - }), Promise.resolve()); +function detach(node: Node) { + node.parentNode.removeChild(node); } -// remove this in 0.9 -export { prefetchRoutes as preloadRoutes }; \ No newline at end of file +function changed(a: Record, b: Record) { + return JSON.stringify(a) !== JSON.stringify(b); +} \ No newline at end of file diff --git a/templates/src/client/goto/index.ts b/templates/src/client/goto/index.ts new file mode 100644 index 0000000..05e48fd --- /dev/null +++ b/templates/src/client/goto/index.ts @@ -0,0 +1,16 @@ +import { select_route, navigate, cid } from '../app'; + +export default function goto(href: string, opts = { replaceState: false }) { + const target = select_route(new URL(href, document.baseURI)); + let promise; + + if (target) { + promise = navigate(target, null); + if (history) history[opts.replaceState ? 'replaceState' : 'pushState']({ id: cid }, '', href); + } else { + window.location.href = href; + promise = new Promise(f => {}); // never resolves + } + + return promise; +} \ No newline at end of file diff --git a/templates/src/client/index.ts b/templates/src/client/index.ts new file mode 100644 index 0000000..f171109 --- /dev/null +++ b/templates/src/client/index.ts @@ -0,0 +1,4 @@ +export { default as start } from './start/index'; +export { default as goto } from './goto/index'; +export { default as prefetch } from './prefetch/index'; +export { default as prefetchRoutes } from './prefetchRoutes/index'; \ No newline at end of file diff --git a/templates/src/client/prefetch/index.ts b/templates/src/client/prefetch/index.ts new file mode 100644 index 0000000..4b601d2 --- /dev/null +++ b/templates/src/client/prefetch/index.ts @@ -0,0 +1,10 @@ +import { select_route, prefetching, set_prefetching, prepare_page } from '../app'; +import { Target } from '../types'; + +export default function prefetch(href: string) { + const target: Target = select_route(new URL(href, document.baseURI)); + + if (target && (!prefetching || href !== prefetching.href)) { + set_prefetching(href, prepare_page(target)); + } +} \ No newline at end of file diff --git a/templates/src/client/prefetchRoutes/index.ts b/templates/src/client/prefetchRoutes/index.ts new file mode 100644 index 0000000..8ed7a48 --- /dev/null +++ b/templates/src/client/prefetchRoutes/index.ts @@ -0,0 +1,14 @@ +import { manifest, load_component } from "../app"; + +export default function prefetchRoutes(pathnames: string[]) { + if (!manifest) throw new Error(`You must call init() first`); + + return manifest.pages + .filter(route => { + if (!pathnames) return true; + return pathnames.some(pathname => route.pattern.test(pathname)); + }) + .reduce((promise: Promise, route) => promise.then(() => { + return Promise.all(route.parts.map(part => part && load_component(part.component))); + }), Promise.resolve()); +} \ No newline at end of file diff --git a/templates/src/client/start/index.ts b/templates/src/client/start/index.ts new file mode 100644 index 0000000..1a26ca9 --- /dev/null +++ b/templates/src/client/start/index.ts @@ -0,0 +1,138 @@ +import { + cid, + history, + initial_data, + navigate, + scroll_history, + scroll_state, + select_route, + set_store, + set_target, + uid, + set_uid, + set_cid +} from '../app'; +import prefetch from '../prefetch/index'; +import { Store } from '../types'; + +export default function start(opts: { + target: Node, + store?: (data: any) => Store +}) { + if ('scrollRestoration' in history) { + history.scrollRestoration = 'manual'; + } + + set_target(opts.target); + if (opts.store) set_store(opts.store); + + window.addEventListener('click', handle_click); + window.addEventListener('popstate', handle_popstate); + + // prefetch + window.addEventListener('touchstart', trigger_prefetch); + window.addEventListener('mousemove', handle_mousemove); + + return Promise.resolve().then(() => { + const { hash, href } = window.location; + + const deep_linked = hash && document.getElementById(hash.slice(1)); + scroll_history[uid] = deep_linked ? + { x: 0, y: deep_linked.getBoundingClientRect().top } : + scroll_state(); + + history.replaceState({ id: uid }, '', href); + + if (!initial_data.error) { + const target = select_route(new URL(window.location.href)); + if (target) return navigate(target, uid); + } + }); +} + +let mousemove_timeout: NodeJS.Timer; + +function handle_mousemove(event: MouseEvent) { + clearTimeout(mousemove_timeout); + mousemove_timeout = setTimeout(() => { + trigger_prefetch(event); + }, 20); +} + +function trigger_prefetch(event: MouseEvent | TouchEvent) { + const a: HTMLAnchorElement = find_anchor(event.target); + if (!a || a.rel !== 'prefetch') return; + + prefetch(a.href); +} + +function handle_click(event: MouseEvent) { + // Adapted from https://github.com/visionmedia/page.js + // MIT license https://github.com/visionmedia/page.js#license + if (which(event) !== 1) return; + if (event.metaKey || event.ctrlKey || event.shiftKey) return; + if (event.defaultPrevented) return; + + const a: HTMLAnchorElement | SVGAElement = find_anchor(event.target); + if (!a) return; + + if (!a.href) return; + + // check if link is inside an svg + // in this case, both href and target are always inside an object + const svg = typeof a.href === 'object' && a.href.constructor.name === 'SVGAnimatedString'; + const href = String(svg ? (a).href.baseVal : a.href); + + if (href === window.location.href) { + event.preventDefault(); + return; + } + + // Ignore if tag has + // 1. 'download' attribute + // 2. rel='external' attribute + if (a.hasAttribute('download') || a.getAttribute('rel') === 'external') return; + + // Ignore if has a target + if (svg ? (a).target.baseVal : a.target) return; + + const url = new URL(href); + + // Don't handle hash changes + if (url.pathname === window.location.pathname && url.search === window.location.search) return; + + const target = select_route(url); + if (target) { + navigate(target, null); + event.preventDefault(); + history.pushState({ id: cid }, '', url.href); + } +} + +function which(event: MouseEvent) { + return event.which === null ? event.button : event.which; +} + +function find_anchor(node: Node) { + while (node && node.nodeName.toUpperCase() !== 'A') node = node.parentNode; // SVG elements have a lowercase name + return node; +} + +function handle_popstate(event: PopStateEvent) { + scroll_history[cid] = scroll_state(); + + if (event.state) { + const url = new URL(window.location.href); + const target = select_route(url); + if (target) { + navigate(target, event.state.id); + } else { + window.location.href = window.location.href; + } + } else { + // hashchange + set_uid(uid + 1); + set_cid(uid); + history.replaceState({ id: cid }, '', window.location.href); + } +} \ No newline at end of file diff --git a/src/runtime/interfaces.ts b/templates/src/client/types.ts similarity index 82% rename from src/runtime/interfaces.ts rename to templates/src/client/types.ts index 457d667..647a94a 100644 --- a/src/runtime/interfaces.ts +++ b/templates/src/client/types.ts @@ -1,10 +1,20 @@ -import { Store } from '../interfaces'; - -export { Store }; export type Params = Record; export type Query = Record; export type RouteData = { params: Params, query: Query, path: string }; +type Child = { + segment?: string; + props?: any; + component?: Component; +}; + +export type RootProps = { + path: string; + params: Record; + query: Record; + child: Child; +}; + export interface ComponentConstructor { new (options: { target: Node, data: any, store: Store, hydrate: boolean }): Component; preload: (props: { params: Params, query: Query }) => Promise; @@ -51,4 +61,8 @@ export type Target = { export type Redirect = { statusCode: number; location: string; -}; \ No newline at end of file +}; + +export type Store = { + get: () => any; +} \ No newline at end of file diff --git a/templates/src/server/index.ts b/templates/src/server/index.ts new file mode 100644 index 0000000..f6706ca --- /dev/null +++ b/templates/src/server/index.ts @@ -0,0 +1 @@ +export { default as middleware } from './middleware/index'; \ No newline at end of file diff --git a/src/middleware.ts b/templates/src/server/middleware/get_page_handler.ts similarity index 52% rename from src/middleware.ts rename to templates/src/server/middleware/get_page_handler.ts index 582d423..6dff50b 100644 --- a/src/middleware.ts +++ b/templates/src/server/middleware/get_page_handler.ts @@ -1,301 +1,29 @@ import * as fs from 'fs'; import * as path from 'path'; -import { URL } from 'url'; -import { ClientRequest, ServerResponse } from 'http'; import cookie from 'cookie'; import devalue from 'devalue'; import fetch from 'node-fetch'; -import { lookup } from './middleware/mime'; -import { locations, dev } from './config'; -import sourceMapSupport from 'source-map-support'; -import read_template from './core/read_template'; +import { build_dir, dev, src_dir, IGNORE } from '../placeholders'; +import { Manifest, Page, Props, Req, Res, Store } from './types'; -sourceMapSupport.install(); - -type ServerRoute = { - pattern: RegExp; - handlers: Record; - params: (match: RegExpMatchArray) => Record; -}; - -type Page = { - pattern: RegExp; - parts: Array<{ - name: string; - component: Component; - params?: (match: RegExpMatchArray) => Record; - }> -}; - -type Manifest = { - server_routes: ServerRoute[]; - pages: Page[]; - root: Component; - error: Component; -} - -type Handler = (req: Req, res: ServerResponse, next: () => void) => void; - -type Store = { - get: () => any -}; - -type Props = { - path: string; - query: Record; - params: Record; - error?: { message: string }; - status?: number; - child: { - segment: string; - component: Component; - props: Props; - }; - [key: string]: any; -}; - -interface Req extends ClientRequest { - url: string; - baseUrl: string; - originalUrl: string; - method: string; - path: string; - params: Record; - query: Record; - headers: Record; -} - -interface Component { - render: (data: any, opts: { store: Store }) => { - head: string; - css: { code: string, map: any }; - html: string - }, - preload: (data: any) => any | Promise -} - -const IGNORE = '__SAPPER__IGNORE__'; -function toIgnore(uri: string, val: any) { - if (Array.isArray(val)) return val.some(x => toIgnore(uri, x)); - if (val instanceof RegExp) return val.test(uri); - if (typeof val === 'function') return val(uri); - return uri.startsWith(val.charCodeAt(0) === 47 ? val : `/${val}`); -} - -export default function middleware(opts: { +export function get_page_handler( manifest: Manifest, - store: (req: Req, res: ServerResponse) => Store, - ignore?: any, - routes?: any // legacy -}) { - if (opts.routes) { - throw new Error(`As of Sapper 0.15, opts.routes should be opts.manifest`); - } - - const output = locations.dest(); - - const { manifest, store, ignore } = opts; - - let emitted_basepath = false; - - const middleware = compose_handlers([ - ignore && ((req: Req, res: ServerResponse, next: () => void) => { - req[IGNORE] = toIgnore(req.path, ignore); - next(); - }), - - (req: Req, res: ServerResponse, next: () => void) => { - if (req[IGNORE]) return next(); - - if (req.baseUrl === undefined) { - let { originalUrl } = req; - if (req.url === '/' && originalUrl[originalUrl.length - 1] !== '/') { - originalUrl += '/'; - } - - req.baseUrl = originalUrl - ? originalUrl.slice(0, -req.url.length) - : ''; - } - - if (!emitted_basepath && process.send) { - process.send({ - __sapper__: true, - event: 'basepath', - basepath: req.baseUrl - }); - - emitted_basepath = true; - } - - if (req.path === undefined) { - req.path = req.url.replace(/\?.*/, ''); - } - - next(); - }, - - fs.existsSync(path.join(output, 'index.html')) && serve({ - pathname: '/index.html', - cache_control: dev() ? 'no-cache' : 'max-age=600' - }), - - fs.existsSync(path.join(output, 'service-worker.js')) && serve({ - pathname: '/service-worker.js', - cache_control: 'no-cache, no-store, must-revalidate' - }), - - fs.existsSync(path.join(output, 'service-worker.js.map')) && serve({ - pathname: '/service-worker.js.map', - cache_control: 'no-cache, no-store, must-revalidate' - }), - - serve({ - prefix: '/client/', - cache_control: dev() ? 'no-cache' : 'max-age=31536000, immutable' - }), - - get_server_route_handler(manifest.server_routes), - get_page_handler(manifest, store) - ].filter(Boolean)); - - return middleware; -} - -function serve({ prefix, pathname, cache_control }: { - prefix?: string, - pathname?: string, - cache_control: string -}) { - const filter = pathname - ? (req: Req) => req.path === pathname - : (req: Req) => req.path.startsWith(prefix); - - const output = locations.dest(); - - const cache: Map = new Map(); - - const read = dev() - ? (file: string) => fs.readFileSync(path.resolve(output, file)) - : (file: string) => (cache.has(file) ? cache : cache.set(file, fs.readFileSync(path.resolve(output, file)))).get(file) - - return (req: Req, res: ServerResponse, next: () => void) => { - if (req[IGNORE]) return next(); - - if (filter(req)) { - const type = lookup(req.path); - - try { - const file = decodeURIComponent(req.path.slice(1)); - const data = read(file); - - res.setHeader('Content-Type', type); - res.setHeader('Cache-Control', cache_control); - res.end(data); - } catch (err) { - res.statusCode = 404; - res.end('not found'); - } - } else { - next(); - } - }; -} - -function get_server_route_handler(routes: ServerRoute[]) { - function handle_route(route: ServerRoute, req: Req, res: ServerResponse, next: () => void) { - req.params = route.params(route.pattern.exec(req.path)); - - const method = req.method.toLowerCase(); - // 'delete' cannot be exported from a module because it is a keyword, - // so check for 'del' instead - const method_export = method === 'delete' ? 'del' : method; - const handle_method = route.handlers[method_export]; - if (handle_method) { - if (process.env.SAPPER_EXPORT) { - const { write, end, setHeader } = res; - const chunks: any[] = []; - const headers: Record = {}; - - // intercept data so that it can be exported - res.write = function(chunk: any) { - chunks.push(Buffer.from(chunk)); - write.apply(res, arguments); - }; - - res.setHeader = function(name: string, value: string) { - headers[name.toLowerCase()] = value; - setHeader.apply(res, arguments); - }; - - res.end = function(chunk?: any) { - if (chunk) chunks.push(Buffer.from(chunk)); - end.apply(res, arguments); - - process.send({ - __sapper__: true, - event: 'file', - url: req.url, - method: req.method, - status: res.statusCode, - type: headers['content-type'], - body: Buffer.concat(chunks).toString() - }); - }; - } - - const handle_next = (err?: Error) => { - if (err) { - res.statusCode = 500; - res.end(err.message); - } else { - process.nextTick(next); - } - }; - - try { - handle_method(req, res, handle_next); - } catch (err) { - handle_next(err); - } - } else { - // no matching handler for method - process.nextTick(next); - } - } - - return function find_route(req: Req, res: ServerResponse, next: () => void) { - if (req[IGNORE]) return next(); - - for (const route of routes) { - if (route.pattern.test(req.path)) { - handle_route(route, req, res, next); - return; - } - } - - next(); - }; -} - -function get_page_handler( - manifest: Manifest, - store_getter: (req: Req, res: ServerResponse) => Store + store_getter: (req: Req, res: Res) => Store ) { - const output = locations.dest(); + 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 get_build_info = dev() - ? () => JSON.parse(fs.readFileSync(path.join(output, 'build.json'), 'utf-8')) - : (assets => () => assets)(JSON.parse(fs.readFileSync(path.join(output, 'build.json'), 'utf-8'))); + const template = dev + ? () => read_template(src_dir) + : (str => () => str)(read_template(build_dir)); - const template = dev() - ? () => read_template() - : (str => () => str)(read_template(output)); + 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: ServerResponse, statusCode: number, error: Error | string) { + function handle_error(req: Req, res: Res, statusCode: number, error: Error | string) { handle_page({ pattern: null, parts: [ @@ -304,7 +32,7 @@ function get_page_handler( }, req, res, statusCode, error || new Error('Unknown error in preload function')); } - function handle_page(page: Page, req: Req, res: ServerResponse, status = 200, error: Error | string = null) { + function handle_page(page: Page, req: Req, res: Res, status = 200, error: Error | string = null) { const build_info: { bundler: 'rollup' | 'webpack', shimport: string | null, @@ -313,7 +41,7 @@ function get_page_handler( } = get_build_info(); res.setHeader('Content-Type', 'text/html'); - res.setHeader('Cache-Control', dev() ? 'no-cache' : 'max-age=600'); + 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? @@ -483,7 +211,6 @@ function get_page_handler( serialized.store && `store:${serialized.store}` ].filter(Boolean).join(',')}};`; - const has_service_worker = fs.existsSync(path.join(locations.dest(), 'service-worker.js')); if (has_service_worker) { script += `if('serviceWorker' in navigator)navigator.serviceWorker.register('${req.baseUrl}/service-worker.js');`; } @@ -505,6 +232,7 @@ function get_page_handler( 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(); if (build_info.css.main) css_chunks.add(build_info.css.main); @@ -549,7 +277,7 @@ function get_page_handler( }); } - return function find_route(req: Req, res: ServerResponse, next: () => void) { + return function find_route(req: Req, res: Res, next: () => void) { if (req[IGNORE]) return next(); if (!server_routes.some(route => route.pattern.test(req.path))) { @@ -565,24 +293,8 @@ function get_page_handler( }; } -function compose_handlers(handlers: Handler[]) { - return (req: Req, res: ServerResponse, next: () => void) => { - let i = 0; - function go() { - const handler = handlers[i]; - - if (handler) { - handler(req, res, () => { - i += 1; - go(); - }); - } else { - next(); - } - } - - go(); - }; +function read_template(dir = build_dir) { + return fs.readFileSync(`${dir}/template.html`, 'utf-8'); } function try_serialize(data: any) { @@ -603,4 +315,4 @@ function escape_html(html: string) { }; return html.replace(/["'&<>]/g, c => `&${chars[c]};`); -} +} \ No newline at end of file diff --git a/templates/src/server/middleware/get_server_route_handler.ts b/templates/src/server/middleware/get_server_route_handler.ts new file mode 100644 index 0000000..c788f4d --- /dev/null +++ b/templates/src/server/middleware/get_server_route_handler.ts @@ -0,0 +1,78 @@ +import { IGNORE } from '../placeholders'; +import { Req, Res, ServerRoute } from './types'; + +export function get_server_route_handler(routes: ServerRoute[]) { + function handle_route(route: ServerRoute, req: Req, res: Res, next: () => void) { + req.params = route.params(route.pattern.exec(req.path)); + + const method = req.method.toLowerCase(); + // 'delete' cannot be exported from a module because it is a keyword, + // so check for 'del' instead + const method_export = method === 'delete' ? 'del' : method; + const handle_method = route.handlers[method_export]; + if (handle_method) { + if (process.env.SAPPER_EXPORT) { + const { write, end, setHeader } = res; + const chunks: any[] = []; + const headers: Record = {}; + + // intercept data so that it can be exported + res.write = function(chunk: any) { + chunks.push(Buffer.from(chunk)); + write.apply(res, arguments); + }; + + res.setHeader = function(name: string, value: string) { + headers[name.toLowerCase()] = value; + setHeader.apply(res, arguments); + }; + + res.end = function(chunk?: any) { + if (chunk) chunks.push(Buffer.from(chunk)); + end.apply(res, arguments); + + process.send({ + __sapper__: true, + event: 'file', + url: req.url, + method: req.method, + status: res.statusCode, + type: headers['content-type'], + body: Buffer.concat(chunks).toString() + }); + }; + } + + const handle_next = (err?: Error) => { + if (err) { + res.statusCode = 500; + res.end(err.message); + } else { + process.nextTick(next); + } + }; + + try { + handle_method(req, res, handle_next); + } catch (err) { + handle_next(err); + } + } else { + // no matching handler for method + process.nextTick(next); + } + } + + return function find_route(req: Req, res: Res, next: () => void) { + if (req[IGNORE]) return next(); + + for (const route of routes) { + if (route.pattern.test(req.path)) { + handle_route(route, req, res, next); + return; + } + } + + next(); + }; +} \ No newline at end of file diff --git a/templates/src/server/middleware/index.ts b/templates/src/server/middleware/index.ts new file mode 100644 index 0000000..f06942c --- /dev/null +++ b/templates/src/server/middleware/index.ts @@ -0,0 +1,143 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { build_dir, dev, manifest, IGNORE } from '../placeholders'; +import { Handler, Req, Res, Store } from './types'; +import { get_server_route_handler } from './get_server_route_handler'; +import { get_page_handler } from './get_page_handler'; +import { lookup } from './mime'; + +export default function middleware(opts: { + store?: (req: Req, res: Res) => Store, + ignore?: any +} = {}) { + const { store, ignore } = opts; + + let emitted_basepath = false; + + return compose_handlers([ + ignore && ((req: Req, res: Res, next: () => void) => { + req[IGNORE] = should_ignore(req.path, ignore); + next(); + }), + + (req: Req, res: Res, next: () => void) => { + if (req[IGNORE]) return next(); + + if (req.baseUrl === undefined) { + let { originalUrl } = req; + if (req.url === '/' && originalUrl[originalUrl.length - 1] !== '/') { + originalUrl += '/'; + } + + req.baseUrl = originalUrl + ? originalUrl.slice(0, -req.url.length) + : ''; + } + + if (!emitted_basepath && process.send) { + process.send({ + __sapper__: true, + event: 'basepath', + basepath: req.baseUrl + }); + + emitted_basepath = true; + } + + if (req.path === undefined) { + req.path = req.url.replace(/\?.*/, ''); + } + + next(); + }, + + fs.existsSync(path.join(build_dir, 'index.html')) && serve({ + pathname: '/index.html', + cache_control: dev ? 'no-cache' : 'max-age=600' + }), + + fs.existsSync(path.join(build_dir, 'service-worker.js')) && serve({ + pathname: '/service-worker.js', + cache_control: 'no-cache, no-store, must-revalidate' + }), + + fs.existsSync(path.join(build_dir, 'service-worker.js.map')) && serve({ + pathname: '/service-worker.js.map', + cache_control: 'no-cache, no-store, must-revalidate' + }), + + serve({ + prefix: '/client/', + cache_control: dev ? 'no-cache' : 'max-age=31536000, immutable' + }), + + get_server_route_handler(manifest.server_routes), + + get_page_handler(manifest, store) + ].filter(Boolean)); +} + +export function compose_handlers(handlers: Handler[]) { + return (req: Req, res: Res, next: () => void) => { + let i = 0; + function go() { + const handler = handlers[i]; + + if (handler) { + handler(req, res, () => { + i += 1; + go(); + }); + } else { + next(); + } + } + + go(); + }; +} + +export function should_ignore(uri: string, val: any) { + if (Array.isArray(val)) return val.some(x => should_ignore(uri, x)); + if (val instanceof RegExp) return val.test(uri); + if (typeof val === 'function') return val(uri); + return uri.startsWith(val.charCodeAt(0) === 47 ? val : `/${val}`); +} + +export function serve({ prefix, pathname, cache_control }: { + prefix?: string, + pathname?: string, + cache_control: string +}) { + const filter = pathname + ? (req: Req) => req.path === pathname + : (req: Req) => req.path.startsWith(prefix); + + const cache: Map = new Map(); + + const read = dev + ? (file: string) => fs.readFileSync(path.resolve(build_dir, file)) + : (file: string) => (cache.has(file) ? cache : cache.set(file, fs.readFileSync(path.resolve(build_dir, file)))).get(file) + + return (req: Req, res: Res, next: () => void) => { + if (req[IGNORE]) return next(); + + if (filter(req)) { + const type = lookup(req.path); + + try { + const file = decodeURIComponent(req.path.slice(1)); + const data = read(file); + + res.setHeader('Content-Type', type); + res.setHeader('Cache-Control', cache_control); + res.end(data); + } catch (err) { + res.statusCode = 404; + res.end('not found'); + } + } else { + next(); + } + }; +} \ No newline at end of file diff --git a/src/middleware/mime-types.md b/templates/src/server/middleware/mime-types.md similarity index 100% rename from src/middleware/mime-types.md rename to templates/src/server/middleware/mime-types.md diff --git a/src/middleware/mime.ts b/templates/src/server/middleware/mime.ts similarity index 100% rename from src/middleware/mime.ts rename to templates/src/server/middleware/mime.ts diff --git a/templates/src/server/middleware/types.ts b/templates/src/server/middleware/types.ts new file mode 100644 index 0000000..6d7597f --- /dev/null +++ b/templates/src/server/middleware/types.ts @@ -0,0 +1,69 @@ +import { ClientRequest, ServerResponse } from 'http'; + +export type ServerRoute = { + pattern: RegExp; + handlers: Record; + params: (match: RegExpMatchArray) => Record; +}; + +export type Page = { + pattern: RegExp; + parts: Array<{ + name: string; + component: Component; + params?: (match: RegExpMatchArray) => Record; + }> +}; + +export type Manifest = { + server_routes: ServerRoute[]; + pages: Page[]; + root: Component; + error: Component; +} + +export type Handler = (req: Req, res: Res, next: () => void) => void; + +export type Store = { + get: () => any +}; + +export type Props = { + path: string; + query: Record; + params: Record; + error?: { message: string }; + status?: number; + child: { + segment: string; + component: Component; + props: Props; + }; + [key: string]: any; +}; + +export interface Req extends ClientRequest { + url: string; + baseUrl: string; + originalUrl: string; + method: string; + path: string; + params: Record; + query: Record; + headers: Record; +} + +export interface Res extends ServerResponse { + write: (data: any) => void; +} + +export { ServerResponse }; + +interface Component { + render: (data: any, opts: { store: Store }) => { + head: string; + css: { code: string, map: any }; + html: string + }, + preload: (data: any) => any | Promise +} \ No newline at end of file diff --git a/templates/src/server/placeholders.ts b/templates/src/server/placeholders.ts new file mode 100644 index 0000000..b95b5ea --- /dev/null +++ b/templates/src/server/placeholders.ts @@ -0,0 +1,11 @@ +import { Manifest } from './types'; + +export const manifest: Manifest = __MANIFEST__; + +export const build_dir = __BUILD__DIR__; + +export const src_dir = __SRC__DIR__; + +export const dev = __DEV__; + +export const IGNORE = '__SAPPER__IGNORE__'; \ No newline at end of file