mirror of
https://github.com/kevin-DL/sapper.git
synced 2026-01-15 20:34:44 +00:00
move app logic into templates (#444)
This commit is contained in:
@@ -2,7 +2,6 @@
|
|||||||
"name": "sapper",
|
"name": "sapper",
|
||||||
"version": "0.21.1",
|
"version": "0.21.1",
|
||||||
"description": "Military-grade apps, engineered by Svelte",
|
"description": "Military-grade apps, engineered by Svelte",
|
||||||
"main": "dist/middleware.js",
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"sapper": "./sapper"
|
"sapper": "./sapper"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import json from 'rollup-plugin-json';
|
|||||||
import resolve from 'rollup-plugin-node-resolve';
|
import resolve from 'rollup-plugin-node-resolve';
|
||||||
import commonjs from 'rollup-plugin-commonjs';
|
import commonjs from 'rollup-plugin-commonjs';
|
||||||
import pkg from './package.json';
|
import pkg from './package.json';
|
||||||
|
import { builtinModules } from 'module';
|
||||||
|
|
||||||
const external = [].concat(
|
const external = [].concat(
|
||||||
Object.keys(pkg.dependencies),
|
Object.keys(pkg.dependencies),
|
||||||
@@ -11,27 +12,37 @@ const external = [].concat(
|
|||||||
'sapper/core.js'
|
'sapper/core.js'
|
||||||
);
|
);
|
||||||
|
|
||||||
export default [
|
function template(kind, external) {
|
||||||
{
|
return {
|
||||||
input: `src/runtime/index.ts`,
|
input: `templates/src/${kind}/index.ts`,
|
||||||
output: {
|
output: {
|
||||||
file: `runtime.js`,
|
file: `templates/dist/${kind}.js`,
|
||||||
format: 'es'
|
format: 'es'
|
||||||
},
|
},
|
||||||
|
external,
|
||||||
plugins: [
|
plugins: [
|
||||||
|
resolve(),
|
||||||
|
commonjs(),
|
||||||
|
string({
|
||||||
|
include: '**/*.md'
|
||||||
|
}),
|
||||||
typescript({
|
typescript({
|
||||||
typescript: require('typescript'),
|
typescript: require('typescript'),
|
||||||
target: "ES2017"
|
target: "ES2017"
|
||||||
})
|
})
|
||||||
]
|
]
|
||||||
},
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default [
|
||||||
|
template('client', []),
|
||||||
|
template('server', builtinModules),
|
||||||
|
|
||||||
{
|
{
|
||||||
input: [
|
input: [
|
||||||
`src/api.ts`,
|
`src/api.ts`,
|
||||||
`src/cli.ts`,
|
`src/cli.ts`,
|
||||||
`src/core.ts`,
|
`src/core.ts`,
|
||||||
`src/middleware.ts`,
|
|
||||||
`src/rollup.ts`,
|
`src/rollup.ts`,
|
||||||
`src/webpack.ts`
|
`src/webpack.ts`
|
||||||
],
|
],
|
||||||
@@ -42,9 +53,6 @@ export default [
|
|||||||
},
|
},
|
||||||
external,
|
external,
|
||||||
plugins: [
|
plugins: [
|
||||||
string({
|
|
||||||
include: '**/*.md'
|
|
||||||
}),
|
|
||||||
json(),
|
json(),
|
||||||
resolve(),
|
resolve(),
|
||||||
commonjs(),
|
commonjs(),
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export function create_main_manifests({ bundler, manifest_data, dev_port }: {
|
|||||||
manifest_data: ManifestData;
|
manifest_data: ManifestData;
|
||||||
dev_port?: number;
|
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);
|
if (!fs.existsSync(manifest_dir)) fs.mkdirSync(manifest_dir);
|
||||||
|
|
||||||
const path_to_routes = path.relative(manifest_dir, locations.routes());
|
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];
|
export const routes = [\n\t${manifest_data.pages.map((r: Page) => `{ pattern: ${r.pattern} }`).join(',\n\t')}\n];
|
||||||
`.replace(/^\t\t/gm, '').trim();
|
`.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(
|
function generate_client(
|
||||||
@@ -64,6 +64,9 @@ function generate_client(
|
|||||||
bundler: string,
|
bundler: string,
|
||||||
dev_port?: number
|
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 =>
|
const page_ids = new Set(manifest_data.pages.map(page =>
|
||||||
page.pattern.toString()));
|
page.pattern.toString()));
|
||||||
|
|
||||||
@@ -71,7 +74,6 @@ function generate_client(
|
|||||||
!page_ids.has(route.pattern.toString()));
|
!page_ids.has(route.pattern.toString()));
|
||||||
|
|
||||||
let code = `
|
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 root from ${stringify(get_file(path_to_routes, manifest_data.root))};
|
||||||
import error from ${stringify(posixify(`${path_to_routes}/_error.html`))};
|
import error from ${stringify(posixify(`${path_to_routes}/_error.html`))};
|
||||||
|
|
||||||
@@ -90,7 +92,7 @@ function generate_client(
|
|||||||
};`;
|
};`;
|
||||||
}).join('\n')}
|
}).join('\n')}
|
||||||
|
|
||||||
export const manifest = {
|
const manifest = {
|
||||||
ignore: [${server_routes_to_ignore.map(route => route.pattern).join(', ')}],
|
ignore: [${server_routes_to_ignore.map(route => route.pattern).join(', ')}],
|
||||||
|
|
||||||
pages: [
|
pages: [
|
||||||
@@ -115,10 +117,7 @@ function generate_client(
|
|||||||
root,
|
root,
|
||||||
|
|
||||||
error
|
error
|
||||||
};
|
};`.replace(/^\t\t/gm, '').trim();
|
||||||
|
|
||||||
// this is included for legacy reasons
|
|
||||||
export const routes = {};`.replace(/^\t\t/gm, '').trim();
|
|
||||||
|
|
||||||
if (dev()) {
|
if (dev()) {
|
||||||
const sapper_dev_client = posixify(
|
const sapper_dev_client = posixify(
|
||||||
@@ -132,13 +131,17 @@ function generate_client(
|
|||||||
});`.replace(/^\t{3}/gm, '');
|
});`.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(
|
function generate_server(
|
||||||
manifest_data: ManifestData,
|
manifest_data: ManifestData,
|
||||||
path_to_routes: string
|
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(
|
const imports = [].concat(
|
||||||
manifest_data.server_routes.map(route =>
|
manifest_data.server_routes.map(route =>
|
||||||
`import * as ${route.name} from ${stringify(posixify(`${path_to_routes}/${route.file}`))};`),
|
`import * as ${route.name} from ${stringify(posixify(`${path_to_routes}/${route.file}`))};`),
|
||||||
@@ -149,7 +152,6 @@ function generate_server(
|
|||||||
);
|
);
|
||||||
|
|
||||||
let code = `
|
let code = `
|
||||||
// This file is generated by Sapper — do not edit it!
|
|
||||||
${imports.join('\n')}
|
${imports.join('\n')}
|
||||||
|
|
||||||
const d = decodeURIComponent;
|
const d = decodeURIComponent;
|
||||||
@@ -199,7 +201,11 @@ function generate_server(
|
|||||||
// this is included for legacy reasons
|
// this is included for legacy reasons
|
||||||
export const routes = {};`.replace(/^\t\t/gm, '').trim();
|
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) {
|
function get_file(path_to_routes: string, component: PageComponent) {
|
||||||
|
|||||||
@@ -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 <a> 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
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,26 +1,14 @@
|
|||||||
import { detach, findAnchor, scroll_state, which } from './utils';
|
import { Manifest, Target, ScrollPosition, Component, Redirect, ComponentLoader, ComponentConstructor, RootProps } from './types';
|
||||||
import { Component, ComponentConstructor, Params, Query, Redirect, Manifest, RouteData, ScrollPosition, Store, Target, ComponentLoader } from './interfaces';
|
import goto from './goto';
|
||||||
|
|
||||||
const initial_data = typeof window !== 'undefined' && window.__SAPPER__;
|
export const manifest: Manifest = __MANIFEST__;
|
||||||
|
|
||||||
export let root: Component;
|
let ready = false;
|
||||||
let target: Node;
|
let root_component: Component;
|
||||||
let store: Store;
|
|
||||||
let manifest: Manifest;
|
|
||||||
let segments: string[] = [];
|
let segments: string[] = [];
|
||||||
|
let current_token: {};
|
||||||
type RootProps = {
|
let root_preload: Promise<any>;
|
||||||
path: string;
|
let root_data: any;
|
||||||
params: Record<string, string>;
|
|
||||||
query: Record<string, string>;
|
|
||||||
child: Child;
|
|
||||||
};
|
|
||||||
|
|
||||||
type Child = {
|
|
||||||
segment?: string;
|
|
||||||
props?: any;
|
|
||||||
component?: Component;
|
|
||||||
};
|
|
||||||
|
|
||||||
const root_props: RootProps = {
|
const root_props: RootProps = {
|
||||||
path: null,
|
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) => {},
|
pushState: (state: any, title: string, href: string) => {},
|
||||||
replaceState: (state: any, title: string, href: string) => {},
|
replaceState: (state: any, title: string, href: string) => {},
|
||||||
scrollRestoration: ''
|
scrollRestoration: ''
|
||||||
};
|
};
|
||||||
|
|
||||||
const scroll_history: Record<string, ScrollPosition> = {};
|
export const scroll_history: Record<string, ScrollPosition> = {};
|
||||||
let uid = 1;
|
|
||||||
let cid: number;
|
|
||||||
|
|
||||||
if ('scrollRestoration' in history) {
|
export function select_route(url: URL): Target {
|
||||||
history.scrollRestoration = 'manual';
|
|
||||||
}
|
|
||||||
|
|
||||||
function select_route(url: URL): Target {
|
|
||||||
if (url.origin !== window.location.origin) return null;
|
if (url.origin !== window.location.origin) return null;
|
||||||
if (!url.pathname.startsWith(initial_data.baseUrl)) 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<any> {
|
||||||
|
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: {}) {
|
function render(data: any, nullable_depth: number, scroll: ScrollPosition, token: {}) {
|
||||||
if (current_token !== token) return;
|
if (current_token !== token) return;
|
||||||
|
|
||||||
if (root) {
|
if (root_component) {
|
||||||
// first, clear out highest-level root component
|
// first, clear out highest-level root component
|
||||||
let level = data.child;
|
let level = data.child;
|
||||||
for (let i = 0; i < nullable_depth; i += 1) {
|
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;
|
const { component } = level;
|
||||||
level.component = null;
|
level.component = null;
|
||||||
root.set({ child: data.child });
|
root_component.set({ child: data.child });
|
||||||
|
|
||||||
// then render new stuff
|
// then render new stuff
|
||||||
level.component = component;
|
level.component = component;
|
||||||
root.set(data);
|
root_component.set(data);
|
||||||
} else {
|
} else {
|
||||||
// first load — remove SSR'd <head> contents
|
// first load — remove SSR'd <head> contents
|
||||||
const start = document.querySelector('#sapper-head-start');
|
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);
|
Object.assign(data, root_data);
|
||||||
|
|
||||||
root = new manifest.root({
|
root_component = new manifest.root({
|
||||||
target,
|
target,
|
||||||
data,
|
data,
|
||||||
store,
|
store,
|
||||||
@@ -124,38 +173,7 @@ function render(data: any, nullable_depth: number, scroll: ScrollPosition, token
|
|||||||
ready = true;
|
ready = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function changed(a: Record<string, string | true>, b: Record<string, string | true>) {
|
export function prepare_page(target: Target): Promise<{
|
||||||
return JSON.stringify(a) !== JSON.stringify(b);
|
|
||||||
}
|
|
||||||
|
|
||||||
let root_preload: Promise<any>;
|
|
||||||
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<ComponentConstructor> {
|
|
||||||
// TODO this is temporary — once placeholders are
|
|
||||||
// always rewritten, scratch the ternary
|
|
||||||
const promises: Array<Promise<any>> = (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<{
|
|
||||||
redirect?: Redirect;
|
redirect?: Redirect;
|
||||||
data?: any;
|
data?: any;
|
||||||
nullable_depth?: number;
|
nullable_depth?: number;
|
||||||
@@ -292,213 +310,34 @@ function prepare_page(target: Target): Promise<{
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function navigate(target: Target, id: number): Promise<any> {
|
function load_css(chunk: string) {
|
||||||
if (id) {
|
const href = `${initial_data.baseUrl}client/${chunk}`;
|
||||||
// popstate or initial navigation
|
if (document.querySelector(`link[href="${href}"]`)) return;
|
||||||
cid = id;
|
|
||||||
} else {
|
|
||||||
// clicked on a link. preserve scroll state
|
|
||||||
scroll_history[cid] = scroll_state();
|
|
||||||
|
|
||||||
id = cid = ++uid;
|
return new Promise((fulfil, reject) => {
|
||||||
scroll_history[cid] = { x: 0, y: 0 };
|
const link = document.createElement('link');
|
||||||
}
|
link.rel = 'stylesheet';
|
||||||
|
link.href = href;
|
||||||
|
|
||||||
cid = id;
|
link.onload = () => fulfil();
|
||||||
|
link.onerror = reject;
|
||||||
|
|
||||||
if (root) {
|
document.head.appendChild(link);
|
||||||
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 = <HTMLAnchorElement | SVGAElement>findAnchor(<Node>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 ? (<SVGAElement>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 <a> has a target
|
|
||||||
if (svg ? (<SVGAElement>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 = <HTMLAnchorElement>findAnchor(<Node>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);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function goto(href: string, opts = { replaceState: false }) {
|
export function load_component(component: ComponentLoader): Promise<ComponentConstructor> {
|
||||||
const target = select_route(new URL(href, document.baseURI));
|
// TODO this is temporary — once placeholders are
|
||||||
let promise;
|
// always rewritten, scratch the ternary
|
||||||
|
const promises: Array<Promise<any>> = (typeof component.css === 'string' ? [] : component.css.map(load_css));
|
||||||
if (target) {
|
promises.unshift(component.js());
|
||||||
promise = navigate(target, null);
|
return Promise.all(promises).then(values => values[0].default);
|
||||||
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 prefetchRoutes(pathnames: string[]) {
|
function detach(node: Node) {
|
||||||
if (!manifest) throw new Error(`You must call init() first`);
|
node.parentNode.removeChild(node);
|
||||||
|
|
||||||
return manifest.pages
|
|
||||||
.filter(route => {
|
|
||||||
if (!pathnames) return true;
|
|
||||||
return pathnames.some(pathname => route.pattern.test(pathname));
|
|
||||||
})
|
|
||||||
.reduce((promise: Promise<any>, route) => promise.then(() => {
|
|
||||||
return Promise.all(route.parts.map(part => part && load_component(part.component)));
|
|
||||||
}), Promise.resolve());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// remove this in 0.9
|
function changed(a: Record<string, string | true>, b: Record<string, string | true>) {
|
||||||
export { prefetchRoutes as preloadRoutes };
|
return JSON.stringify(a) !== JSON.stringify(b);
|
||||||
|
}
|
||||||
16
templates/src/client/goto/index.ts
Normal file
16
templates/src/client/goto/index.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
4
templates/src/client/index.ts
Normal file
4
templates/src/client/index.ts
Normal file
@@ -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';
|
||||||
10
templates/src/client/prefetch/index.ts
Normal file
10
templates/src/client/prefetch/index.ts
Normal file
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
14
templates/src/client/prefetchRoutes/index.ts
Normal file
14
templates/src/client/prefetchRoutes/index.ts
Normal file
@@ -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<any>, route) => promise.then(() => {
|
||||||
|
return Promise.all(route.parts.map(part => part && load_component(part.component)));
|
||||||
|
}), Promise.resolve());
|
||||||
|
}
|
||||||
138
templates/src/client/start/index.ts
Normal file
138
templates/src/client/start/index.ts
Normal file
@@ -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 = <HTMLAnchorElement>find_anchor(<Node>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 = <HTMLAnchorElement | SVGAElement>find_anchor(<Node>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 ? (<SVGAElement>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 <a> has a target
|
||||||
|
if (svg ? (<SVGAElement>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 <a> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,20 @@
|
|||||||
import { Store } from '../interfaces';
|
|
||||||
|
|
||||||
export { Store };
|
|
||||||
export type Params = Record<string, string>;
|
export type Params = Record<string, string>;
|
||||||
export type Query = Record<string, string | true>;
|
export type Query = Record<string, string | true>;
|
||||||
export type RouteData = { params: Params, query: Query, path: string };
|
export type RouteData = { params: Params, query: Query, path: string };
|
||||||
|
|
||||||
|
type Child = {
|
||||||
|
segment?: string;
|
||||||
|
props?: any;
|
||||||
|
component?: Component;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RootProps = {
|
||||||
|
path: string;
|
||||||
|
params: Record<string, string>;
|
||||||
|
query: Record<string, string>;
|
||||||
|
child: Child;
|
||||||
|
};
|
||||||
|
|
||||||
export interface ComponentConstructor {
|
export interface ComponentConstructor {
|
||||||
new (options: { target: Node, data: any, store: Store, hydrate: boolean }): Component;
|
new (options: { target: Node, data: any, store: Store, hydrate: boolean }): Component;
|
||||||
preload: (props: { params: Params, query: Query }) => Promise<any>;
|
preload: (props: { params: Params, query: Query }) => Promise<any>;
|
||||||
@@ -52,3 +62,7 @@ export type Redirect = {
|
|||||||
statusCode: number;
|
statusCode: number;
|
||||||
location: string;
|
location: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type Store = {
|
||||||
|
get: () => any;
|
||||||
|
}
|
||||||
1
templates/src/server/index.ts
Normal file
1
templates/src/server/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { default as middleware } from './middleware/index';
|
||||||
@@ -1,301 +1,29 @@
|
|||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import { URL } from 'url';
|
|
||||||
import { ClientRequest, ServerResponse } from 'http';
|
|
||||||
import cookie from 'cookie';
|
import cookie from 'cookie';
|
||||||
import devalue from 'devalue';
|
import devalue from 'devalue';
|
||||||
import fetch from 'node-fetch';
|
import fetch from 'node-fetch';
|
||||||
import { lookup } from './middleware/mime';
|
import { build_dir, dev, src_dir, IGNORE } from '../placeholders';
|
||||||
import { locations, dev } from './config';
|
import { Manifest, Page, Props, Req, Res, Store } from './types';
|
||||||
import sourceMapSupport from 'source-map-support';
|
|
||||||
import read_template from './core/read_template';
|
|
||||||
|
|
||||||
sourceMapSupport.install();
|
export function get_page_handler(
|
||||||
|
|
||||||
type ServerRoute = {
|
|
||||||
pattern: RegExp;
|
|
||||||
handlers: Record<string, Handler>;
|
|
||||||
params: (match: RegExpMatchArray) => Record<string, string>;
|
|
||||||
};
|
|
||||||
|
|
||||||
type Page = {
|
|
||||||
pattern: RegExp;
|
|
||||||
parts: Array<{
|
|
||||||
name: string;
|
|
||||||
component: Component;
|
|
||||||
params?: (match: RegExpMatchArray) => Record<string, string>;
|
|
||||||
}>
|
|
||||||
};
|
|
||||||
|
|
||||||
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<string, string>;
|
|
||||||
params: Record<string, string>;
|
|
||||||
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<string, string>;
|
|
||||||
query: Record<string, string>;
|
|
||||||
headers: Record<string, string>;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Component {
|
|
||||||
render: (data: any, opts: { store: Store }) => {
|
|
||||||
head: string;
|
|
||||||
css: { code: string, map: any };
|
|
||||||
html: string
|
|
||||||
},
|
|
||||||
preload: (data: any) => any | Promise<any>
|
|
||||||
}
|
|
||||||
|
|
||||||
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: {
|
|
||||||
manifest: Manifest,
|
manifest: Manifest,
|
||||||
store: (req: Req, res: ServerResponse) => Store,
|
store_getter: (req: Req, res: Res) => 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<string, Buffer> = 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<string, string> = {};
|
|
||||||
|
|
||||||
// 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
|
|
||||||
) {
|
) {
|
||||||
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()
|
const template = dev
|
||||||
? () => JSON.parse(fs.readFileSync(path.join(output, 'build.json'), 'utf-8'))
|
? () => read_template(src_dir)
|
||||||
: (assets => () => assets)(JSON.parse(fs.readFileSync(path.join(output, 'build.json'), 'utf-8')));
|
: (str => () => str)(read_template(build_dir));
|
||||||
|
|
||||||
const template = dev()
|
const has_service_worker = fs.existsSync(path.join(build_dir, 'service-worker.js'));
|
||||||
? () => read_template()
|
|
||||||
: (str => () => str)(read_template(output));
|
|
||||||
|
|
||||||
const { server_routes, pages } = manifest;
|
const { server_routes, pages } = manifest;
|
||||||
const error_route = manifest.error;
|
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({
|
handle_page({
|
||||||
pattern: null,
|
pattern: null,
|
||||||
parts: [
|
parts: [
|
||||||
@@ -304,7 +32,7 @@ function get_page_handler(
|
|||||||
}, req, res, statusCode, error || new Error('Unknown error in preload function'));
|
}, 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: {
|
const build_info: {
|
||||||
bundler: 'rollup' | 'webpack',
|
bundler: 'rollup' | 'webpack',
|
||||||
shimport: string | null,
|
shimport: string | null,
|
||||||
@@ -313,7 +41,7 @@ function get_page_handler(
|
|||||||
} = get_build_info();
|
} = get_build_info();
|
||||||
|
|
||||||
res.setHeader('Content-Type', 'text/html');
|
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
|
// preload main.js and current route
|
||||||
// TODO detect other stuff we can preload? images, CSS, fonts?
|
// TODO detect other stuff we can preload? images, CSS, fonts?
|
||||||
@@ -483,7 +211,6 @@ function get_page_handler(
|
|||||||
serialized.store && `store:${serialized.store}`
|
serialized.store && `store:${serialized.store}`
|
||||||
].filter(Boolean).join(',')}};`;
|
].filter(Boolean).join(',')}};`;
|
||||||
|
|
||||||
const has_service_worker = fs.existsSync(path.join(locations.dest(), 'service-worker.js'));
|
|
||||||
if (has_service_worker) {
|
if (has_service_worker) {
|
||||||
script += `if('serviceWorker' in navigator)navigator.serviceWorker.register('${req.baseUrl}/service-worker.js');`;
|
script += `if('serviceWorker' in navigator)navigator.serviceWorker.register('${req.baseUrl}/service-worker.js');`;
|
||||||
}
|
}
|
||||||
@@ -505,6 +232,7 @@ function get_page_handler(
|
|||||||
let styles: string;
|
let styles: string;
|
||||||
|
|
||||||
// TODO make this consistent across apps
|
// TODO make this consistent across apps
|
||||||
|
// TODO embed build_info in placeholder.ts
|
||||||
if (build_info.css && build_info.css.main) {
|
if (build_info.css && build_info.css.main) {
|
||||||
const css_chunks = new Set();
|
const css_chunks = new Set();
|
||||||
if (build_info.css.main) css_chunks.add(build_info.css.main);
|
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 (req[IGNORE]) return next();
|
||||||
|
|
||||||
if (!server_routes.some(route => route.pattern.test(req.path))) {
|
if (!server_routes.some(route => route.pattern.test(req.path))) {
|
||||||
@@ -565,24 +293,8 @@ function get_page_handler(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function compose_handlers(handlers: Handler[]) {
|
function read_template(dir = build_dir) {
|
||||||
return (req: Req, res: ServerResponse, next: () => void) => {
|
return fs.readFileSync(`${dir}/template.html`, 'utf-8');
|
||||||
let i = 0;
|
|
||||||
function go() {
|
|
||||||
const handler = handlers[i];
|
|
||||||
|
|
||||||
if (handler) {
|
|
||||||
handler(req, res, () => {
|
|
||||||
i += 1;
|
|
||||||
go();
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
next();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
go();
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function try_serialize(data: any) {
|
function try_serialize(data: any) {
|
||||||
78
templates/src/server/middleware/get_server_route_handler.ts
Normal file
78
templates/src/server/middleware/get_server_route_handler.ts
Normal file
@@ -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<string, string> = {};
|
||||||
|
|
||||||
|
// 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();
|
||||||
|
};
|
||||||
|
}
|
||||||
143
templates/src/server/middleware/index.ts
Normal file
143
templates/src/server/middleware/index.ts
Normal file
@@ -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<string, Buffer> = 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();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
69
templates/src/server/middleware/types.ts
Normal file
69
templates/src/server/middleware/types.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { ClientRequest, ServerResponse } from 'http';
|
||||||
|
|
||||||
|
export type ServerRoute = {
|
||||||
|
pattern: RegExp;
|
||||||
|
handlers: Record<string, Handler>;
|
||||||
|
params: (match: RegExpMatchArray) => Record<string, string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Page = {
|
||||||
|
pattern: RegExp;
|
||||||
|
parts: Array<{
|
||||||
|
name: string;
|
||||||
|
component: Component;
|
||||||
|
params?: (match: RegExpMatchArray) => Record<string, string>;
|
||||||
|
}>
|
||||||
|
};
|
||||||
|
|
||||||
|
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<string, string>;
|
||||||
|
params: Record<string, string>;
|
||||||
|
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<string, string>;
|
||||||
|
query: Record<string, string>;
|
||||||
|
headers: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<any>
|
||||||
|
}
|
||||||
11
templates/src/server/placeholders.ts
Normal file
11
templates/src/server/placeholders.ts
Normal file
@@ -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__';
|
||||||
Reference in New Issue
Block a user