Compare commits

...

100 Commits

Author SHA1 Message Date
Rich Harris
087acd5765 -> v0.13.0 2018-05-28 19:49:40 -04:00
Rich Harris
43bf6e8d8a Merge pull request #273 from sveltejs/gh-272
[WIP] expose dev, build, export APIs
2018-05-28 19:47:08 -04:00
Rich Harris
78be6aa343 add api.js 2018-05-28 18:04:18 -04:00
Rich Harris
8ba57969c2 oops 2018-05-26 12:39:44 -04:00
Rich Harris
58d2f605fc expose find_page method 2018-05-26 12:31:52 -04:00
Rich Harris
e0b4319c7d preserve webpack stats, write client assets elsewhere 2018-05-26 12:31:43 -04:00
Rich Harris
98d0df4320 work around webpack silliness 2018-05-23 22:05:40 -04:00
Rich Harris
6aa3ce4f05 make stdout etc available via api 2018-05-23 21:56:35 -04:00
Rich Harris
046db325f1 update deps 2018-05-23 20:41:48 -04:00
Rich Harris
1a4bace5f4 add dev API 2018-05-23 09:05:24 -04:00
Rich Harris
0dbf75f100 create API for exporting 2018-05-22 11:57:24 -04:00
Rich Harris
4f49fd8d5c create build API 2018-05-22 11:32:06 -04:00
Rich Harris
86f71e1faf Merge pull request #268 from lukeed/dep/colors
Update colors dep
2018-05-22 08:01:23 -04:00
Luke Edwards
147e2c64b5 update webpack-format-messages 2018-05-17 13:03:25 -07:00
Luke Edwards
9063057b0c swap clorox —> ansi-colors 2018-05-15 09:00:55 -07:00
Rich Harris
25f0d94595 -> v0.12.0 2018-05-05 10:00:58 -04:00
Rich Harris
8155df2e22 Merge branch 'gh-157' 2018-05-05 09:58:12 -04:00
Rich Harris
bb51470004 Merge pull request #259 from sveltejs/gh-157
switch to single App component model
2018-05-05 09:57:28 -04:00
Rich Harris
53446e2ec7 Merge branch 'master' into gh-157 2018-05-05 09:45:04 -04:00
Rich Harris
c4c09550eb Merge pull request #260 from sveltejs/another-sorting-bug
fix sorting
2018-05-05 09:43:16 -04:00
Rich Harris
da47fdec96 fix sorting 2018-05-05 09:38:24 -04:00
Rich Harris
971342ac7a set preloading: true when appropriate 2018-05-04 23:23:41 -04:00
Rich Harris
3becc1cbe2 error on incorrect init args 2018-05-04 23:06:10 -04:00
Rich Harris
8ee5346900 switch to single App component model (#157) 2018-05-04 22:46:41 -04:00
Rich Harris
9e4b79c6ff Merge pull request #258 from sveltejs/gh-208
exit with code 1 if build/export fails
2018-05-04 17:48:29 -04:00
Rich Harris
4ec1c65395 exit with code 1 if build/export fails - fixes #208 2018-05-04 17:42:37 -04:00
Rich Harris
c743d11b3b -> v0.11.1 2018-05-04 17:22:34 -04:00
Rich Harris
b525eb6480 get tests passing 2018-05-04 17:19:39 -04:00
Rich Harris
210d03fb06 Merge branch 'collision' of https://github.com/akihikodaki/sapper into akihikodaki-collision 2018-05-04 17:08:55 -04:00
Rich Harris
0685cc4cbe Merge branch 'master' of github.com:sveltejs/sapper 2018-05-04 17:08:47 -04:00
Rich Harris
9e2d0a7fbc Merge branch 'master' into collision 2018-05-04 17:05:18 -04:00
Rich Harris
a751a3b731 Merge pull request #254 from akihikodaki/dot_strict
Ignore files and directories with leading dots except .well-known
2018-05-04 17:04:27 -04:00
Rich Harris
bc7faeeab9 remove unused esm package 2018-05-04 16:55:57 -04:00
Rich Harris
a88c1de2f6 Merge pull request #256 from johnmuhl/esm
replace discontinued @std/esm package with esm
2018-05-04 16:53:35 -04:00
john muhl
a231795c4c replace discontinued @std/esm package with esm 2018-05-04 13:56:17 -05:00
Akihiko Odaki
ba7525c676 Ignore files and directories with leading dots except .well-known 2018-05-04 22:18:23 +09:00
Rich Harris
4843e9a40a -> v0.11.0 2018-05-03 23:51:04 -04:00
Rich Harris
ca4a1ca9b0 Merge pull request #251 from sveltejs/client-info
only save the bits of client_info we need
2018-05-03 23:49:30 -04:00
Rich Harris
ad7c872ee3 Merge pull request #250 from sveltejs/gh-240
implement --launcher
2018-05-03 23:49:06 -04:00
Rich Harris
4f98324a8a oops, missed one 2018-05-03 23:46:41 -04:00
Rich Harris
1fcf3f79ee only save the bits of client_info we need 2018-05-03 23:42:19 -04:00
Rich Harris
0b5741194a Merge pull request #205 from sveltejs/gh-140
prefetch on mouse stop
2018-05-03 23:31:08 -04:00
Rich Harris
9653d4c6ce Merge pull request #249 from sveltejs/gh-241
allow process.env.NODE_ENV to be overridden when building
2018-05-03 23:30:55 -04:00
Rich Harris
4fa5ed5e2c simplify 2018-05-03 23:30:09 -04:00
Rich Harris
f4eac2515f fix tests 2018-05-03 23:23:02 -04:00
Rich Harris
1a5364ae9d on second thoughts, default to build/index.js 2018-05-03 23:16:56 -04:00
Rich Harris
d7a9074c69 implement --launcher 2018-05-03 23:04:05 -04:00
Rich Harris
00adb53802 allow process.env.NODE_ENV to be overridden when building (#241) 2018-05-03 22:21:00 -04:00
Rich Harris
b10edddc96 cheat 2018-05-03 22:01:14 -04:00
Rich Harris
93b2d12438 Merge branch 'master' into gh-140 2018-05-03 21:54:52 -04:00
Rich Harris
7303e811be update tests, move test app to v2 2018-05-03 21:54:23 -04:00
Rich Harris
992d89027d Merge branch 'master' into collision 2018-05-03 21:44:28 -04:00
Rich Harris
3531cc587d -> v0.10.7 2018-05-03 21:42:50 -04:00
Rich Harris
562a91fa57 Merge pull request #245 from johnmuhl/patch-1
Include process.env in exporter server options
2018-05-03 21:40:50 -04:00
Rich Harris
93128a0156 Merge pull request #243 from akihikodaki/dot
Accept directory entries which starts with dot as routes
2018-05-03 21:39:46 -04:00
Rich Harris
d7a2132966 Merge pull request #234 from akihikodaki/master
Do not encode characters allowed in path when generating routes
2018-05-03 21:37:59 -04:00
John Muhl
56ac1aea9d match sapper start 2018-04-22 22:50:05 -05:00
John Muhl
37a9fb62e2 Include process.env in exporter server options 2018-04-22 20:29:47 -05:00
Rich Harris
a70e88b1f4 -> v0.10.6 2018-04-19 13:03:12 -04:00
Akihiko Odaki
6f9ce9ce85 Accept directory entries which starts with dot as routes
It allows to implement .well-known URIs.
2018-04-19 22:04:03 +09:00
Akihiko Odaki
917dd60cc3 Allow to have middleware for the path same with a HTML page
HTTP allows to change the type of the content to serve by Accept field in
the request. The middleware for the path same with a HTML page will
be inserted before the HTML renderer, and can take advantage of this
feature, using expressjs's "accepts" method, for example.
2018-04-15 23:11:08 +09:00
Akihiko Odaki
b13cc6f39a Do not encode characters allowed in path when generating routes
In RFC 3986, some characters not allowed in query, which encodeURIComponent
is designed for, is allowed in path.
A notable example is "@", which is commonly included in paths of social
profile pages. Such characters should not be encoded.

The new encoding function is conforming to the RFC.
2018-04-14 01:08:27 +09:00
Rich-Harris
2758382c68 -> v0.10.5 2018-04-06 18:32:44 -07:00
Rich Harris
dd7f1ff99c Merge pull request #231 from sveltejs/fix-missing-service-worker
fix missing service worker
2018-04-06 21:31:53 -04:00
Rich-Harris
45142cd037 fix missing service worker 2018-04-06 14:44:50 -07:00
Rich-Harris
ceb1caf1de -> v0.10.4 2018-04-03 21:43:30 -04:00
Rich Harris
7e263a3076 Merge pull request #227 from naturalethic/upgrade-chokidar-disable-globbing-issue-212
Upgrade chokidar disable globbing issue 212
2018-04-03 21:42:34 -04:00
Rich Harris
ec88d4a430 Remove unnecessary globbing pattern 2018-04-03 21:38:28 -04:00
Joshua Kifer
909ea72108 Update dev.ts 2018-04-03 14:04:08 -07:00
Joshua Kifer
cd09d75d99 Merge branch 'master' into upgrade-chokidar-disable-globbing-issue-212 2018-04-03 13:00:07 -07:00
Joshua Kifer
0e3abe489a Re-upgrade chokidar, disable globbing 2018-04-03 12:58:06 -07:00
Joshua Kifer
a5d141d2f1 Update (#1)
* Downgrade chokidar to 1.7.0

* -> v0.10.3
2018-04-03 12:49:55 -07:00
Rich-Harris
87eae6164b -> v0.10.3 2018-04-02 15:39:34 -04:00
Rich Harris
97e00f5a9c Merge pull request #226 from naturalethic/downgrade-chokidar
Downgrade chokidar to 1.7.0
2018-04-02 15:38:40 -04:00
Joshua Kifer
bd55558b5e Downgrade chokidar to 1.7.0 2018-04-02 12:24:57 -07:00
Rich-Harris
25dc4b3a4c -> v0.10.2 2018-03-25 15:20:48 -04:00
Rich Harris
72c27b78a3 Merge pull request #215 from sveltejs/stable-sort
Stable sort
2018-03-25 15:19:43 -04:00
Rich-Harris
25809ec409 enforce stable sort 2018-03-25 15:12:35 -04:00
Rich-Harris
3220c522d7 attach store to error pages 2018-03-20 16:08:23 -04:00
Rich Harris
d5d25f1d30 -> v0.10.1 2018-03-18 22:47:11 -04:00
Rich Harris
7ccd6ba329 Merge pull request #207 from sveltejs/fix-fetch-paths
fix server-side fetch paths
2018-03-18 22:41:01 -04:00
Rich Harris
35c30ae2c5 fix server-side fetch paths 2018-03-18 22:36:55 -04:00
Rich Harris
2c61f6d396 -> v0.10.0 2018-03-18 22:17:53 -04:00
Rich Harris
86233a8eab Merge pull request #206 from sveltejs/sapper-base-error
app/template.html must have %sapper.base%
2018-03-18 22:13:24 -04:00
Rich Harris
c140b128ee expect %sapper.base% 2018-03-18 22:06:11 -04:00
Rich Harris
a6b1527fd3 try using mousemove in tests 2018-03-18 21:53:13 -04:00
Rich Harris
c2f3a2aac0 prefetch on mouse stop (#140) 2018-03-18 21:41:47 -04:00
Rich Harris
66ac9773c0 Merge pull request #204 from nolanlawson/ignore-source-maps
Add support for sourcemap *.map files
2018-03-18 21:23:26 -04:00
Rich Harris
e60714bb98 Merge pull request #203 from sveltejs/gh-178-fetch
implement this.fetch
2018-03-18 21:21:00 -04:00
Nolan Lawson
52dfd6e939 Don't preload .map files 2018-03-18 16:31:53 -07:00
Nolan Lawson
fc2312eba6 Add full *.map file support 2018-03-18 16:17:41 -07:00
Nolan Lawson
cf90476255 Ignore source map files in %sapper.scripts% 2018-03-18 11:13:24 -07:00
Rich Harris
1e8d7d10ab implement this.fetch (#178) 2018-03-17 19:21:25 -04:00
Rich Harris
cf6621b83c Merge pull request #202 from sveltejs/gh-178-store
add server- and client-side store management (#178)
2018-03-17 16:10:48 -04:00
Rich Harris
9812cbd71c add server- and client-side store management (#178) 2018-03-17 13:45:59 -04:00
Rich Harris
67a81a3cac Merge pull request #201 from sveltejs/simplify-tests
simplify tests
2018-03-17 13:24:42 -04:00
Rich Harris
67463683cc simplify tests 2018-03-17 13:18:18 -04:00
Rich Harris
b94481b716 Support being mounted on a path — fixes #180 2018-03-17 11:55:02 -04:00
Rich Harris
a95ddee48d add protocol to startup message 2018-03-16 10:51:19 -04:00
Rich Harris
953694f77f update deps 2018-03-16 10:51:04 -04:00
51 changed files with 1891 additions and 1417 deletions

7
.gitignore vendored
View File

@@ -7,8 +7,7 @@ test/app/.sapper
test/app/app/manifest
test/app/export
test/app/build
*.js
*.js.map
*.ts.js
*.ts.js.map
sapper
runtime.js
dist
!rollup.config.js

View File

@@ -1,5 +1,65 @@
# sapper changelog
## 0.13.0
* Expose `dev`, `build`, `export` and `find_page` APIs ([#272](https://github.com/sveltejs/sapper/issues/272))
## 0.12.0
* Each app has a single `<App>` component. See the [migration guide](https://sapper.svelte.technology/guide#0-11-to-0-12) for more information ([#157](https://github.com/sveltejs/sapper/issues/157))
* Process exits with error code 1 if build/export fails ([#208](https://github.com/sveltejs/sapper/issues/208))
## 0.11.1
* Limit routes with leading dots to `.well-known` URIs ([#252](https://github.com/sveltejs/sapper/issues/252))
* Allow server routes to sit in front of pages ([#236](https://github.com/sveltejs/sapper/pull/236))
## 0.11.0
* Create launcher file ([#240](https://github.com/sveltejs/sapper/issues/240))
* Only keep necessary parts of webpack stats ([#251](https://github.com/sveltejs/sapper/pull/251))
* Allow `NODE_ENV` to be overridden when building ([#241](https://github.com/sveltejs/sapper/issues/241))
## 0.10.7
* Allow routes to have a leading `.` ([#243](https://github.com/sveltejs/sapper/pull/243))
* Only encode necessary characters in routes ([#234](https://github.com/sveltejs/sapper/pull/234))
* Preserve existing `process.env` when exporting ([#245](https://github.com/sveltejs/sapper/pull/245))
## 0.10.6
* Fix error reporting in `sapper start`
## 0.10.5
* Fix missing service worker ([#231](https://github.com/sveltejs/sapper/pull/231))
## 0.10.4
* Upgrade chokidar, this time with a fix ([#227](https://github.com/sveltejs/sapper/pull/227))
## 0.10.3
* Downgrade chokidar ([#212](https://github.com/sveltejs/sapper/issues/212))
## 0.10.2
* Attach `store` to error pages
* Fix sorting edge case ([#215](https://github.com/sveltejs/sapper/pull/215))
## 0.10.1
* Fix server-side `fetch` paths ([#207](https://github.com/sveltejs/sapper/pull/207))
## 0.10.0
* Support mounting on a path (this requires `app/template.html` to include `%sapper.base%`) ([#180](https://github.com/sveltejs/sapper/issues/180))
* Support per-request server-side `Store` with client-side hydration ([#178](https://github.com/sveltejs/sapper/issues/178))
* Add `this.fetch` to `preload`, with credentials support ([#178](https://github.com/sveltejs/sapper/issues/178))
* Exclude sourcemaps from preload links and `<script>` block ([#204](https://github.com/sveltejs/sapper/pull/204))
* Register service worker in `<script>` block
## 0.9.6
* Whoops — `tslib` is a runtime dependency

1
api.js Normal file
View File

@@ -0,0 +1 @@
module.exports = require('./dist/api.ts.js');

View File

@@ -1,6 +1,6 @@
{
"name": "sapper",
"version": "0.9.6",
"version": "0.13.0",
"description": "Military-grade apps, engineered by Svelte",
"main": "dist/middleware.ts.js",
"bin": {
@@ -18,51 +18,50 @@
"test": "test"
},
"dependencies": {
"ansi-colors": "^2.0.1",
"cheerio": "^1.0.0-rc.2",
"chokidar": "^1.7.0",
"clorox": "^1.0.3",
"chokidar": "^2.0.3",
"cookie": "^0.3.1",
"devalue": "^1.0.1",
"glob": "^7.1.2",
"html-minifier": "^3.5.10",
"html-minifier": "^3.5.16",
"mkdirp": "^0.5.1",
"node-fetch": "^1.7.3",
"node-fetch": "^2.1.1",
"port-authority": "^1.0.2",
"pretty-bytes": "^4.0.2",
"pretty-bytes": "^5.0.0",
"pretty-ms": "^3.1.0",
"require-relative": "^0.8.7",
"rimraf": "^2.6.2",
"sade": "^1.4.0",
"sade": "^1.4.1",
"sander": "^0.6.0",
"source-map-support": "^0.5.3",
"tslib": "^1.9.0",
"source-map-support": "^0.5.6",
"tslib": "^1.9.1",
"url-parse": "^1.2.0",
"walk-sync": "^0.3.2",
"webpack-format-messages": "^1.0.1"
"webpack-format-messages": "^2.0.1"
},
"devDependencies": {
"@std/esm": "^0.19.7",
"@types/glob": "^5.0.34",
"@types/mkdirp": "^0.5.2",
"@types/rimraf": "^2.0.2",
"compression": "^1.7.1",
"eslint": "^4.13.1",
"eslint-plugin-import": "^2.8.0",
"get-port": "^3.2.0",
"mocha": "^4.0.1",
"nightmare": "^2.10.0",
"npm-run-all": "^4.1.2",
"polka": "^0.3.4",
"rollup": "^0.56.5",
"rollup-plugin-commonjs": "^8.3.0",
"rollup-plugin-json": "^2.3.0",
"eslint-plugin-import": "^2.12.0",
"express": "^4.16.3",
"mocha": "^5.2.0",
"nightmare": "^3.0.0",
"npm-run-all": "^4.1.3",
"polka": "^0.4.0",
"rollup": "^0.59.2",
"rollup-plugin-commonjs": "^9.1.3",
"rollup-plugin-json": "^3.0.0",
"rollup-plugin-string": "^2.0.2",
"rollup-plugin-typescript": "^0.8.1",
"serve-static": "^1.13.2",
"svelte": "^1.49.1",
"svelte-loader": "^2.3.2",
"ts-node": "^4.1.0",
"typescript": "^2.6.2",
"webpack": "^4.1.0"
"svelte": "^2.6.3",
"svelte-loader": "^2.9.0",
"typescript": "^2.8.3",
"walk-sync": "^0.3.2",
"webpack": "^4.8.3"
},
"scripts": {
"cy:open": "cypress open",

View File

@@ -25,7 +25,13 @@ export default [
},
{
input: [`src/cli.ts`, `src/core.ts`, `src/middleware.ts`, `src/webpack.ts`],
input: [
`src/api.ts`,
`src/cli.ts`,
`src/core.ts`,
`src/middleware.ts`,
`src/webpack.ts`
],
output: {
dir: 'dist',
format: 'cjs',

View File

@@ -1,271 +0,0 @@
function detach(node) {
node.parentNode.removeChild(node);
}
function findAnchor(node) {
while (node && node.nodeName.toUpperCase() !== 'A')
node = node.parentNode; // SVG <a> elements have a lowercase name
return node;
}
function which(event) {
return event.which === null ? event.button : event.which;
}
function scroll_state() {
return {
x: window.scrollX,
y: window.scrollY
};
}
var component;
var target;
var routes;
var errors;
var history = typeof window !== 'undefined' ? window.history : {
pushState: function (state, title, href) { },
replaceState: function (state, title, href) { },
scrollRestoration: ''
};
var scroll_history = {};
var uid = 1;
var cid;
if ('scrollRestoration' in history) {
history.scrollRestoration = 'manual';
}
function select_route(url) {
if (url.origin !== window.location.origin)
return null;
var _loop_1 = function (route) {
var match = route.pattern.exec(url.pathname);
if (match) {
if (route.ignore)
return { value: null };
var params = route.params(match);
var query_1 = {};
if (url.search.length > 0) {
url.search.slice(1).split('&').forEach(function (searchParam) {
var _a = /([^=]+)=(.*)/.exec(searchParam), key = _a[1], value = _a[2];
query_1[key] = value || true;
});
}
return { value: { url: url, route: route, data: { params: params, query: query_1 } } };
}
};
for (var _i = 0, routes_1 = routes; _i < routes_1.length; _i++) {
var route = routes_1[_i];
var state_1 = _loop_1(route);
if (typeof state_1 === "object")
return state_1.value;
}
}
var current_token;
function render(Component, data, scroll, token) {
if (current_token !== token)
return;
if (component) {
component.destroy();
}
else {
// first load — remove SSR'd <head> contents
var start = document.querySelector('#sapper-head-start');
var end = document.querySelector('#sapper-head-end');
if (start && end) {
while (start.nextSibling !== end)
detach(start.nextSibling);
detach(start);
detach(end);
}
}
component = new Component({
target: target,
data: data,
hydrate: !component
});
if (scroll) {
window.scrollTo(scroll.x, scroll.y);
}
}
function prepare_route(Component, data) {
var redirect = null;
var error = null;
if (!Component.preload) {
return { Component: Component, data: data, redirect: redirect, error: error };
}
if (!component && window.__SAPPER__ && window.__SAPPER__.preloaded) {
return { Component: Component, data: Object.assign(data, window.__SAPPER__.preloaded), redirect: redirect, error: error };
}
return Promise.resolve(Component.preload.call({
redirect: function (statusCode, location) {
redirect = { statusCode: statusCode, location: location };
},
error: function (statusCode, message) {
error = { statusCode: statusCode, message: message };
}
}, data))["catch"](function (err) {
error = { statusCode: 500, message: err };
}).then(function (preloaded) {
if (error) {
var route = error.statusCode >= 400 && error.statusCode < 500
? errors['4xx']
: errors['5xx'];
return route.load().then(function (_a) {
var Component = _a["default"];
var err = error.message instanceof Error ? error.message : new Error(error.message);
Object.assign(data, { status: error.statusCode, error: err });
return { Component: Component, data: data, redirect: null };
});
}
Object.assign(data, preloaded);
return { Component: Component, data: data, redirect: redirect };
});
}
function navigate(target, id) {
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;
var loaded = prefetching && prefetching.href === target.url.href ?
prefetching.promise :
target.route.load().then(function (mod) { return prepare_route(mod["default"], target.data); });
prefetching = null;
var token = current_token = {};
return loaded.then(function (_a) {
var Component = _a.Component, data = _a.data, redirect = _a.redirect;
if (redirect) {
return goto(redirect.location, { replaceState: true });
}
render(Component, data, scroll_history[id], token);
});
}
function handle_click(event) {
// 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;
var a = findAnchor(event.target);
if (!a)
return;
// check if link is inside an svg
// in this case, both href and target are always inside an object
var svg = typeof a.href === 'object' && a.href.constructor.name === 'SVGAnimatedString';
var 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 <a> has a target
if (svg ? a.target.baseVal : a.target)
return;
var url = new URL(href);
// Don't handle hash changes
if (url.pathname === window.location.pathname && url.search === window.location.search)
return;
var target = select_route(url);
if (target) {
navigate(target, null);
event.preventDefault();
history.pushState({ id: cid }, '', url.href);
}
}
function handle_popstate(event) {
scroll_history[cid] = scroll_state();
if (event.state) {
var url = new URL(window.location.href);
var target_1 = select_route(url);
navigate(target_1, event.state.id);
}
else {
// hashchange
cid = ++uid;
history.replaceState({ id: cid }, '', window.location.href);
}
}
var prefetching = null;
function prefetch(href) {
var selected = select_route(new URL(href));
if (selected) {
prefetching = {
href: href,
promise: selected.route.load().then(function (mod) { return prepare_route(mod["default"], selected.data); })
};
}
}
function handle_touchstart_mouseover(event) {
var a = findAnchor(event.target);
if (!a || a.rel !== 'prefetch')
return;
prefetch(a.href);
}
var inited;
function init(_target, _routes) {
target = _target;
routes = _routes.filter(function (r) { return !r.error; });
errors = {
'4xx': _routes.find(function (r) { return r.error === '4xx'; }),
'5xx': _routes.find(function (r) { return r.error === '5xx'; })
};
if (!inited) {
window.addEventListener('click', handle_click);
window.addEventListener('popstate', handle_popstate);
// prefetch
window.addEventListener('touchstart', handle_touchstart_mouseover);
window.addEventListener('mouseover', handle_touchstart_mouseover);
inited = true;
}
return Promise.resolve().then(function () {
var _a = window.location, hash = _a.hash, href = _a.href;
var 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);
var target = select_route(new URL(window.location.href));
return navigate(target, uid);
});
}
function goto(href, opts) {
if (opts === void 0) { opts = { replaceState: false }; }
var target = select_route(new URL(href, window.location.href));
if (target) {
navigate(target, null);
if (history)
history[opts.replaceState ? 'replaceState' : 'pushState']({ id: cid }, '', href);
}
else {
window.location.href = href;
}
}
function prefetchRoutes(pathnames) {
if (!routes)
throw new Error("You must call init() first");
return routes
.filter(function (route) {
if (!pathnames)
return true;
return pathnames.some(function (pathname) {
return route.error
? route.error === pathname
: route.pattern.test(pathname);
});
})
.reduce(function (promise, route) {
return promise.then(route.load);
}, Promise.resolve());
}
export { component, prefetch, init, goto, prefetchRoutes, prefetchRoutes as preloadRoutes };

6
src/api.ts Normal file
View File

@@ -0,0 +1,6 @@
import { dev } from './api/dev';
import { build } from './api/build';
import { exporter } from './api/export';
import { find_page } from './api/find_page';
export { dev, build, exporter, find_page };

111
src/api/build.ts Normal file
View File

@@ -0,0 +1,111 @@
import * as fs from 'fs';
import * as path from 'path';
import mkdirp from 'mkdirp';
import rimraf from 'rimraf';
import { EventEmitter } from 'events';
import { minify_html } from './utils/minify_html';
import { create_compilers, create_main_manifests, create_routes, create_serviceworker_manifest } from '../core'
import { locations } from '../config';
import * as events from './interfaces';
export function build(opts: {}) {
const emitter = new EventEmitter();
execute(emitter, opts).then(
() => {
emitter.emit('done', <events.DoneEvent>{}); // TODO do we need to pass back any info?
},
error => {
emitter.emit('error', <events.ErrorEvent>{
error
});
}
);
return emitter;
}
async function execute(emitter: EventEmitter, {
dest = 'build',
app = 'app',
webpack = 'webpack',
routes = 'routes'
} = {}) {
mkdirp.sync(dest);
rimraf.sync(path.join(dest, '**/*'));
// minify app/template.html
// TODO compile this to a function? could be quicker than str.replace(...).replace(...).replace(...)
const template = fs.readFileSync(`${app}/template.html`, 'utf-8');
// remove this in a future version
if (template.indexOf('%sapper.base%') === -1) {
const error = new Error(`As of Sapper v0.10, your template.html file must include %sapper.base% in the <head>`);
error.code = `missing-sapper-base`;
throw error;
}
fs.writeFileSync(`${dest}/template.html`, minify_html(template));
const route_objects = create_routes();
// create app/manifest/client.js and app/manifest/server.js
create_main_manifests({ routes: route_objects });
const { client, server, serviceworker } = create_compilers({ webpack });
const client_stats = await compile(client);
emitter.emit('build', <events.BuildEvent>{
type: 'client',
// TODO duration/warnings
webpack_stats: client_stats
});
const client_info = client_stats.toJson();
fs.writeFileSync(path.join(dest, 'client_info.json'), JSON.stringify(client_info));
fs.writeFileSync(path.join(dest, 'client_assets.json'), JSON.stringify(client_info.assetsByChunkName));
const server_stats = await compile(server);
emitter.emit('build', <events.BuildEvent>{
type: 'server',
// TODO duration/warnings
webpack_stats: server_stats
});
let serviceworker_stats;
if (serviceworker) {
create_serviceworker_manifest({
routes: route_objects,
client_files: client_stats.toJson().assets.map((chunk: { name: string }) => `client/${chunk.name}`)
});
serviceworker_stats = await compile(serviceworker);
emitter.emit('build', <events.BuildEvent>{
type: 'serviceworker',
// TODO duration/warnings
webpack_stats: serviceworker_stats
});
}
}
function compile(compiler: any) {
return new Promise((fulfil, reject) => {
compiler.run((err: Error, stats: any) => {
if (err) {
reject(err);
process.exit(1);
}
if (stats.hasErrors()) {
console.error(stats.toString({ colors: true }));
reject(new Error(`Encountered errors while building app`));
}
else {
fulfil(stats);
}
});
});
}

422
src/api/dev.ts Normal file
View File

@@ -0,0 +1,422 @@
import * as path from 'path';
import * as fs from 'fs';
import * as http from 'http';
import * as child_process from 'child_process';
import * as ports from 'port-authority';
import mkdirp from 'mkdirp';
import rimraf from 'rimraf';
import format_messages from 'webpack-format-messages';
import prettyMs from 'pretty-ms';
import { locations } from '../config';
import { EventEmitter } from 'events';
import { create_routes, create_main_manifests, create_compilers, create_serviceworker_manifest } from '../core';
import * as events from './interfaces';
export function dev(opts) {
return new Watcher(opts);
}
class Watcher extends EventEmitter {
dirs: {
app: string;
dest: string;
routes: string;
webpack: string;
}
port: number;
closed: boolean;
dev_server: DevServer;
proc: child_process.ChildProcess;
filewatchers: Array<{ close: () => void }>;
deferreds: {
client: Deferred;
server: Deferred;
};
restarting: boolean;
current_build: {
changed: Set<string>;
rebuilding: Set<string>;
unique_warnings: Set<string>;
unique_errors: Set<string>;
}
constructor({
app = locations.app(),
dest = locations.dest(),
routes = locations.routes(),
webpack = 'webpack',
port = +process.env.PORT
}: {
app: string,
dest: string,
routes: string,
webpack: string,
port: number
}) {
super();
this.dirs = { app, dest, routes, webpack };
this.port = port;
this.closed = false;
this.filewatchers = [];
this.current_build = {
changed: new Set(),
rebuilding: new Set(),
unique_errors: new Set(),
unique_warnings: new Set()
};
// remove this in a future version
const template = fs.readFileSync(path.join(app, 'template.html'), 'utf-8');
if (template.indexOf('%sapper.base%') === -1) {
const error = new Error(`As of Sapper v0.10, your template.html file must include %sapper.base% in the <head>`);
error.code = `missing-sapper-base`;
throw error;
}
process.env.NODE_ENV = 'development';
process.on('exit', () => {
this.close();
});
this.init();
}
async init() {
if (this.port) {
if (!await ports.check(this.port)) {
this.emit('fatal', <events.FatalEvent>{
error: new Error(`Port ${this.port} is unavailable`)
});
return;
}
} else {
this.port = await ports.find(3000);
}
const { dest } = this.dirs;
rimraf.sync(dest);
mkdirp.sync(dest);
const dev_port = await ports.find(10000);
const routes = create_routes();
create_main_manifests({ routes, dev_port });
this.dev_server = new DevServer(dev_port);
this.filewatchers.push(
watch_files(locations.routes(), ['add', 'unlink'], () => {
const routes = create_routes();
create_main_manifests({ routes, dev_port });
}),
watch_files(`${locations.app()}/template.html`, ['change'], () => {
this.dev_server.send({
action: 'reload'
});
})
);
this.deferreds = {
server: new Deferred(),
client: new Deferred()
};
// TODO watch the configs themselves?
const compilers = create_compilers({ webpack: this.dirs.webpack });
this.watch(compilers.server, {
name: 'server',
invalid: filename => {
this.restart(filename, 'server');
this.deferreds.server = new Deferred();
},
result: info => {
fs.writeFileSync(path.join(dest, 'server_info.json'), JSON.stringify(info, null, ' '));
this.deferreds.client.promise.then(() => {
this.dev_server.send({
status: 'completed'
});
const restart = () => {
ports.wait(this.port).then((() => {
this.emit('ready', <events.ReadyEvent>{
port: this.port,
process: this.proc
});
this.deferreds.server.fulfil();
}));
};
if (this.proc) {
this.proc.kill();
this.proc.on('exit', restart);
} else {
restart();
}
this.proc = child_process.fork(`${dest}/server.js`, [], {
cwd: process.cwd(),
env: Object.assign({
PORT: this.port
}, process.env),
stdio: ['ipc']
});
});
}
});
let first = true;
this.watch(compilers.client, {
name: 'client',
invalid: filename => {
this.restart(filename, 'client');
this.deferreds.client = new Deferred();
// TODO we should delete old assets. due to a webpack bug
// i don't even begin to comprehend, this is apparently
// quite difficult
},
result: info => {
fs.writeFileSync(path.join(dest, 'client_info.json'), JSON.stringify(info));
fs.writeFileSync(path.join(dest, 'client_assets.json'), JSON.stringify(info.assetsByChunkName, null, ' '));
this.deferreds.client.fulfil();
const client_files = info.assets.map((chunk: { name: string }) => `client/${chunk.name}`);
create_serviceworker_manifest({
routes: create_routes(),
client_files
});
// we need to wait a beat before watching the service
// worker, because of some webpack nonsense
setTimeout(watch_serviceworker, 100);
}
});
let watch_serviceworker = compilers.serviceworker
? () => {
watch_serviceworker = noop;
this.watch(compilers.serviceworker, {
name: 'service worker',
result: info => {
fs.writeFileSync(path.join(dest, 'serviceworker_info.json'), JSON.stringify(info, null, ' '));
}
});
}
: noop;
}
close() {
if (this.closed) return;
this.closed = true;
this.dev_server.close();
if (this.proc) this.proc.kill();
this.filewatchers.forEach(watcher => {
watcher.close();
});
}
restart(filename: string, type: string) {
if (this.restarting) {
this.current_build.changed.add(filename);
this.current_build.rebuilding.add(type);
} else {
this.restarting = true;
this.current_build = {
changed: new Set(),
rebuilding: new Set(),
unique_warnings: new Set(),
unique_errors: new Set()
};
process.nextTick(() => {
this.emit('invalid', <events.InvalidEvent>{
changed: Array.from(this.current_build.changed),
invalid: {
server: this.current_build.rebuilding.has('server'),
client: this.current_build.rebuilding.has('client'),
serviceworker: this.current_build.rebuilding.has('serviceworker'),
}
});
this.restarting = false;
});
}
}
watch(compiler: any, { name, invalid = noop, result }: {
name: string,
invalid?: (filename: string) => void;
result: (stats: any) => void;
}) {
compiler.hooks.invalid.tap('sapper', (filename: string) => {
invalid(filename);
});
compiler.watch({}, (err: Error, stats: any) => {
if (err) {
this.emit('error', <events.ErrorEvent>{
type: name,
error: err
});
} else {
const messages = format_messages(stats);
const info = stats.toJson();
this.emit('build', {
type: name,
duration: info.time,
errors: messages.errors.map((message: string) => {
const duplicate = this.current_build.unique_errors.has(message);
this.current_build.unique_errors.add(message);
return mungeWebpackError(message, duplicate);
}),
warnings: messages.warnings.map((message: string) => {
const duplicate = this.current_build.unique_warnings.has(message);
this.current_build.unique_warnings.add(message);
return mungeWebpackError(message, duplicate);
}),
});
result(info);
}
});
}
}
const locPattern = /\((\d+):(\d+)\)$/;
function mungeWebpackError(message: string, duplicate: boolean) {
// TODO this is all a bit rube goldberg...
const lines = message.split('\n');
const file = lines.shift()
.replace('', '') // careful — there is a special character at the beginning of this string
.replace('', '')
.replace('./', '');
let line = null;
let column = null;
const match = locPattern.exec(lines[0]);
if (match) {
lines[0] = lines[0].replace(locPattern, '');
line = +match[1];
column = +match[2];
}
return {
file,
line,
column,
message: lines.join('\n'),
originalMessage: message,
duplicate
};
}
class Deferred {
promise: Promise<any>;
fulfil: (value?: any) => void;
reject: (error: Error) => void;
constructor() {
this.promise = new Promise((fulfil, reject) => {
this.fulfil = fulfil;
this.reject = reject;
});
}
}
class DevServer {
clients: Set<http.ServerResponse>;
_: http.Server;
constructor(port: number, interval = 10000) {
this.clients = new Set();
this._ = http.createServer((req, res) => {
if (req.url !== '/__sapper__') return;
req.socket.setKeepAlive(true);
res.writeHead(200, {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'Cache-Control',
'Content-Type': 'text/event-stream;charset=utf-8',
'Cache-Control': 'no-cache, no-transform',
'Connection': 'keep-alive',
// While behind nginx, event stream should not be buffered:
// http://nginx.org/docs/http/ngx_http_proxy_module.html#proxy_buffering
'X-Accel-Buffering': 'no'
});
res.write('\n');
this.clients.add(res);
req.on('close', () => {
this.clients.delete(res);
});
});
this._.listen(port);
setInterval(() => {
this.send(null);
});
}
close() {
this._.close();
}
send(data: any) {
this.clients.forEach(client => {
client.write(`data: ${JSON.stringify(data)}\n\n`);
});
}
}
function noop() {}
function watch_files(pattern: string, events: string[], callback: () => void) {
const chokidar = require('chokidar');
const watcher = chokidar.watch(pattern, {
persistent: true,
ignoreInitial: true,
disableGlobbing: true
});
events.forEach(event => {
watcher.on(event, callback);
});
return {
close: () => watcher.close()
};
}

131
src/api/export.ts Normal file
View File

@@ -0,0 +1,131 @@
import * as child_process from 'child_process';
import * as path from 'path';
import * as sander from 'sander';
import cheerio from 'cheerio';
import URL from 'url-parse';
import fetch from 'node-fetch';
import * as ports from 'port-authority';
import { EventEmitter } from 'events';
import { minify_html } from './utils/minify_html';
import { locations } from '../config';
import * as events from './interfaces';
export function exporter(opts: {}) {
const emitter = new EventEmitter();
execute(emitter, opts).then(
() => {
emitter.emit('done', <events.DoneEvent>{}); // TODO do we need to pass back any info?
},
error => {
emitter.emit('error', <events.ErrorEvent>{
error
});
}
);
return emitter;
}
async function execute(emitter: EventEmitter, {
build = 'build',
dest = 'export',
basepath = ''
} = {}) {
const export_dir = path.join(dest, basepath);
// Prep output directory
sander.rimrafSync(export_dir);
sander.copydirSync('assets').to(export_dir);
sander.copydirSync(build, 'client').to(export_dir, 'client');
if (sander.existsSync(build, 'service-worker.js')) {
sander.copyFileSync(build, 'service-worker.js').to(export_dir, 'service-worker.js');
}
if (sander.existsSync(build, 'service-worker.js.map')) {
sander.copyFileSync(build, 'service-worker.js.map').to(export_dir, 'service-worker.js.map');
}
const port = await ports.find(3000);
const origin = `http://localhost:${port}`;
const proc = child_process.fork(path.resolve(`${build}/server.js`), [], {
cwd: process.cwd(),
env: Object.assign({
PORT: port,
NODE_ENV: 'production',
SAPPER_DEST: build,
SAPPER_EXPORT: 'true'
}, process.env)
});
const seen = new Set();
const saved = new Set();
proc.on('message', message => {
if (!message.__sapper__) return;
let file = new URL(message.url, origin).pathname.slice(1);
let { body } = message;
if (saved.has(file)) return;
saved.add(file);
const is_html = message.type === 'text/html';
if (is_html) {
file = file === '' ? 'index.html' : `${file}/index.html`;
body = minify_html(body);
}
emitter.emit('file', <events.FileEvent>{
file,
size: body.length
});
sander.writeFileSync(export_dir, file, body);
});
async function handle(url: URL) {
const r = await fetch(url.href);
const range = ~~(r.status / 100);
if (range >= 4) {
emitter.emit('failure', <events.FailureEvent>{
status: r.status,
pathname: url.pathname
});
return;
}
if (range === 2) {
if (r.headers.get('Content-Type') === 'text/html') {
const body = await r.text();
const $ = cheerio.load(body);
const urls: URL[] = [];
const base = new URL($('base').attr('href') || '/', url.href);
$('a[href]').each((i: number, $a) => {
const url = new URL($a.attribs.href, base.href);
if (url.origin === origin && !seen.has(url.pathname)) {
seen.add(url.pathname);
urls.push(url);
}
});
for (const url of urls) {
await handle(url);
}
}
}
}
return ports.wait(port)
.then(() => handle(new URL(`/${basepath}`, origin))) // TODO all static routes
.then(() => proc.kill());
}

16
src/api/find_page.ts Normal file
View File

@@ -0,0 +1,16 @@
import * as glob from 'glob';
import { locations } from '../config';
import { create_routes } from '../core';
export function find_page(pathname: string, files: string[] = glob.sync('**/*.*', { cwd: locations.routes(), dot: true, nodir: true })) {
const routes = create_routes({ files });
for (let i = 0; i < routes.length; i += 1) {
const route = routes[i];
if (route.pattern.test(pathname)) {
const page = route.handlers.find(handler => handler.type === 'page');
if (page) return page.file;
}
}
}

43
src/api/interfaces.ts Normal file
View File

@@ -0,0 +1,43 @@
import * as child_process from 'child_process';
export type ReadyEvent = {
port: number;
process: child_process.ChildProcess;
};
export type ErrorEvent = {
type: string;
error: Error;
};
export type FatalEvent = {
error: Error;
};
export type InvalidEvent = {
changed: string[];
invalid: {
client: boolean;
server: boolean;
serviceworker: boolean;
}
};
export type BuildEvent = {
type: string;
errors: Array<{ message: string, duplicate: boolean }>;
warnings: Array<{ message: string, duplicate: boolean }>;
duration: number;
webpack_stats: any;
}
export type FileEvent = {
file: string;
size: number;
}
export type FailureEvent = {
}
export type DoneEvent = {}

View File

@@ -2,7 +2,7 @@ import * as fs from 'fs';
import * as path from 'path';
import * as child_process from 'child_process';
import sade from 'sade';
import * as clorox from 'clorox';
import * as colors from 'ansi-colors';
import prettyMs from 'pretty-ms';
// import upgrade from './cli/upgrade';
import * as ports from 'port-authority';
@@ -21,10 +21,12 @@ prog.command('dev')
prog.command('build [dest]')
.describe('Create a production-ready version of your app')
.action(async (dest = 'build') => {
.option('-p, --port', 'Default of process.env.PORT', '3000')
.example(`build custom-dir -p 4567`)
.action(async (dest = 'build', opts: { port: string }) => {
console.log(`> Building...`);
process.env.NODE_ENV = 'production';
process.env.NODE_ENV = process.env.NODE_ENV || 'production';
process.env.SAPPER_DEST = dest;
const start = Date.now();
@@ -32,9 +34,23 @@ prog.command('build [dest]')
try {
const { build } = await import('./cli/build');
await build();
console.error(`\n> Finished in ${elapsed(start)}. Type ${clorox.bold.cyan(dest === 'build' ? 'npx sapper start' : `npx sapper start ${dest}`)} to run the app.`);
const launcher = path.resolve(dest, 'index.js');
fs.writeFileSync(launcher, `
// generated by sapper build at ${new Date().toISOString()}
process.env.NODE_ENV = process.env.NODE_ENV || 'production';
process.env.SAPPER_DEST = __dirname;
process.env.PORT = process.env.PORT || ${opts.port || 3000};
console.log('Starting server on port ' + process.env.PORT);
require('./server.js');
`.replace(/^\t+/gm, '').trim());
console.error(`\n> Finished in ${elapsed(start)}. Type ${colors.bold.cyan(`node ${dest}`)} to run the app.`);
} catch (err) {
console.error(err ? err.details || err.stack || err.message || err : 'Unknown error');
process.exit(1);
}
});
@@ -49,7 +65,8 @@ prog.command('start [dir]')
prog.command('export [dest]')
.describe('Export your app as static files (if possible)')
.action(async (dest = 'export') => {
.option('--basepath', 'Specify a base path')
.action(async (dest = 'export', opts: { basepath?: string }) => {
console.log(`> Building...`);
process.env.NODE_ENV = 'production';
@@ -63,10 +80,11 @@ prog.command('export [dest]')
console.error(`\n> Built in ${elapsed(start)}. Crawling site...`);
const { exporter } = await import('./cli/export');
await exporter(dest);
console.error(`\n> Finished in ${elapsed(start)}. Type ${clorox.bold.cyan(`npx serve ${dest}`)} to run the app.`);
await exporter(dest, opts);
console.error(`\n> Finished in ${elapsed(start)}. Type ${colors.bold.cyan(`npx serve ${dest}`)} to run the app.`);
} catch (err) {
console.error(err ? err.details || err.stack || err.message || err : 'Unknown error');
process.exit(1);
}
});
@@ -76,4 +94,4 @@ prog.parse(process.argv);
function elapsed(start: number) {
return prettyMs(Date.now() - start);
}
}

View File

@@ -1,69 +1,32 @@
import * as fs from 'fs';
import * as path from 'path';
import * as clorox from 'clorox';
import mkdirp from 'mkdirp';
import rimraf from 'rimraf';
import { minify_html } from './utils/minify_html';
import { create_compilers, create_main_manifests, create_routes, create_serviceworker_manifest } from '../core'
import { build as _build } from '../api/build';
import * as colors from 'ansi-colors';
import { locations } from '../config';
export async function build() {
const output = locations.dest();
mkdirp.sync(output);
rimraf.sync(path.join(output, '**/*'));
const routes = create_routes();
// create app/manifest/client.js and app/manifest/server.js
create_main_manifests({ routes });
const { client, server, serviceworker } = create_compilers();
const client_stats = await compile(client);
console.log(`${clorox.inverse(`\nbuilt client`)}`);
console.log(client_stats.toString({ colors: true }));
fs.writeFileSync(path.join(output, 'client_info.json'), JSON.stringify(client_stats.toJson()));
const server_stats = await compile(server);
console.log(`${clorox.inverse(`\nbuilt server`)}`);
console.log(server_stats.toString({ colors: true }));
let serviceworker_stats;
if (serviceworker) {
create_serviceworker_manifest({
routes,
client_files: client_stats.toJson().assets.map((chunk: { name: string }) => `/client/${chunk.name}`)
});
serviceworker_stats = await compile(serviceworker);
console.log(`${clorox.inverse(`\nbuilt service worker`)}`);
console.log(serviceworker_stats.toString({ colors: true }));
}
// minify app/template.html
// TODO compile this to a function? could be quicker than str.replace(...).replace(...).replace(...)
const template = fs.readFileSync(`${locations.app()}/template.html`, 'utf-8');
fs.writeFileSync(`${output}/template.html`, minify_html(template));
}
function compile(compiler: any) {
export function build() {
return new Promise((fulfil, reject) => {
compiler.run((err: Error, stats: any) => {
if (err) {
reject(err);
process.exit(1);
}
try {
const emitter = _build({
dest: locations.dest(),
app: locations.app(),
routes: locations.routes(),
webpack: 'webpack'
});
if (stats.hasErrors()) {
console.error(stats.toString({ colors: true }));
reject(new Error(`Encountered errors while building app`));
}
emitter.on('build', event => {
console.log(colors.inverse(`\nbuilt ${event.type}`));
console.log(event.webpack_stats.toString({ colors: true }));
});
else {
fulfil(stats);
}
});
emitter.on('error', event => {
reject(event.error);
});
emitter.on('done', event => {
fulfil();
});
} catch (err) {
console.log(`${colors.bold.red(`> ${err.message}`)}`);
process.exit(1);
}
});
}
}

View File

@@ -1,313 +1,77 @@
import * as fs from 'fs';
import * as path from 'path';
import * as net from 'net';
import * as clorox from 'clorox';
import * as colors from 'ansi-colors';
import * as child_process from 'child_process';
import * as http from 'http';
import mkdirp from 'mkdirp';
import rimraf from 'rimraf';
import format_messages from 'webpack-format-messages';
import prettyMs from 'pretty-ms';
import * as ports from 'port-authority';
import { locations } from '../config';
import { create_compilers, create_main_manifests, create_routes, create_serviceworker_manifest } from '../core';
import { dev as _dev } from '../api/dev';
import * as events from '../api/interfaces';
type Deferred = {
promise?: Promise<any>;
fulfil?: (value?: any) => void;
reject?: (err: Error) => void;
}
export function dev(opts: { port: number, open: boolean }) {
try {
const watcher = _dev(opts);
function deferred() {
const d: Deferred = {};
let first = true;
d.promise = new Promise((fulfil, reject) => {
d.fulfil = fulfil;
d.reject = reject;
});
watcher.on('ready', (event: events.ReadyEvent) => {
if (first) {
console.log(`${colors.bold.cyan(`> Listening on http://localhost:${event.port}`)}`);
if (opts.open) child_process.exec(`open http://localhost:${event.port}`);
first = false;
}
return d;
}
// TODO clear screen?
function create_hot_update_server(port: number, interval = 10000) {
const clients = new Set();
event.process.stdout.on('data', data => {
process.stdout.write(data);
});
const server = http.createServer((req, res) => {
if (req.url !== '/__sapper__') return;
req.socket.setKeepAlive(true);
res.writeHead(200, {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'Cache-Control',
'Content-Type': 'text/event-stream;charset=utf-8',
'Cache-Control': 'no-cache, no-transform',
'Connection': 'keep-alive',
// While behind nginx, event stream should not be buffered:
// http://nginx.org/docs/http/ngx_http_proxy_module.html#proxy_buffering
'X-Accel-Buffering': 'no'
event.process.stderr.on('data', data => {
process.stderr.write(data);
});
});
res.write('\n');
clients.add(res);
req.on('close', () => {
clients.delete(res);
});
});
server.listen(port);
function send(data: any) {
clients.forEach(client => {
client.write(`data: ${JSON.stringify(data)}\n\n`);
});
}
setInterval(() => {
send(null)
}, interval);
return { send };
}
export async function dev(opts: { port: number, open: boolean }) {
process.env.NODE_ENV = 'development';
let port = opts.port || +process.env.PORT;
if (port) {
if (!await ports.check(port)) {
console.log(`${clorox.bold.red(`> Port ${port} is unavailable`)}`);
return;
}
} else {
port = await ports.find(3000);
}
const dir = locations.dest();
rimraf.sync(dir);
mkdirp.sync(dir);
const dev_port = await ports.find(10000);
const routes = create_routes();
create_main_manifests({ routes, dev_port });
const hot_update_server = create_hot_update_server(dev_port);
watch_files(`${locations.routes()}/**/*`, ['add', 'unlink'], () => {
const routes = create_routes();
create_main_manifests({ routes, dev_port });
});
watch_files(`${locations.app()}/template.html`, ['change'], () => {
hot_update_server.send({
action: 'reload'
});
});
let proc: child_process.ChildProcess;
process.on('exit', () => {
// sometimes webpack crashes, so we need to kill our children
if (proc) proc.kill();
});
const deferreds = {
server: deferred(),
client: deferred()
};
let restarting = false;
let build = {
unique_warnings: new Set(),
unique_errors: new Set()
};
function restart_build(filename: string) {
if (restarting) return;
restarting = true;
build = {
unique_warnings: new Set(),
unique_errors: new Set()
};
process.nextTick(() => {
restarting = false;
watcher.on('invalid', (event: events.InvalidEvent) => {
const changed = event.changed.map(filename => path.relative(process.cwd(), filename)).join(', ');
console.log(`\n${colors.bold.cyan(changed)} changed. rebuilding...`);
});
console.log(`\n${clorox.bold.cyan(path.relative(process.cwd(), filename))} changed. rebuilding...`);
}
// TODO watch the configs themselves?
const compilers = create_compilers();
function watch(compiler: any, { name, invalid = noop, error = noop, result }: {
name: string,
invalid?: (filename: string) => void;
error?: (error: Error) => void;
result: (stats: any) => void;
}) {
compiler.hooks.invalid.tap('sapper', (filename: string) => {
invalid(filename);
watcher.on('error', (event: events.ErrorEvent) => {
console.log(`${colors.red(`${event.type}`)}`);
console.log(`${colors.red(event.error.message)}`);
});
compiler.watch({}, (err: Error, stats: any) => {
if (err) {
console.log(`${clorox.red(`${name}`)}`);
console.log(`${clorox.red(err.message)}`);
error(err);
} else {
const messages = format_messages(stats);
const info = stats.toJson();
watcher.on('fatal', (event: events.FatalEvent) => {
console.log(`${colors.bold.red(`> ${event.error.message}`)}`);
});
if (messages.errors.length > 0) {
console.log(`${clorox.bold.red(`${name}`)}`);
watcher.on('build', (event: events.BuildEvent) => {
if (event.errors.length) {
console.log(`${colors.bold.red(`${event.type}`)}`);
const filtered = messages.errors.filter((message: string) => {
return !build.unique_errors.has(message);
});
event.errors.filter(e => !e.duplicate).forEach(error => {
console.log(error.message);
});
filtered.forEach((message: string) => {
build.unique_errors.add(message);
console.log(message);
});
const hidden = messages.errors.length - filtered.length;
if (hidden > 0) {
console.log(`${hidden} duplicate ${hidden === 1 ? 'error' : 'errors'} hidden\n`);
}
} else {
if (messages.warnings.length > 0) {
console.log(`${clorox.bold.yellow(`${name}`)}`);
const filtered = messages.warnings.filter((message: string) => {
return !build.unique_warnings.has(message);
});
filtered.forEach((message: string) => {
build.unique_warnings.add(message);
console.log(`${message}\n`);
});
const hidden = messages.warnings.length - filtered.length;
if (hidden > 0) {
console.log(`${hidden} duplicate ${hidden === 1 ? 'warning' : 'warnings'} hidden\n`);
}
} else {
console.log(`${clorox.bold.green(`${name}`)} ${clorox.gray(`(${prettyMs(info.time)})`)}`);
}
result(info);
const hidden = event.errors.filter(e => e.duplicate).length;
if (hidden > 0) {
console.log(`${hidden} duplicate ${hidden === 1 ? 'error' : 'errors'} hidden\n`);
}
} else if (event.warnings.length) {
console.log(`${colors.bold.yellow(`${event.type}`)}`);
event.warnings.filter(e => !e.duplicate).forEach(warning => {
console.log(warning.message);
});
const hidden = event.warnings.filter(e => e.duplicate).length;
if (hidden > 0) {
console.log(`${hidden} duplicate ${hidden === 1 ? 'warning' : 'warnings'} hidden\n`);
}
} else {
console.log(`${colors.bold.green(`${event.type}`)} ${colors.gray(`(${prettyMs(event.duration)})`)}`);
}
});
} catch (err) {
console.log(`${colors.bold.red(`> ${err.message}`)}`);
process.exit(1);
}
watch(compilers.server, {
name: 'server',
invalid: filename => {
restart_build(filename);
// TODO print message
deferreds.server = deferred();
},
result: info => {
// TODO log compile errors/warnings
fs.writeFileSync(path.join(dir, 'server_info.json'), JSON.stringify(info, null, ' '));
deferreds.client.promise.then(() => {
function restart() {
ports.wait(port).then(deferreds.server.fulfil);
}
if (proc) {
proc.kill();
proc.on('exit', restart);
} else {
restart();
}
proc = child_process.fork(`${dir}/server.js`, [], {
cwd: process.cwd(),
env: Object.assign({
PORT: port
}, process.env)
});
});
}
});
let first = true;
watch(compilers.client, {
name: 'client',
invalid: filename => {
restart_build(filename);
deferreds.client = deferred();
// TODO we should delete old assets. due to a webpack bug
// i don't even begin to comprehend, this is apparently
// quite difficult
},
result: info => {
fs.writeFileSync(path.join(dir, 'client_info.json'), JSON.stringify(info, null, ' '));
deferreds.client.fulfil();
const client_files = info.assets.map((chunk: { name: string }) => `/client/${chunk.name}`);
deferreds.server.promise.then(() => {
hot_update_server.send({
status: 'completed'
});
if (first) {
first = false;
console.log(`${clorox.bold.cyan(`> Listening on localhost:${port}`)}`);
if (opts.open) child_process.exec(`open http://localhost:${port}`);
}
});
create_serviceworker_manifest({
routes: create_routes(),
client_files
});
watch_serviceworker();
}
});
let watch_serviceworker = compilers.serviceworker
? function() {
watch_serviceworker = noop;
watch(compilers.serviceworker, {
name: 'service worker',
result: info => {
fs.writeFileSync(path.join(dir, 'serviceworker_info.json'), JSON.stringify(info, null, ' '));
}
});
}
: noop;
}
function noop() {}
function watch_files(pattern: string, events: string[], callback: () => void) {
const chokidar = require('chokidar');
const watcher = chokidar.watch(pattern, {
persistent: true,
ignoreInitial: true
});
events.forEach(event => {
watcher.on(event, callback);
});
}
}

View File

@@ -1,95 +1,35 @@
import * as child_process from 'child_process';
import * as path from 'path';
import * as sander from 'sander';
import * as clorox from 'clorox';
import cheerio from 'cheerio';
import URL from 'url-parse';
import fetch from 'node-fetch';
import * as ports from 'port-authority';
import { exporter as _exporter } from '../api/export';
import * as colors from 'ansi-colors';
import prettyBytes from 'pretty-bytes';
import { minify_html } from './utils/minify_html';
import { locations } from '../config';
export async function exporter(export_dir: string) {
const build_dir = locations.dest();
// Prep output directory
sander.rimrafSync(export_dir);
sander.copydirSync('assets').to(export_dir);
sander.copydirSync(build_dir, 'client').to(export_dir, 'client');
if (sander.existsSync(build_dir, 'service-worker.js')) {
sander.copyFileSync(build_dir, 'service-worker.js').to(export_dir, 'service-worker.js');
}
const port = await ports.find(3000);
const origin = `http://localhost:${port}`;
const proc = child_process.fork(path.resolve(`${build_dir}/server.js`), [], {
cwd: process.cwd(),
env: {
PORT: port,
NODE_ENV: 'production',
SAPPER_DEST: build_dir,
SAPPER_EXPORT: 'true'
}
});
const seen = new Set();
const saved = new Set();
proc.on('message', message => {
if (!message.__sapper__) return;
let file = new URL(message.url, origin).pathname.slice(1);
let { body } = message;
if (saved.has(file)) return;
saved.add(file);
const is_html = message.type === 'text/html';
if (is_html) {
file = file === '' ? 'index.html' : `${file}/index.html`;
body = minify_html(body);
}
console.log(`${clorox.bold.cyan(file)} ${clorox.gray(`(${prettyBytes(body.length)})`)}`);
sander.writeFileSync(`${export_dir}/${file}`, body);
});
function handle(url: URL) {
if (url.origin !== origin) return;
if (seen.has(url.pathname)) return;
seen.add(url.pathname);
return fetch(url.href)
.then(r => {
if (r.headers.get('Content-Type') === 'text/html') {
return r.text().then((body: string) => {
const $ = cheerio.load(body);
const hrefs: string[] = [];
$('a[href]').each((i: number, $a) => {
hrefs.push($a.attribs.href);
});
return hrefs.reduce((promise, href) => {
return promise.then(() => handle(new URL(href, url.href)));
}, Promise.resolve());
});
}
})
.catch((err: Error) => {
console.log(`${clorox.red(`> Error rendering ${url.pathname}: ${err.message}`)}`);
export function exporter(export_dir: string, { basepath = '' }) {
return new Promise((fulfil, reject) => {
try {
const emitter = _exporter({
build: locations.dest(),
dest: export_dir,
basepath
});
}
return ports.wait(port)
.then(() => handle(new URL(origin))) // TODO all static routes
.then(() => proc.kill());
emitter.on('file', event => {
console.log(`${colors.bold.cyan(event.file)} ${colors.gray(`(${prettyBytes(event.size)})`)}`);
});
emitter.on('failure', event => {
console.log(`${colors.red(`> Received ${event.status} response when fetching ${event.pathname}`)}`);
});
emitter.on('error', event => {
reject(event.error);
});
emitter.on('done', event => {
fulfil();
});
} catch (err) {
console.log(`${colors.bold.red(`> ${err.message}`)}`);
process.exit(1);
}
});
}

View File

@@ -1,7 +1,7 @@
import * as fs from 'fs';
import * as path from 'path';
import * as child_process from 'child_process';
import * as clorox from 'clorox';
import * as colors from 'ansi-colors';
import * as ports from 'port-authority';
export async function start(dir: string, opts: { port: number, open: boolean }) {
@@ -11,13 +11,13 @@ export async function start(dir: string, opts: { port: number, open: boolean })
const server = path.resolve(dir, 'server.js');
if (!fs.existsSync(server)) {
console.log(clorox.bold.red(`> ${dir}/server.js does not exist — type ${clorox.bold.cyan(dir === 'build' ? `npx sapper build` : `npx sapper build ${dir}`)} to create it`));
console.log(`${colors.bold.red(`> ${dir}/server.js does not exist — type ${colors.bold.cyan(dir === 'build' ? `npx sapper build` : `npx sapper build ${dir}`)} to create it`)}`);
return;
}
if (port) {
if (!await ports.check(port)) {
console.log(clorox.bold.red(`> Port ${port} is unavailable`));
console.log(`${colors.bold.red(`> Port ${port} is unavailable`)}`);
return;
}
} else {
@@ -34,6 +34,6 @@ export async function start(dir: string, opts: { port: number, open: boolean })
});
await ports.wait(port);
console.log(`${clorox.bold.cyan(`> Listening on localhost:${port}`)}`);
console.log(`${colors.bold.cyan(`> Listening on http://localhost:${port}`)}`);
if (opts.open) child_process.exec(`open http://localhost:${port}`);
}
}

View File

@@ -1,5 +1,5 @@
import * as fs from 'fs';
import * as clorox from 'clorox';
import * as colors from 'ansi-colors';
export default async function upgrade() {
const upgraded = [
@@ -27,10 +27,10 @@ async function upgrade_sapper_main() {
if (/\%sapper\.main\%/.test(template)) {
if (!pattern.test(template)) {
console.log(`${clorox.red(`Could not replace %sapper.main% in ${file}`)}`);
console.log(`${colors.red(`Could not replace %sapper.main% in ${file}`)}`);
} else {
write(file, template.replace(pattern, `%sapper.scripts%`));
console.log(`${clorox.green(`Replaced %sapper.main% in ${file}`)}`);
console.log(`${colors.green(`Replaced %sapper.main% in ${file}`)}`);
replaced = true;
}
}

View File

@@ -1,21 +1,21 @@
import * as path from 'path';
import relative from 'require-relative';
export default function create_compilers() {
const webpack = relative('webpack', process.cwd());
export default function create_compilers({ webpack }: { webpack: string }) {
const wp = relative('webpack', process.cwd());
const serviceworker_config = try_require(path.resolve('webpack/service-worker.config.js'));
const serviceworker_config = try_require(path.resolve(`${webpack}/service-worker.config.js`));
return {
client: webpack(
require(path.resolve('webpack/client.config.js'))
client: wp(
require(path.resolve(`${webpack}/client.config.js`))
),
server: webpack(
require(path.resolve('webpack/server.config.js'))
server: wp(
require(path.resolve(`${webpack}/server.config.js`))
),
serviceworker: serviceworker_config && webpack(serviceworker_config)
serviceworker: serviceworker_config && wp(serviceworker_config)
};
}

View File

@@ -1,7 +1,6 @@
import * as fs from 'fs';
import * as path from 'path';
import * as glob from 'glob';
import create_routes from './create_routes';
import { posixify, write_if_changed } from './utils';
import { dev, locations } from '../config';
import { Route } from '../interfaces';
@@ -45,11 +44,13 @@ function generate_client(routes: Route[], path_to_routes: string, dev_port?: num
export const routes = [
${routes
.map(route => {
if (route.type !== 'page') {
const page = route.handlers.find(({ type }) => type === 'page');
if (!page) {
return `{ pattern: ${route.pattern}, ignore: true }`;
}
const file = posixify(`${path_to_routes}/${route.file}`);
const file = posixify(`${path_to_routes}/${page.file}`);
if (route.id === '_4xx' || route.id === '_5xx') {
return `{ error: '${route.id.slice(1)}', load: () => import(/* webpackChunkName: "${route.id}" */ '${file}') }`;
@@ -85,28 +86,36 @@ function generate_server(routes: Route[], path_to_routes: string) {
let code = `
// This file is generated by Sapper — do not edit it!
${routes
.map(route => {
const file = posixify(`${path_to_routes}/${route.file}`);
return route.type === 'page'
? `import ${route.id} from '${file}';`
: `import * as ${route.id} from '${file}';`;
})
.map(route =>
route.handlers
.map(({ type, file }, index) => {
const module = posixify(`${path_to_routes}/${file}`);
return type === 'page'
? `import ${route.id}${index} from '${module}';`
: `import * as ${route.id}${index} from '${module}';`;
})
.join('\n')
)
.join('\n')}
export const routes = [
${routes
.map(route => {
const file = posixify(`../../${route.file}`);
const handlers = route.handlers
.map(({ type }, index) =>
`{ type: '${type}', module: ${route.id}${index} }`)
.join(', ');
if (route.id === '_4xx' || route.id === '_5xx') {
return `{ error: '${route.id.slice(1)}', module: ${route.id} }`;
return `{ error: '${route.id.slice(1)}', handlers: [${handlers}] }`;
}
const params = route.params.length === 0
? '{}'
: `{ ${route.params.map((part, i) => `${part}: match[${i + 1}]`).join(', ')} }`;
return `{ id: '${route.id}', type: '${route.type}', pattern: ${route.pattern}, params: ${route.params.length > 0 ? `match` : `()`} => (${params}), module: ${route.id} }`;
return `{ id: '${route.id}', pattern: ${route.pattern}, params: ${route.params.length > 0 ? `match` : `()`} => (${params}), handlers: [${handlers}] }`;
})
.join(',\n\t')
}

View File

@@ -3,10 +3,11 @@ import glob from 'glob';
import { locations } from '../config';
import { Route } from '../interfaces';
export default function create_routes({ files } = { files: glob.sync('**/*.*', { cwd: locations.routes(), nodir: true }) }) {
export default function create_routes({ files } = { files: glob.sync('**/*.*', { cwd: locations.routes(), dot: true, nodir: true }) }) {
const routes: Route[] = files
.filter((file: string) => !/(^|\/|\\)_/.test(file))
.map((file: string) => {
if (/(^|\/|\\)_/.test(file)) return;
if (/(^|\/|\\)(_|\.(?!well-known))/.test(file)) return;
if (/]\[/.test(file)) {
throw new Error(`Invalid route ${file} — parameters must be separated`);
@@ -16,6 +17,58 @@ export default function create_routes({ files } = { files: glob.sync('**/*.*', {
const parts = base.split('/'); // glob output is always posix-style
if (parts[parts.length - 1] === 'index') parts.pop();
return {
files: [file],
base,
parts
};
})
.filter(Boolean)
.filter((a, index, array) => {
const found = array.slice(index + 1).find(b => a.base === b.base);
if (found) found.files.push(...a.files);
return !found;
})
.sort((a, b) => {
if (a.parts[0] === '4xx' || a.parts[0] === '5xx') return -1;
if (b.parts[0] === '4xx' || b.parts[0] === '5xx') return 1;
const max = Math.max(a.parts.length, b.parts.length);
for (let i = 0; i < max; i += 1) {
const a_part = a.parts[i];
const b_part = b.parts[i];
if (!a_part) return -1;
if (!b_part) return 1;
const a_sub_parts = get_sub_parts(a_part);
const b_sub_parts = get_sub_parts(b_part);
const max = Math.max(a_sub_parts.length, b_sub_parts.length);
for (let i = 0; i < max; i += 1) {
const a_sub_part = a_sub_parts[i];
const b_sub_part = b_sub_parts[i];
if (!a_sub_part) return 1; // b is more specific, so goes first
if (!b_sub_part) return -1;
if (a_sub_part.dynamic !== b_sub_part.dynamic) {
return a_sub_part.dynamic ? 1 : -1;
}
if (!a_sub_part.dynamic && a_sub_part.content !== b_sub_part.content) {
return (
(b_sub_part.content.length - a_sub_part.content.length) ||
(a_sub_part.content < b_sub_part.content ? -1 : 1)
);
}
}
}
throw new Error(`The ${a.base} and ${b.base} routes clash`);
})
.map(({ files, base, parts }) => {
const id = (
parts.join('_').replace(/[[\]]/g, '$').replace(/^\d/, '_$&').replace(/[^a-zA-Z0-9_$]/g, '_')
) || '_';
@@ -33,7 +86,7 @@ export default function create_routes({ files } = { files: glob.sync('**/*.*', {
let i = parts.length;
let nested = true;
while (i--) {
const part = encodeURIComponent(parts[i].normalize()).replace(/%5B/g, '[').replace(/%5D/g, ']');
const part = encodeURI(parts[i].normalize()).replace(/\?/g, '%3F').replace(/#/g, '%23').replace(/%5B/g, '[').replace(/%5D/g, ']');
const dynamic = ~part.indexOf('[');
if (dynamic) {
@@ -63,51 +116,26 @@ export default function create_routes({ files } = { files: glob.sync('**/*.*', {
return {
id,
type: path.extname(file) === '.html' ? 'page' : 'route',
file,
handlers: files.map(file => ({
type: path.extname(file) === '.html' ? 'page' : 'route',
file
})).sort((a, b) => {
if (a.type === 'page' && b.type === 'route') {
return 1;
}
if (a.type === 'route' && b.type === 'page') {
return -1;
}
return 0;
}),
pattern,
test,
exec,
parts,
params
};
})
.filter(Boolean)
.sort((a: Route, b: Route) => {
if (a.file === '4xx.html' || a.file === '5xx.html') return -1;
if (b.file === '4xx.html' || b.file === '5xx.html') return 1;
const max = Math.max(a.parts.length, b.parts.length);
for (let i = 0; i < max; i += 1) {
const a_part = a.parts[i];
const b_part = b.parts[i];
if (!a_part) return -1;
if (!b_part) return 1;
const a_sub_parts = get_sub_parts(a_part);
const b_sub_parts = get_sub_parts(b_part);
const max = Math.max(a_sub_parts.length, b_sub_parts.length);
for (let i = 0; i < max; i += 1) {
const a_sub_part = a_sub_parts[i];
const b_sub_part = b_sub_parts[i];
if (!a_sub_part) return 1; // b is more specific, so goes first
if (!b_sub_part) return -1;
if (a_sub_part.dynamic !== b_sub_part.dynamic) {
return a_sub_part.dynamic ? 1 : -1;
}
if (!a_sub_part.dynamic && a_sub_part.content !== b_sub_part.content) {
return b_sub_part.content.length - a_sub_part.content.length;
}
}
}
throw new Error(`The ${a.file} and ${b.file} routes clash`);
});
return routes;

View File

@@ -1,7 +1,9 @@
export type Route = {
id: string;
type: 'page' | 'route';
file: string;
handlers: {
type: 'page' | 'route';
file: string;
}[];
pattern: RegExp;
test: (url: string) => boolean;
exec: (url: string) => Record<string, string>;
@@ -12,4 +14,8 @@ export type Route = {
export type Template = {
render: (data: Record<string, string>) => string;
stream: (req, res, data: Record<string, string | Promise<string>>) => void;
};
export type Store = {
get: () => any;
};

View File

@@ -1,11 +1,13 @@
import * as fs from 'fs';
import * as path from 'path';
import { resolve, URL } from 'url';
import { ClientRequest, ServerResponse } from 'http';
import cookie from 'cookie';
import mkdirp from 'mkdirp';
import rimraf from 'rimraf';
import devalue from 'devalue';
import fetch from 'node-fetch';
import { lookup } from './middleware/mime';
import { create_routes, create_compilers } from './core';
import { locations, dev } from './config';
import { Route, Template } from './interfaces';
import sourceMapSupport from 'source-map-support';
@@ -17,36 +19,60 @@ type RouteObject = {
type: 'page' | 'route';
pattern: RegExp;
params: (match: RegExpMatchArray) => Record<string, string>;
module: {
render: (data: any) => {
head: string;
css: { code: string, map: any };
html: string
},
preload: (data: any) => any | Promise<any>
};
module: Component;
error?: string;
}
type Handler = (req: Req, res: ServerResponse, next: () => void) => void;
type Store = {
get: () => any
};
interface Req extends ClientRequest {
url: string;
baseUrl: string;
originalUrl: string;
method: string;
pathname: string;
path: string;
params: Record<string, string>;
headers: Record<string, string>;
}
export default function middleware({ routes }: {
routes: RouteObject[]
interface Component {
render: (data: any, opts: { store: Store }) => {
head: string;
css: { code: string, map: any };
html: string
},
preload: (data: any) => any | Promise<any>
}
export default function middleware({ App, routes, store }: {
App: Component,
routes: RouteObject[],
store: (req: Req) => Store
}) {
if (!App) {
throw new Error(`As of 0.12, you must supply an App component to Sapper — see https://sapper.svelte.technology/guide#0-11-to-0-12 for more information`);
}
const output = locations.dest();
const client_info = JSON.parse(fs.readFileSync(path.join(output, 'client_info.json'), 'utf-8'));
const client_assets = JSON.parse(fs.readFileSync(path.join(output, 'client_assets.json'), 'utf-8'));
const middleware = compose_handlers([
(req: Req, res: ServerResponse, next: () => void) => {
req.pathname = req.url.replace(/\?.*/, '');
if (req.baseUrl === undefined) {
req.baseUrl = req.originalUrl
? req.originalUrl.slice(0, -req.url.length)
: '';
}
if (req.path === undefined) {
req.path = req.url.replace(/\?.*/, '');
}
next();
},
@@ -60,12 +86,17 @@ export default function middleware({ routes }: {
cache_control: 'max-age=600'
}),
fs.existsSync(path.join(output, 'service-worker.js.map')) && serve({
pathname: '/service-worker.js.map',
cache_control: 'max-age=600'
}),
serve({
prefix: '/client/',
cache_control: 'max-age=31536000'
}),
get_route_handler(client_info.assetsByChunkName, routes)
get_route_handler(client_assets, App, routes, store)
].filter(Boolean));
return middleware;
@@ -77,8 +108,8 @@ function serve({ prefix, pathname, cache_control }: {
cache_control: string
}) {
const filter = pathname
? (req: Req) => req.pathname === pathname
: (req: Req) => req.pathname.startsWith(prefix);
? (req: Req) => req.path === pathname
: (req: Req) => req.path.startsWith(prefix);
const output = locations.dest();
@@ -90,10 +121,10 @@ function serve({ prefix, pathname, cache_control }: {
return (req: Req, res: ServerResponse, next: () => void) => {
if (filter(req)) {
const type = lookup(req.pathname);
const type = lookup(req.path);
try {
const data = read(req.pathname.slice(1));
const data = read(req.path.slice(1));
res.setHeader('Content-Type', type);
res.setHeader('Cache-Control', cache_control);
@@ -110,149 +141,220 @@ function serve({ prefix, pathname, cache_control }: {
const resolved = Promise.resolve();
function get_route_handler(chunks: Record<string, string>, routes: RouteObject[]) {
function get_route_handler(chunks: Record<string, string>, App: Component, routes: RouteObject[], store_getter: (req: Req) => Store) {
const template = dev()
? () => fs.readFileSync(`${locations.app()}/template.html`, 'utf-8')
: (str => () => str)(fs.readFileSync(`${locations.dest()}/template.html`, 'utf-8'));
function handle_route(route: RouteObject, req: Req, res: ServerResponse) {
req.params = route.params(route.pattern.exec(req.pathname));
req.params = route.params(route.pattern.exec(req.path));
const mod = route.module;
const handlers = route.handlers[Symbol.iterator]();
if (route.type === 'page') {
res.setHeader('Content-Type', 'text/html');
// preload main.js and current route
// TODO detect other stuff we can preload? images, CSS, fonts?
const link = []
.concat(chunks.main, chunks[route.id])
.map(file => `</client/${file}>;rel="preload";as="script"`)
.join(', ');
res.setHeader('Link', link);
const data = { params: req.params, query: req.query };
let redirect: { statusCode: number, location: string };
let error: { statusCode: number, message: Error | string };
Promise.resolve(
mod.preload ? mod.preload.call({
redirect: (statusCode: number, location: string) => {
redirect = { statusCode, location };
},
error: (statusCode: number, message: Error | string) => {
error = { statusCode, message };
}
}, req) : {}
).catch(err => {
error = { statusCode: 500, message: err };
}).then(preloaded => {
if (redirect) {
res.statusCode = redirect.statusCode;
res.setHeader('Location', redirect.location);
res.end();
function next() {
try {
const { value: handler, done } = handlers.next();
if (done) {
handle_error(req, res, 404, 'Not found');
return;
}
if (error) {
handle_error(req, res, error.statusCode, error.message);
return;
}
const mod = handler.module;
const serialized = try_serialize(preloaded); // TODO bail on non-POJOs
Object.assign(data, preloaded);
if (handler.type === 'page') {
res.setHeader('Content-Type', 'text/html');
const { html, head, css } = mod.render(data);
// preload main.js and current route
// TODO detect other stuff we can preload? images, CSS, fonts?
const link = []
.concat(chunks.main, chunks[route.id])
.filter(file => !file.match(/\.map$/))
.map(file => `<${req.baseUrl}/client/${file}>;rel="preload";as="script"`)
.join(', ');
let scripts = []
.concat(chunks.main) // chunks main might be an array. it might not! thanks, webpack
.map(file => `<script src='/client/${file}'></script>`)
.join('');
res.setHeader('Link', link);
scripts = `<script>__SAPPER__ = { preloaded: ${serialized} };</script>${scripts}`;
const store = store_getter ? store_getter(req) : null;
const props = { params: req.params, query: req.query, path: req.path };
const page = template()
.replace('%sapper.scripts%', scripts)
.replace('%sapper.html%', html)
.replace('%sapper.head%', `<noscript id='sapper-head-start'></noscript>${head}<noscript id='sapper-head-end'></noscript>`)
.replace('%sapper.styles%', (css && css.code ? `<style>${css.code}</style>` : ''));
let redirect: { statusCode: number, location: string };
let error: { statusCode: number, message: Error | string };
res.end(page);
Promise.resolve(
mod.preload ? mod.preload.call({
redirect: (statusCode: number, location: string) => {
redirect = { statusCode, location };
},
error: (statusCode: number, message: Error | string) => {
error = { statusCode, message };
},
fetch: (url: string, opts?: any) => {
const parsed = new URL(url, `http://127.0.0.1:${process.env.PORT}${req.baseUrl ? req.baseUrl + '/' :''}`);
if (process.send) {
process.send({
__sapper__: true,
url: req.url,
method: req.method,
status: 200,
type: 'text/html',
body: page
if (opts) {
opts = Object.assign({}, opts);
const include_cookies = (
opts.credentials === 'include' ||
opts.credentials === 'same-origin' && parsed.origin === `http://127.0.0.1:${process.env.PORT}`
);
if (include_cookies) {
const cookies: Record<string, string> = {};
if (!opts.headers) opts.headers = {};
const str = []
.concat(
cookie.parse(req.headers.cookie || ''),
cookie.parse(opts.headers.cookie || ''),
cookie.parse(res.getHeader('Set-Cookie') || '')
)
.map(cookie => {
return Object.keys(cookie)
.map(name => `${name}=${encodeURIComponent(cookie[name])}`)
.join('; ');
})
.filter(Boolean)
.join(', ');
opts.headers.cookie = str;
}
}
return fetch(parsed.href, opts);
},
store
}, req) : {}
).catch(err => {
error = { statusCode: 500, message: err };
}).then(preloaded => {
if (redirect) {
res.statusCode = redirect.statusCode;
res.setHeader('Location', `${req.baseUrl}/${redirect.location}`);
res.end();
return;
}
if (error) {
handle_error(req, res, error.statusCode, error.message);
return;
}
const serialized = {
preloaded: mod.preload && try_serialize(preloaded),
store: store && try_serialize(store.get())
};
Object.assign(props, preloaded);
const { html, head, css } = App.render({ Page: mod, props }, {
store
});
let scripts = []
.concat(chunks.main) // chunks main might be an array. it might not! thanks, webpack
.filter(file => !file.match(/\.map$/))
.map(file => `<script src='${req.baseUrl}/client/${file}'></script>`)
.join('');
let inline_script = `__SAPPER__={${[
`baseUrl: "${req.baseUrl}"`,
serialized.preloaded && `preloaded: ${serialized.preloaded}`,
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) {
inline_script += `if ('serviceWorker' in navigator) navigator.serviceWorker.register('${req.baseUrl}/service-worker.js');`;
}
const page = template()
.replace('%sapper.base%', `<base href="${req.baseUrl}/">`)
.replace('%sapper.scripts%', `<script>${inline_script}</script>${scripts}`)
.replace('%sapper.html%', html)
.replace('%sapper.head%', `<noscript id='sapper-head-start'></noscript>${head}<noscript id='sapper-head-end'></noscript>`)
.replace('%sapper.styles%', (css && css.code ? `<style>${css.code}</style>` : ''));
res.end(page);
if (process.send) {
process.send({
__sapper__: true,
url: req.url,
method: req.method,
status: 200,
type: 'text/html',
body: page
});
}
});
}
});
}
else {
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 handler = mod[method_export];
if (handler) {
if (process.env.SAPPER_EXPORT) {
const { write, end, setHeader } = res;
const chunks: any[] = [];
const headers: Record<string, string> = {};
else {
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 = mod[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(new Buffer(chunk));
write.apply(res, arguments);
};
// intercept data so that it can be exported
res.write = function(chunk: any) {
chunks.push(new Buffer(chunk));
write.apply(res, arguments);
};
res.setHeader = function(name: string, value: string) {
headers[name.toLowerCase()] = value;
setHeader.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(new Buffer(chunk));
end.apply(res, arguments);
res.end = function(chunk?: any) {
if (chunk) chunks.push(new Buffer(chunk));
end.apply(res, arguments);
process.send({
__sapper__: true,
url: req.url,
method: req.method,
status: res.statusCode,
type: headers['content-type'],
body: Buffer.concat(chunks).toString()
});
};
}
process.send({
__sapper__: true,
url: req.url,
method: req.method,
status: res.statusCode,
type: headers['content-type'],
body: Buffer.concat(chunks).toString()
});
};
}
const handle_bad_result = (err?: Error) => {
if (err) {
console.error(err.stack);
res.statusCode = 500;
res.end(err.message);
const handle_bad_result = (err?: Error) => {
if (err) {
console.error(err.stack);
res.statusCode = 500;
res.end(err.message);
} else {
process.nextTick(next);
}
};
try {
handle_method(req, res, handle_bad_result);
} catch (err) {
handle_bad_result(err);
}
} else {
handle_error(req, res, 404, 'Not found');
// no matching handler for method
process.nextTick(next);
}
};
try {
handler(req, res, handle_bad_result);
} catch (err) {
handle_bad_result(err);
}
} else {
// no matching handler for method — 404
handle_error(req, res, 404, 'Not found');
} catch (error) {
handle_error(req, res, 500, error);
}
}
next();
}
const not_found_route = routes.find((route: RouteObject) => route.error === '4xx');
@@ -270,38 +372,62 @@ function get_route_handler(chunks: Record<string, string>, routes: RouteObject[]
? not_found_route
: error_route;
const title: string = not_found
? 'Not found'
: `Internal server error: ${error.message}`;
function render_page({ head, css, html }) {
const page = template()
.replace('%sapper.base%', `<base href="${req.baseUrl}/">`)
.replace('%sapper.scripts%', `<script>__SAPPER__={baseUrl: "${req.baseUrl}"}</script><script src='${req.baseUrl}/client/${chunks.main}'></script>`)
.replace('%sapper.html%', html)
.replace('%sapper.head%', `<noscript id='sapper-head-start'></noscript>${head}<noscript id='sapper-head-end'></noscript>`)
.replace('%sapper.styles%', (css && css.code ? `<style>${css.code}</style>` : ''));
const rendered = route ? route.module.render({
status: statusCode,
error
}) : { head: '', css: null, html: title };
res.end(page);
}
const { head, css, html } = rendered;
function handle_notfound() {
const title: string = not_found
? 'Not found'
: `Internal server error: ${error.message}`;
const page = template()
.replace('%sapper.scripts%', `<script src='/client/${chunks.main}'></script>`)
.replace('%sapper.html%', html)
.replace('%sapper.head%', `<noscript id='sapper-head-start'></noscript>${head}<noscript id='sapper-head-end'></noscript>`)
.replace('%sapper.styles%', (css && css.code ? `<style>${css.code}</style>` : ''));
render_page({ head: '', css: null, html: title });
}
res.end(page);
if (route) {
const handlers = route.handlers[Symbol.iterator]();
function next() {
const { value: handler, done } = handlers.next();
if (done) {
handle_notfound();
} else if (handler.type === 'page') {
render_page(handler.module.render({
status: statusCode,
error
}, {
store: store_getter && store_getter(req)
}));
} else {
const handle_method = mod[method_export];
if (handle_method) {
handle_method(req, res, next);
} else {
next();
}
}
}
next();
} else {
handle_notfound();
}
}
return function find_route(req: Req, res: ServerResponse) {
const url = req.pathname;
try {
for (const route of routes) {
if (!route.error && route.pattern.test(url)) return handle_route(route, req, res);
}
handle_error(req, res, 404, 'Not found');
} catch (error) {
handle_error(req, res, 500, error);
for (const route of routes) {
if (!route.error && route.pattern.test(req.path)) return handle_route(route, req, res);
}
handle_error(req, res, 404, 'Not found');
};
}
@@ -335,4 +461,4 @@ function try_serialize(data: any) {
} catch (err) {
return null;
}
}
}

View File

@@ -29,7 +29,7 @@ application/java-archive jar
application/java-serialized-object ser
application/java-vm class
application/javascript js
application/json json
application/json json map
application/jsonml+json jsonml
application/lost+xml lostxml
application/mac-binhex40 hqx

View File

@@ -1,8 +1,12 @@
import { detach, findAnchor, scroll_state, which } from './utils';
import { Component, ComponentConstructor, Params, Query, Route, RouteData, ScrollPosition, Target } from './interfaces';
import { Component, ComponentConstructor, Params, Query, Route, RouteData, ScrollPosition, Store, Target } from './interfaces';
const manifest = typeof window !== 'undefined' && window.__SAPPER__;
export let App: ComponentConstructor;
export let component: Component;
let target: Node;
let store: Store;
let routes: Route[];
let errors: { '4xx': Route, '5xx': Route };
@@ -22,9 +26,12 @@ if ('scrollRestoration' in history) {
function select_route(url: URL): Target {
if (url.origin !== window.location.origin) return null;
if (!url.pathname.startsWith(manifest.baseUrl)) return null;
const path = url.pathname.slice(manifest.baseUrl.length);
for (const route of routes) {
const match = route.pattern.exec(url.pathname);
const match = route.pattern.exec(path);
if (match) {
if (route.ignore) return null;
@@ -37,18 +44,24 @@ function select_route(url: URL): Target {
query[key] = value || true;
})
}
return { url, route, data: { params, query } };
return { url, route, props: { params, query, path } };
}
}
}
let current_token: {};
function render(Component: ComponentConstructor, data: any, scroll: ScrollPosition, token: {}) {
function render(Page: ComponentConstructor, props: any, scroll: ScrollPosition, token: {}) {
if (current_token !== token) return;
const data = {
Page,
props,
preloading: false
};
if (component) {
component.destroy();
component.set(data);
} else {
// first load — remove SSR'd <head> contents
const start = document.querySelector('#sapper-head-start');
@@ -59,39 +72,48 @@ function render(Component: ComponentConstructor, data: any, scroll: ScrollPositi
detach(start);
detach(end);
}
}
component = new Component({
target,
data,
hydrate: !component
});
component = new App({
target,
data,
store,
hydrate: true
});
}
if (scroll) {
window.scrollTo(scroll.x, scroll.y);
}
}
function prepare_route(Component: ComponentConstructor, data: RouteData) {
function prepare_route(Page: ComponentConstructor, props: RouteData) {
let redirect: { statusCode: number, location: string } = null;
let error: { statusCode: number, message: Error | string } = null;
if (!Component.preload) {
return { Component, data, redirect, error };
if (!Page.preload) {
return { Page, props, redirect, error };
}
if (!component && window.__SAPPER__ && window.__SAPPER__.preloaded) {
return { Component, data: Object.assign(data, window.__SAPPER__.preloaded), redirect, error };
if (!component && manifest.preloaded) {
return { Page, props: Object.assign(props, manifest.preloaded), redirect, error };
}
return Promise.resolve(Component.preload.call({
if (component) {
component.set({
preloading: true
});
}
return Promise.resolve(Page.preload.call({
store,
fetch: (url: string, opts?: any) => window.fetch(url, opts),
redirect: (statusCode: number, location: string) => {
redirect = { statusCode, location };
},
error: (statusCode: number, message: Error | string) => {
error = { statusCode, message };
}
}, data)).catch(err => {
}, props)).catch(err => {
error = { statusCode: 500, message: err };
}).then(preloaded => {
if (error) {
@@ -99,15 +121,15 @@ function prepare_route(Component: ComponentConstructor, data: RouteData) {
? errors['4xx']
: errors['5xx'];
return route.load().then(({ default: Component }: { default: ComponentConstructor }) => {
return route.load().then(({ default: Page }: { default: ComponentConstructor }) => {
const err = error.message instanceof Error ? error.message : new Error(error.message);
Object.assign(data, { status: error.statusCode, error: err });
return { Component, data, redirect: null };
Object.assign(props, { status: error.statusCode, error: err });
return { Page, props, redirect: null };
});
}
Object.assign(data, preloaded)
return { Component, data, redirect };
Object.assign(props, preloaded)
return { Page, props, redirect };
});
}
@@ -127,18 +149,18 @@ function navigate(target: Target, id: number) {
const loaded = prefetching && prefetching.href === target.url.href ?
prefetching.promise :
target.route.load().then(mod => prepare_route(mod.default, target.data));
target.route.load().then(mod => prepare_route(mod.default, target.props));
prefetching = null;
const token = current_token = {};
return loaded.then(({ Component, data, redirect }) => {
return loaded.then(({ Page, props, redirect }) => {
if (redirect) {
return goto(redirect.location, { replaceState: true });
}
render(Component, data, scroll_history[id], token);
render(Page, props, scroll_history[id], token);
});
}
@@ -199,21 +221,30 @@ function handle_popstate(event: PopStateEvent) {
let prefetching: {
href: string;
promise: Promise<{ Component: ComponentConstructor, data: any }>;
promise: Promise<{ Page: ComponentConstructor, props: any }>;
} = null;
export function prefetch(href: string) {
const selected = select_route(new URL(href));
const selected = select_route(new URL(href, document.baseURI));
if (selected) {
if (selected && (!prefetching || href !== prefetching.href)) {
prefetching = {
href,
promise: selected.route.load().then(mod => prepare_route(mod.default, selected.data))
promise: selected.route.load().then(mod => prepare_route(mod.default, selected.props))
};
}
}
function handle_touchstart_mouseover(event: MouseEvent | TouchEvent) {
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;
@@ -222,21 +253,30 @@ function handle_touchstart_mouseover(event: MouseEvent | TouchEvent) {
let inited: boolean;
export function init(_target: Node, _routes: Route[]) {
target = _target;
routes = _routes.filter(r => !r.error);
export function init(opts: { App: ComponentConstructor, target: Node, routes: Route[], store?: (data: any) => Store }) {
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`);
}
App = opts.App;
target = opts.target;
routes = opts.routes.filter(r => !r.error);
errors = {
'4xx': _routes.find(r => r.error === '4xx'),
'5xx': _routes.find(r => r.error === '5xx')
'4xx': opts.routes.find(r => r.error === '4xx'),
'5xx': opts.routes.find(r => r.error === '5xx')
};
if (opts && opts.store) {
store = opts.store(manifest.store);
}
if (!inited) { // this check makes HMR possible
window.addEventListener('click', handle_click);
window.addEventListener('popstate', handle_popstate);
// prefetch
window.addEventListener('touchstart', handle_touchstart_mouseover);
window.addEventListener('mouseover', handle_touchstart_mouseover);
window.addEventListener('touchstart', trigger_prefetch);
window.addEventListener('mousemove', handle_mousemove);
inited = true;
}
@@ -257,7 +297,8 @@ export function init(_target: Node, _routes: Route[]) {
}
export function goto(href: string, opts = { replaceState: false }) {
const target = select_route(new URL(href, window.location.href));
const target = select_route(new URL(href, document.baseURI));
if (target) {
navigate(target, null);
if (history) history[opts.replaceState ? 'replaceState' : 'pushState']({ id: cid }, '', href);

View File

@@ -1,10 +1,13 @@
import { Store } from '../interfaces';
export { Store };
export type Params = Record<string, string>;
export type Query = Record<string, string | true>;
export type RouteData = { params: Params, query: Query };
export type RouteData = { params: Params, query: Query, path: string };
export interface ComponentConstructor {
new (options: { target: Node, data: any, hydrate: boolean }): Component;
preload: (data: { params: Params, query: Query }) => Promise<any>;
new (options: { target: Node, data: any, store: Store, hydrate: boolean }): Component;
preload: (props: { params: Params, query: Query }) => Promise<any>;
};
export interface Component {
@@ -27,5 +30,5 @@ export type ScrollPosition = {
export type Target = {
url: URL;
route: Route;
data: RouteData;
props: RouteData;
};

View File

@@ -15,7 +15,7 @@ export default {
path: `${locations.dest()}/client`,
filename: '[hash]/[name].js',
chunkFilename: '[hash]/[name].[id].js',
publicPath: '/client/'
publicPath: `client/`
};
}
},

6
test/app/app/App.html Normal file
View File

@@ -0,0 +1,6 @@
{#if preloading}
<progress class='preloading-progress' value=0.5/>
{/if}
<svelte:component this={Page} {...props}/>

View File

@@ -1,8 +1,15 @@
import { init, prefetchRoutes } from '../../../runtime.js';
import { Store } from 'svelte/store.js';
import { routes } from './manifest/client.js';
import App from './App.html';
window.init = () => {
return init(document.querySelector('#sapper'), routes);
return init({
target: document.querySelector('#sapper'),
App,
routes,
store: data => new Store(data)
});
};
window.prefetchRoutes = prefetchRoutes;

View File

@@ -1,9 +1,11 @@
import fs from 'fs';
import polka from 'polka';
import compression from 'compression';
import { resolve } from 'url';
import express from 'express';
import serve from 'serve-static';
import sapper from '../../../dist/middleware.ts.js';
import { Store } from 'svelte/store.js';
import { routes } from './manifest/server.js';
import App from './App.html'
let pending;
let ended;
@@ -28,58 +30,77 @@ process.on('message', message => {
}
});
const app = polka();
const app = express();
app.use((req, res, next) => {
if (pending) pending.add(req.url);
const { write, end } = res;
const chunks = [];
res.write = function(chunk) {
chunks.push(new Buffer(chunk));
write.apply(res, arguments);
};
res.end = function(chunk) {
if (chunk) chunks.push(new Buffer(chunk));
end.apply(res, arguments);
if (pending) pending.delete(req.url);
process.send({
method: req.method,
url: req.url,
status: res.statusCode,
headers: res._headers,
body: Buffer.concat(chunks).toString()
});
if (pending && pending.size === 0 && ended) {
process.send({ type: 'done' });
}
};
next();
});
const { PORT = 3000 } = process.env;
const { PORT = 3000, BASEPATH = '' } = process.env;
const base = `http://localhost:${PORT}${BASEPATH}/`;
// this allows us to do e.g. `fetch('/api/blog')` on the server
const fetch = require('node-fetch');
global.fetch = (url, opts) => {
if (url[0] === '/') url = `http://localhost:${PORT}${url}`;
return fetch(url, opts);
return fetch(resolve(base, url), opts);
};
app.use(compression({ threshold: 0 }));
const middlewares = [
serve('assets'),
app.use(serve('assets'));
// set test cookie
(req, res, next) => {
res.setHeader('Set-Cookie', 'test=woohoo!; Max-Age=3600');
next();
},
app.use(sapper({
routes
}));
// emit messages so we can capture requests
(req, res, next) => {
if (!pending) return next();
app.listen(PORT, () => {
console.log(`listening on port ${PORT}`);
});
pending.add(req.url);
const { write, end } = res;
const chunks = [];
res.write = function(chunk) {
chunks.push(new Buffer(chunk));
write.apply(res, arguments);
};
res.end = function(chunk) {
if (chunk) chunks.push(new Buffer(chunk));
end.apply(res, arguments);
if (pending) pending.delete(req.url);
process.send({
method: req.method,
url: req.url,
status: res.statusCode,
headers: res._headers,
body: Buffer.concat(chunks).toString()
});
if (pending && pending.size === 0 && ended) {
process.send({ type: 'done' });
}
};
next();
},
sapper({
App,
routes,
store: () => {
return new Store({
title: 'Stored title'
});
}
})
];
if (BASEPATH) {
app.use(BASEPATH, ...middlewares);
} else {
app.use(...middlewares);
}
app.listen(PORT);

View File

@@ -5,15 +5,11 @@
<meta name='viewport' content='width=device-width'>
<meta name='theme-color' content='#aa1e1e'>
<link rel='stylesheet' href='/global.css'>
<link rel='manifest' href='/manifest.json'>
<link rel='icon' type='image/png' href='/favicon.png'>
%sapper.base%
<script>
// if ('serviceWorker' in navigator) {
// navigator.serviceWorker.register('/service-worker.js');
// }
</script>
<link rel='stylesheet' href='global.css'>
<link rel='manifest' href='manifest.json'>
<link rel='icon' type='image/png' href='favicon.png'>
<!-- Sapper generates a <style> tag containing critical CSS
for the current page. CSS for the rest of the app is

View File

@@ -1,18 +1,6 @@
<:Head>
<title>{{status}}</title>
</:Head>
<svelte:head>
<title>{status}</title>
</svelte:head>
<Layout page='home'>
<h1>Not found</h1>
<p>{{error.message}}</p>
</Layout>
<script>
import Layout from './_components/Layout.html';
export default {
components: {
Layout
}
};
</script>
<h1>Not found</h1>
<p>{error.message}</p>

View File

@@ -1,18 +1,6 @@
<:Head>
<svelte:head>
<title>Internal server error</title>
</:Head>
</svelte:head>
<Layout page='home'>
<h1>Internal server error</h1>
<p>{{error.message}}</p>
</Layout>
<script>
import Layout from './_components/Layout.html';
export default {
components: {
Layout
}
};
</script>
<h1>Internal server error</h1>
<p>{error.message}</p>

View File

@@ -1,15 +0,0 @@
<Nav page={{page}}/>
<main>
<slot></slot>
</main>
<script>
import Nav from './Nav.html';
export default {
components: {
Nav
}
};
</script>

View File

@@ -1,57 +0,0 @@
<nav>
<ul>
<li><a href='/'>home</a></li>
<li><a href='/about'>about</a></li>
<li><a href='/slow-preload'>slow preload</a></li>
<li><a href='/redirect-from'>redirect</a></li>
<li><a href='/blog/nope'>broken link</a></li>
<li><a href='/blog/throw-an-error'>error link</a></li>
<li><a rel=prefetch class='{{page === "blog" ? "selected" : ""}}' href='/blog'>blog</a></li>
</ul>
</nav>
<style>
nav {
border-bottom: 1px solid rgba(170,30,30,0.1);
font-weight: 300;
padding: 0 1em;
}
ul {
margin: 0;
padding: 0;
}
/* clearfix */
ul::after {
content: '';
display: block;
clear: both;
}
li {
display: block;
float: left;
}
.selected {
position: relative;
display: inline-block;
}
.selected::after {
position: absolute;
content: '';
width: calc(100% - 1em);
height: 2px;
background-color: rgb(170,30,30);
display: block;
bottom: -1px;
}
a {
text-decoration: none;
padding: 1em 0.5em;
display: block;
}
</style>

View File

@@ -1,25 +1,18 @@
<:Head>
<svelte:head>
<title>About</title>
</:Head>
</svelte:head>
<Layout page='about'>
<h1>About this site</h1>
<h1>About this site</h1>
<p>This is the 'about' page. There's not much here.</p>
<p>This is the 'about' page. There's not much here.</p>
<button class='goto' on:click='goto("/blog/what-is-sapper")'>What is Sapper?</button>
<button class='prefetch' on:click='goto("/blog/why-the-name")'>Why the name?</button>
</Layout>
<button class='goto' on:click='goto("blog/what-is-sapper")'>What is Sapper?</button>
<button class='prefetch' on:click='prefetch("blog/why-the-name")'>Why the name?</button>
<script>
import Layout from './_components/Layout.html';
import { goto, prefetch } from '../../../runtime.js';
export default {
components: {
Layout
},
methods: {
goto,
prefetch

View File

@@ -1,59 +1,15 @@
<:Head>
<title>{{post.title}}</title>
</:Head>
<svelte:head>
<title>{post.title}</title>
</svelte:head>
<Layout page='blog'>
<h1>{{post.title}}</h1>
<h1>{post.title}</h1>
<div class='content'>
{{{post.html}}}
</div>
</Layout>
<style>
/*
By default, CSS is locally scoped to the component,
and any unused styles are dead-code-eliminated.
In this page, Svelte can't know which elements are
going to appear inside the {{{post.html}}} block,
so we have to use the :global(...) modifier to target
all elements inside .content
*/
.content :global(h2) {
font-size: 1.4em;
font-weight: 500;
}
.content :global(pre) {
background-color: #f9f9f9;
box-shadow: inset 1px 1px 5px rgba(0,0,0,0.05);
padding: 0.5em;
border-radius: 2px;
overflow-x: auto;
}
.content :global(pre) :global(code) {
background-color: transparent;
padding: 0;
}
.content :global(ul) {
line-height: 1.5;
}
.content :global(li) {
margin: 0 0 0.5em 0;
}
</style>
<div class='content'>
{@html post.html}
</div>
<script>
import Layout from '../_components/Layout.html';
export default {
components: {
Layout
},
preload({ params, query }) {
// the `slug` parameter is available because this file
// is called [slug].html
@@ -63,7 +19,7 @@
return this.error(500, 'something went wrong');
}
return fetch(`/blog/${slug}.json`).then(r => {
return fetch(`blog/${slug}.json`).then(r => {
if (r.status === 200) {
return r.json().then(post => ({ post }));
this.error(r.status, '')

View File

@@ -14,7 +14,7 @@ const posts = [
html: `
<p>First, you have to know what <a href='https://svelte.technology'>Svelte</a> is. Svelte is a UI framework with a bold new idea: rather than providing a library that you write code with (like React or Vue, for example), it's a compiler that turns your components into highly optimized vanilla JavaScript. If you haven't already read the <a href='https://svelte.technology/blog/frameworks-without-the-framework'>introductory blog post</a>, you should!</p>
<p>Sapper is a Next.js-style framework (<a href='/blog/how-is-sapper-different-from-next'>more on that here</a>) built around Svelte. It makes it embarrassingly easy to create extremely high performance web apps. Out of the box, you get:</p>
<p>Sapper is a Next.js-style framework (<a href='blog/how-is-sapper-different-from-next'>more on that here</a>) built around Svelte. It makes it embarrassingly easy to create extremely high performance web apps. Out of the box, you get:</p>
<ul>
<li>Code-splitting, dynamic imports and hot module replacement, powered by webpack</li>
@@ -70,8 +70,8 @@ const posts = [
<ul>
<li>It's powered by <a href='https://svelte.technology'>Svelte</a> instead of React, so it's faster and your apps are smaller</li>
<li>Instead of route masking, we encode route parameters in filenames. For example, the page you're looking at right now is <code>routes/blog/[slug].html</code></li>
<li>As well as pages (Svelte components, which render on server or client), you can create <em>server routes</em> in your <code>routes</code> directory. These are just <code>.js</code> files that export functions corresponding to HTTP methods, and receive Express <code>request</code> and <code>response</code> objects as arguments. This makes it very easy to, for example, add a JSON API such as the one <a href='/blog/how-is-sapper-different-from-next.json'>powering this very page</a></li>
<li>Links are just <code>&lt;a&gt;</code> elements, rather than framework-specific <code>&lt;Link&gt;</code> components. That means, for example, that <a href='/blog/how-can-i-get-involved'>this link right here</a>, despite being inside a blob of HTML, works with the router as you'd expect.</li>
<li>As well as pages (Svelte components, which render on server or client), you can create <em>server routes</em> in your <code>routes</code> directory. These are just <code>.js</code> files that export functions corresponding to HTTP methods, and receive Express <code>request</code> and <code>response</code> objects as arguments. This makes it very easy to, for example, add a JSON API such as the one <a href='blog/how-is-sapper-different-from-next.json'>powering this very page</a></li>
<li>Links are just <code>&lt;a&gt;</code> elements, rather than framework-specific <code>&lt;Link&gt;</code> components. That means, for example, that <a href='blog/how-can-i-get-involved'>this link right here</a>, despite being inside a blob of HTML, works with the router as you'd expect.</li>
</ul>
`
},

View File

@@ -1,38 +1,23 @@
<:Head>
<svelte:head>
<title>Blog</title>
</:Head>
</svelte:head>
<Layout page='blog'>
<h1>Recent posts</h1>
<h1>Recent posts</h1>
<ul>
{{#each posts as post}}
<!-- we're using the non-standard `rel=prefetch` attribute to
tell Sapper to load the data for the page as soon as
the user hovers over the link or taps it, instead of
waiting for the 'click' event -->
<li><a rel='prefetch' href='/blog/{{post.slug}}'>{{post.title}}</a></li>
{{/each}}
</ul>
</Layout>
<style>
ul {
margin: 0 0 1em 0;
line-height: 1.5;
}
</style>
<ul>
{#each posts as post}
<!-- we're using the non-standard `rel=prefetch` attribute to
tell Sapper to load the data for the page as soon as
the user hovers over the link or taps it, instead of
waiting for the 'click' event -->
<li><a rel='prefetch' href='blog/{post.slug}'>{post.title}</a></li>
{/each}
</ul>
<script>
import Layout from '../_components/Layout.html';
export default {
components: {
Layout
},
preload({ params, query }) {
return fetch(`/blog.json`).then(r => r.json()).then(posts => {
return fetch(`blog.json`).then(r => r.json()).then(posts => {
return { posts };
});
}

View File

@@ -0,0 +1,11 @@
<h1>{message}</h1>
<script>
export default {
preload({ query }) {
return this.fetch(`credentials/test.json`, {
credentials: query.creds
}).then(r => r.json());
}
};
</script>

View File

@@ -0,0 +1,28 @@
export function get(req, res) {
const cookies = req.headers.cookie
? req.headers.cookie.split(/,\s+/).reduce((cookies, cookie) => {
const [pair] = cookie.split('; ');
const [name, value] = pair.split('=');
cookies[name] = value;
return cookies;
}, {})
: {};
if (cookies.test) {
res.writeHead(200, {
'Content-Type': 'application/json'
});
res.end(JSON.stringify({
message: cookies.test
}));
} else {
res.writeHead(403, {
'Content-Type': 'application/json'
});
res.end(JSON.stringify({
message: 'unauthorized'
}));
}
}

View File

@@ -4,7 +4,7 @@
export default {
methods: {
del() {
fetch(`/api/delete/42`, { method: 'DELETE' })
fetch(`api/delete/42`, { method: 'DELETE' })
.then(r => r.json())
.then(data => {
window.deleted = data;

View File

@@ -1,60 +1,26 @@
<:Head>
<svelte:head>
<title>Sapper project template</title>
</:Head>
</svelte:head>
<Layout page='home'>
<h1>Great success!</h1>
<h1>Great success!</h1>
<figure>
<img alt='borat' src='/great-success.png'>
<figcaption>HIGH FIVE!</figcaption>
</figure>
<p><strong>Try editing this file (routes/index.html) to test hot module reloading.</strong></p>
</Layout>
<a href='.'>home</a>
<a href='about'>about</a>
<a href='slow-preload'>slow preload</a>
<a href='redirect-from'>redirect</a>
<a href='blog/nope'>broken link</a>
<a href='blog/throw-an-error'>error link</a>
<a href='credentials?creds=include'>credentials</a>
<a rel=prefetch class='{page === "blog" ? "selected" : ""}' href='blog'>blog</a>
<div class='hydrate-test'></div>
<style>
h1, figure, p {
text-align: center;
margin: 0 auto;
}
h1 {
text-align: center;
font-size: 2.8em;
text-transform: uppercase;
font-weight: 700;
margin: 0 0 0.5em 0;
}
figure {
margin: 0 0 1em 0;
}
img {
width: 100%;
max-width: 400px;
margin: 0 0 1em 0;
}
p {
margin: 1em auto;
}
@media (min-width: 480px) {
h1 {
font-size: 4em;
}
}
</style>
<script>
import Layout from './_components/Layout.html';
export default {
components: {
Layout
}
};
</script>
</style>

View File

@@ -1,4 +1,4 @@
<h1>{{foo.bar()}}</h1>
<h1>{foo.bar()}</h1>
<script>
export default {

View File

@@ -1,4 +1,4 @@
<h1>{{set.has('x')}}</h1>
<h1>{set.has('x')}</h1>
<script>
export default {

View File

@@ -1,7 +1,7 @@
<script>
export default {
preload() {
this.redirect(301, '/redirect-to');
this.redirect(301, 'redirect-to');
}
};
</script>

View File

@@ -1,4 +1,4 @@
<p>URL is {{url}}</p>
<p>URL is {url}</p>
<script>
export default {

View File

@@ -0,0 +1 @@
<h1>{$title}</h1>

View File

@@ -1,3 +1,4 @@
const fs = require('fs');
const path = require('path');
const assert = require('assert');
const Nightmare = require('nightmare');
@@ -12,6 +13,10 @@ Nightmare.action('page', {
this.evaluate_now(() => document.querySelector('h1').textContent, done);
},
html(done) {
this.evaluate_now(() => document.documentElement.innerHTML, done);
},
text(done) {
this.evaluate_now(() => document.body.textContent, done);
}
@@ -34,12 +39,23 @@ describe('sapper', function() {
rimraf.sync('export');
rimraf.sync('build');
rimraf.sync('.sapper');
rimraf.sync('start.js');
this.timeout(30000);
this.timeout(process.env.CI ? 30000 : 10000);
// TODO reinstate dev tests
// run('development');
run('production');
// run({
// mode: 'development'
// });
run({
mode: 'production'
});
run({
mode: 'production',
basepath: '/custom-basepath'
});
describe('export', () => {
before(() => {
@@ -111,17 +127,30 @@ describe('sapper', function() {
});
});
function run(env) {
describe(`env=${env}`, function () {
function run({ mode, basepath = '' }) {
describe(`mode=${mode}`, function () {
let proc;
let nightmare;
let capture;
let base;
const nightmare = new Nightmare();
nightmare.on('console', (type, ...args) => {
console[type](...args);
});
nightmare.on('page', (type, ...args) => {
if (type === 'error') {
console.error(args[1]);
} else {
console.warn(type, args);
}
});
before(() => {
const promise = env === 'production'
? exec(`node ${cli} build`).then(() => ports.find(3000))
const promise = mode === 'production'
? exec(`node ${cli} build -l`).then(() => ports.find(3000))
: ports.find(3000).then(port => {
exec(`node ${cli} dev`);
return ports.wait(port).then(() => port);
@@ -129,13 +158,19 @@ function run(env) {
return promise.then(port => {
base = `http://localhost:${port}`;
if (basepath) base += basepath;
const dir = env === 'production' ? 'build' : '.sapper';
const dir = mode === 'production' ? 'build' : '.sapper';
if (mode === 'production') {
assert.ok(fs.existsSync('build/index.js'));
}
proc = require('child_process').fork(`${dir}/server.js`, {
cwd: process.cwd(),
env: {
NODE_ENV: env,
NODE_ENV: mode,
BASEPATH: basepath,
SAPPER_DEST: dir,
PORT: port
}
@@ -183,30 +218,13 @@ function run(env) {
after(() => {
// give a chance to clean up
return new Promise(fulfil => {
proc.on('exit', fulfil);
proc.kill();
});
});
beforeEach(() => {
nightmare = new Nightmare();
nightmare.on('console', (type, ...args) => {
console[type](...args);
});
nightmare.on('page', (type, ...args) => {
if (type === 'error') {
console.error(args[1]);
} else {
console.warn(type, args);
}
});
});
afterEach(() => {
return nightmare.end();
return Promise.all([
nightmare.end(),
new Promise(fulfil => {
proc.on('exit', fulfil);
proc.kill();
})
]);
});
describe('basic functionality', () => {
@@ -235,16 +253,16 @@ function run(env) {
});
it('navigates to a new page without reloading', () => {
return capture(() => nightmare.goto(base).init().prefetchRoutes())
return nightmare.goto(base).init().prefetchRoutes()
.then(() => {
return capture(() => nightmare.click('a[href="/about"]'));
return capture(() => nightmare.click('a[href="about"]'));
})
.then(requests => {
assert.deepEqual(requests.map(r => r.url), []);
return nightmare.path();
})
.then(path => {
assert.equal(path, '/about');
assert.equal(path, `${basepath}/about`);
return nightmare.title();
})
.then(title => {
@@ -257,7 +275,7 @@ function run(env) {
.goto(`${base}/about`)
.init()
.click('.goto')
.wait(() => window.location.pathname === '/blog/what-is-sapper')
.wait(url => window.location.pathname === url, `${basepath}/blog/what-is-sapper`)
.wait(100)
.title()
.then(title => {
@@ -266,9 +284,7 @@ function run(env) {
});
it('prefetches programmatically', () => {
return nightmare
.goto(`${base}/about`)
.init()
return capture(() => nightmare.goto(`${base}/about`).init())
.then(() => {
return capture(() => {
return nightmare
@@ -277,7 +293,7 @@ function run(env) {
});
})
.then(requests => {
assert.ok(!!requests.find(r => r.url === '/blog/why-the-name.json'));
assert.ok(!!requests.find(r => r.url === `/blog/why-the-name.json`));
});
});
@@ -291,28 +307,31 @@ function run(env) {
});
});
it('reuses prefetch promise', () => {
it.skip('reuses prefetch promise', () => {
return nightmare
.goto(`${base}/blog`)
.init()
.then(() => {
return capture(() => {
return nightmare
.mouseover('[href="/blog/what-is-sapper"]')
.evaluate(() => {
const a = document.querySelector('[href="blog/what-is-sapper"]');
a.dispatchEvent(new MouseEvent('mousemove'));
})
.wait(200);
});
})
.then(mouseover_requests => {
assert.ok(mouseover_requests.findIndex(r => r.url === '/blog/what-is-sapper.json') !== -1);
assert.ok(mouseover_requests.findIndex(r => r.url === `/blog/what-is-sapper.json`) !== -1);
return capture(() => {
return nightmare
.click('[href="/blog/what-is-sapper"]')
.click('[href="blog/what-is-sapper"]')
.wait(200);
});
})
.then(click_requests => {
assert.ok(click_requests.findIndex(r => r.url === '/blog/what-is-sapper.json') === -1);
assert.ok(click_requests.findIndex(r => r.url === `/blog/what-is-sapper.json`) === -1);
});
});
@@ -320,13 +339,13 @@ function run(env) {
return nightmare
.goto(base)
.init()
.click('a[href="/slow-preload"]')
.click('a[href="slow-preload"]')
.wait(100)
.click('a[href="/about"]')
.click('a[href="about"]')
.wait(100)
.then(() => nightmare.path())
.then(path => {
assert.equal(path, '/about');
assert.equal(path, `${basepath}/about`);
return nightmare.title();
})
.then(title => {
@@ -335,7 +354,7 @@ function run(env) {
})
.then(() => nightmare.path())
.then(path => {
assert.equal(path, '/about');
assert.equal(path, `${basepath}/about`);
return nightmare.title();
})
.then(title => {
@@ -348,7 +367,7 @@ function run(env) {
.goto(`${base}/show-url`)
.init()
.evaluate(() => document.querySelector('p').innerHTML)
.end().then(html => {
.then(html => {
assert.equal(html, `URL is /show-url`);
});
});
@@ -384,7 +403,7 @@ function run(env) {
return nightmare.goto(`${base}/redirect-from`)
.path()
.then(path => {
assert.equal(path, '/redirect-to');
assert.equal(path, `${basepath}/redirect-to`);
})
.then(() => nightmare.page.title())
.then(title => {
@@ -394,12 +413,12 @@ function run(env) {
it('redirects in client', () => {
return nightmare.goto(base)
.wait('[href="/redirect-from"]')
.click('[href="/redirect-from"]')
.wait('[href="redirect-from"]')
.click('[href="redirect-from"]')
.wait(200)
.path()
.then(path => {
assert.equal(path, '/redirect-to');
assert.equal(path, `${basepath}/redirect-to`);
})
.then(() => nightmare.page.title())
.then(title => {
@@ -411,7 +430,7 @@ function run(env) {
return nightmare.goto(`${base}/blog/nope`)
.path()
.then(path => {
assert.equal(path, '/blog/nope');
assert.equal(path, `${basepath}/blog/nope`);
})
.then(() => nightmare.page.title())
.then(title => {
@@ -422,11 +441,11 @@ function run(env) {
it('handles 4xx error in client', () => {
return nightmare.goto(base)
.init()
.click('[href="/blog/nope"]')
.click('[href="blog/nope"]')
.wait(200)
.path()
.then(path => {
assert.equal(path, '/blog/nope');
assert.equal(path, `${basepath}/blog/nope`);
})
.then(() => nightmare.page.title())
.then(title => {
@@ -438,7 +457,7 @@ function run(env) {
return nightmare.goto(`${base}/blog/throw-an-error`)
.path()
.then(path => {
assert.equal(path, '/blog/throw-an-error');
assert.equal(path, `${basepath}/blog/throw-an-error`);
})
.then(() => nightmare.page.title())
.then(title => {
@@ -449,11 +468,11 @@ function run(env) {
it('handles non-4xx error in client', () => {
return nightmare.goto(base)
.init()
.click('[href="/blog/throw-an-error"]')
.click('[href="blog/throw-an-error"]')
.wait(200)
.path()
.then(path => {
assert.equal(path, '/blog/throw-an-error');
assert.equal(path, `${basepath}/blog/throw-an-error`);
})
.then(() => nightmare.page.title())
.then(title => {
@@ -464,7 +483,7 @@ function run(env) {
it('does not attempt client-side navigation to server routes', () => {
return nightmare.goto(`${base}/blog/how-is-sapper-different-from-next`)
.init()
.click(`[href="/blog/how-is-sapper-different-from-next.json"]`)
.click(`[href="blog/how-is-sapper-different-from-next.json"]`)
.wait(200)
.page.text()
.then(text => {
@@ -511,11 +530,78 @@ function run(env) {
assert.equal(title, '42');
});
});
it('renders store props', () => {
return nightmare.goto(`${base}/store`)
.page.title()
.then(title => {
assert.equal(title, 'Stored title');
return nightmare.init().page.title();
})
.then(title => {
assert.equal(title, 'Stored title');
});
});
it('sends cookies when using this.fetch with credentials: "include"', () => {
return nightmare.goto(`${base}/credentials?creds=include`)
.page.title()
.then(title => {
assert.equal(title, 'woohoo!');
});
});
it('does not send cookies when using this.fetch without credentials', () => {
return nightmare.goto(`${base}/credentials`)
.page.title()
.then(title => {
assert.equal(title, 'unauthorized');
});
});
it('delegates to fetch on the client', () => {
return nightmare.goto(base).init()
.click('[href="credentials?creds=include"]')
.wait(100)
.page.title()
.then(title => {
assert.equal(title, 'woohoo!');
});
});
it('includes service worker', () => {
return nightmare.goto(base).page.html().then(html => {
assert.ok(html.indexOf('service-worker.js') !== -1);
});
});
it('sets preloading true when appropriate', () => {
return nightmare
.goto(base)
.init()
.click('a[href="slow-preload"]')
.wait(100)
.evaluate(() => {
const progress = document.querySelector('progress');
return !!progress;
})
.then(hasProgressIndicator => {
assert.ok(hasProgressIndicator);
})
.then(() => nightmare.evaluate(() => window.fulfil()))
.then(() => nightmare.evaluate(() => {
const progress = document.querySelector('progress');
return !!progress;
}))
.then(hasProgressIndicator => {
assert.ok(!hasProgressIndicator);
});
});
});
describe('headers', () => {
it('sets Content-Type and Link...preload headers', () => {
return capture(() => nightmare.goto(base).end()).then(requests => {
return capture(() => nightmare.goto(base)).then(requests => {
const { headers } = requests[0];
assert.equal(
@@ -523,8 +609,16 @@ function run(env) {
'text/html'
);
const str = ['main', '_\\.\\d+']
.map(file => {
return `<${basepath}/client/[^/]+/${file}\\.js>;rel="preload";as="script"`;
})
.join(', ');
const regex = new RegExp(str);
assert.ok(
/<\/client\/[^/]+\/main\.js>;rel="preload";as="script", <\/client\/[^/]+\/_\.\d+\.js>;rel="preload";as="script"/.test(headers['link']),
regex.test(headers['link']),
headers['link']
);
});
@@ -535,7 +629,7 @@ function run(env) {
function exec(cmd) {
return new Promise((fulfil, reject) => {
const parts = cmd.split(' ');
const parts = cmd.trim().split(' ');
const proc = require('child_process').spawn(parts.shift(), parts);
proc.stdout.on('data', data => {

View File

@@ -2,18 +2,59 @@ const assert = require('assert');
const { create_routes } = require('../../dist/core.ts.js');
describe('create_routes', () => {
it('sorts handlers correctly', () => {
const routes = create_routes({
files: ['foo.html', 'foo.js']
});
assert.deepEqual(
routes.map(r => r.handlers),
[
[
{
type: 'route',
file: 'foo.js'
},
{
type: 'page',
file: 'foo.html'
}
]
]
)
});
it('encodes characters not allowed in path', () => {
const routes = create_routes({
files: [
'"',
'#',
'?'
]
});
assert.deepEqual(
routes.map(r => r.pattern),
[
/^\/%22\/?$/,
/^\/%23\/?$/,
/^\/%3F\/?$/
]
);
});
it('sorts routes correctly', () => {
const routes = create_routes({
files: ['index.html', 'about.html', 'post/f[xx].html', '[wildcard].html', 'post/foo.html', 'post/[id].html', 'post/bar.html', 'post/[id].json.js']
});
assert.deepEqual(
routes.map(r => r.file),
routes.map(r => r.handlers[0].file),
[
'index.html',
'about.html',
'post/foo.html',
'post/bar.html',
'post/foo.html',
'post/f[xx].html',
'post/[id].json.js',
'post/[id].html',
@@ -23,7 +64,7 @@ describe('create_routes', () => {
});
it('prefers index page to nested route', () => {
const routes = create_routes({
let routes = create_routes({
files: [
'api/examples/[slug].js',
'api/examples/index.js',
@@ -40,7 +81,7 @@ describe('create_routes', () => {
});
assert.deepEqual(
routes.map(r => r.file),
routes.map(r => r.handlers[0].file),
[
'4xx.html',
'5xx.html',
@@ -55,6 +96,45 @@ describe('create_routes', () => {
'api/gists/[id].js',
]
);
routes = create_routes({
files: [
'4xx.html',
'5xx.html',
'api/blog/[slug].js',
'api/blog/index.js',
'api/guide/contents.js',
'api/guide/index.js',
'blog/[slug].html',
'blog/index.html',
'blog/rss.xml.js',
'gist/[id].js',
'gist/create.js',
'guide/index.html',
'index.html',
'repl/index.html'
]
});
assert.deepEqual(
routes.map(r => r.handlers[0].file),
[
'4xx.html',
'5xx.html',
'index.html',
'guide/index.html',
'blog/index.html',
'blog/rss.xml.js',
'blog/[slug].html',
'gist/create.js',
'gist/[id].js',
'repl/index.html',
'api/guide/index.js',
'api/guide/contents.js',
'api/blog/index.js',
'api/blog/[slug].js',
]
);
});
it('generates params', () => {
@@ -67,7 +147,7 @@ describe('create_routes', () => {
for (let i = 0; i < routes.length; i += 1) {
const route = routes[i];
if (params = route.exec('/post/123')) {
file = route.file;
file = route.handlers[0].file;
break;
}
}
@@ -84,7 +164,7 @@ describe('create_routes', () => {
});
assert.deepEqual(
routes.map(r => r.file),
routes.map(r => r.handlers[0].file),
[
'index.html',
'e/f/g/h.html'
@@ -92,6 +172,17 @@ describe('create_routes', () => {
);
});
it('ignores files and directories with leading dots except .well-known', () => {
const routes = create_routes({
files: ['.well-known', '.unknown']
});
assert.deepEqual(
routes.map(r => r.handlers[0].file),
['.well-known']
);
});
it('matches /foo/:bar before /:baz/qux', () => {
const a = create_routes({
files: ['foo/[bar].html', '[baz]/qux.html']
@@ -101,12 +192,12 @@ describe('create_routes', () => {
});
assert.deepEqual(
a.map(r => r.file),
a.map(r => r.handlers[0].file),
['foo/[bar].html', '[baz]/qux.html']
);
assert.deepEqual(
b.map(r => r.file),
b.map(r => r.handlers[0].file),
['foo/[bar].html', '[baz]/qux.html']
);
});
@@ -116,13 +207,7 @@ describe('create_routes', () => {
create_routes({
files: ['[foo].html', '[bar]/index.html']
});
}, /The \[foo\].html and \[bar\]\/index.html routes clash/);
assert.throws(() => {
create_routes({
files: ['foo.html', 'foo.js']
});
}, /The foo.html and foo.js routes clash/);
}, /The \[foo\] and \[bar\]\/index routes clash/);
});
it('matches nested routes', () => {
@@ -145,7 +230,7 @@ describe('create_routes', () => {
});
assert.deepEqual(
routes.map(r => r.file),
routes.map(r => r.handlers[0].file),
['settings.html', 'settings/[submenu].html']
);
});