Compare commits

...

114 Commits

Author SHA1 Message Date
Rich Harris
5104abf329 -> v0.6.0 2018-02-03 13:09:31 -05:00
Rich Harris
6554fc8616 Merge branch 'restructure' 2018-02-03 13:07:36 -05:00
Rich Harris
cd01b7e6db Merge branch 'master' of github.com:sveltejs/sapper 2018-02-03 12:24:59 -05:00
Rich Harris
bfa3da6d3d Merge branch 'issue_109-patch-1' of https://github.com/samhatchett/sapper into samhatchett-issue_109-patch-1 2018-02-03 12:07:53 -05:00
Rich Harris
6ee092f8d4 Merge pull request #107 from lukastaegert/fix-search-param-handling
Fix query param handling
2018-02-03 12:05:57 -05:00
Rich Harris
ac70004f77 remove runtime.js.map (again?) 2018-02-03 12:04:39 -05:00
Rich Harris
3449f1eb37 add test for #105 2018-02-03 12:04:14 -05:00
Rich Harris
16cb1fccc6 Merge pull request #105 from thgh/patch-2
Fix route not found when url is /?
2018-02-03 12:03:36 -05:00
Rich Harris
b20c1c029f Merge branch 'thgh-patch-1' 2018-02-03 11:50:37 -05:00
Rich Harris
7abfb1aab1 Merge branch 'restructure' 2018-02-03 11:50:12 -05:00
Rich Harris
205c2defe4 Merge branch 'patch-1' of https://github.com/thgh/sapper into thgh-patch-1 2018-02-03 11:45:54 -05:00
Rich Harris
09a6eec83e Merge pull request #101 from sveltejs/restructure
Restructure codebase
2018-02-03 11:44:57 -05:00
Rich Harris
2cabf61ea7 ignore runtime.js.map 2018-02-03 11:41:33 -05:00
Sam Hatchett
71cfdd2907 fixes api route default content-type
fixes issue where api routes were being defaulted to text/html. Page routes should be text/html, but api routes could be json, zip files, etc., and express does some type-guessing to assist in case the user code does not specify the content-type.
2018-01-31 11:08:25 -05:00
Lukas Taegert
297f4276de Fix query param handling by
* not using a for-of loop on an iterator that is transpiled wrongly
* not using URL.searchParams which is only supported by rather new
  browsers
2018-01-26 23:29:34 +01:00
Thomas Ghysels
422e31e183 Fix route not found when url is /? 2018-01-26 02:48:56 +01:00
Thomas Ghysels
b53ee061c0 Fix DOMException when location.hash is invalid selector 2018-01-26 02:24:49 +01:00
Rich Harris
8bad37205d convert to typescript 2018-01-21 16:28:02 -05:00
Rich Harris
fd0dd4fe58 and again... 2018-01-21 16:14:16 -05:00
Rich Harris
4940644ae3 more tidying up 2018-01-21 16:11:46 -05:00
Rich Harris
fb8d952eeb more tidying up 2018-01-21 16:02:42 -05:00
Rich Harris
fc631c4866 make route handling more explicit 2018-01-21 15:41:13 -05:00
Rich Harris
03ce2ea998 tidy up a bit 2018-01-21 15:04:22 -05:00
Rich Harris
dd8deb2d8a wip 3 2018-01-21 14:41:11 -05:00
Rich Harris
7d721abb2a wip 2 2018-01-21 11:15:32 -05:00
Rich Harris
39b1fa89ce wip 2018-01-21 11:03:23 -05:00
Rich Harris
7a3506420f Merge pull request #100 from sveltejs/gh-99
return a promise from init
2018-01-20 20:16:46 -05:00
Rich Harris
72ae4a1c64 ugh still need to wait for requests to complete 2018-01-20 19:55:21 -05:00
Rich Harris
a09c33d6a5 return a promise from init - fixes #99 2018-01-20 19:49:41 -05:00
Rich Harris
4590aa313c Merge pull request #96 from mrkishi/master
Identify js and html route clashes
2018-01-20 18:36:50 -05:00
Rich Harris
d11bd954e0 seems tests weren't actually running in appveyor? (#98)
well this was harder than it should have been
2018-01-20 18:34:30 -05:00
Rich Harris
c15959710b huh. not sure where this broke 2018-01-20 13:39:49 -05:00
Rich Harris
bb8ff74f68 remove noise from tests 2018-01-20 13:21:53 -05:00
Rich Harris
2cbbe91490 Merge branch 'master' of github.com:sveltejs/sapper 2018-01-20 12:53:16 -05:00
Rich Harris
faeddd8add work around Svelte hydration failures 2018-01-20 12:53:06 -05:00
Rich Harris
d77722c042 Merge branch 'chexxor-patch-1' 2018-01-20 12:40:35 -05:00
Rich Harris
61daba7a64 add hydration test, add window.init function to make it possible 2018-01-20 12:40:31 -05:00
Rich Harris
54ff8cc2e6 Merge branch 'patch-1' of https://github.com/chexxor/sapper into chexxor-patch-1 2018-01-20 12:37:48 -05:00
Rich Harris
e6fcafe09b Merge pull request #95 from lukeed/fix/express-reliance
Remove Express Helpers
2018-01-20 12:10:43 -05:00
Rich Harris
a305d3cea1 Merge pull request #97 from sveltejs/gh-90
return service worker from generate_asset_cache
2018-01-20 12:09:11 -05:00
Rich Harris
75e70207b8 return service worker from generate_asset_cache - fixes #90 2018-01-20 12:02:32 -05:00
mrkishi
8a8526d9ed Identify js and html route clashes 2018-01-20 01:42:13 -02:00
Luke Edwards
9a76229bb6 test: shim setHeader method 2018-01-19 13:56:01 -08:00
Luke Edwards
f4e46e6e6c replace Express shorthands w/ native counterparts 2018-01-19 13:54:55 -08:00
Alex Berg
90cd347112 Fix typo 2018-01-19 01:25:20 -06:00
Alex Berg
5adfdd6fe0 Hydrate on first load, not destroy 2018-01-19 01:22:08 -06:00
Rich Harris
a6dc61a182 -> v0.5.1 2018-01-16 08:47:50 -05:00
Rich Harris
96666d05ec only write to filesystem in dev mode 2018-01-16 08:47:44 -05:00
Rich Harris
6390ba692b -> v0.5.0 2018-01-15 10:28:48 -05:00
Rich Harris
0e131cc81e Merge pull request #88 from sveltejs/gh-9
Exporting
2018-01-15 10:24:35 -05:00
Rich Harris
bd3d5713cb Merge pull request #64 from lukeed/lazy-chokidar
Lazily Require Chokidar
2018-01-15 10:04:37 -05:00
Rich Harris
9ec23c47ad update test 2018-01-15 10:04:24 -05:00
Rich Harris
b7bb69925e rename extract to export, for familiarity to next.js devs 2018-01-15 10:03:27 -05:00
Rich Harris
25124f6ee7 Merge branch 'master' into lazy-chokidar 2018-01-15 09:58:39 -05:00
Rich Harris
73d491cd19 Merge branch 'master' into lazy-chokidar 2018-01-15 09:58:03 -05:00
Rich Harris
e25fceb4b8 node 6 is the new IE 2018-01-15 09:36:12 -05:00
Rich Harris
3807147c57 add missing dependency 2018-01-14 22:45:59 -05:00
Rich Harris
a523ba58ff save pages as index.html, ignore hashes 2018-01-14 22:45:51 -05:00
Rich Harris
fe03fd3a52 update lockfile 2018-01-14 18:21:37 -05:00
Rich Harris
89c430a0cb slightly different approach to extracting 2018-01-14 18:19:51 -05:00
Rich Harris
8ef312849c always write service-worker.js and shell index.html file 2018-01-14 18:16:10 -05:00
Rich Harris
4200446684 ignore trailing slash in pathnames 2018-01-14 18:15:40 -05:00
Rich Harris
681ed005b8 remove unused deps 2018-01-14 18:15:12 -05:00
Rich Harris
d457af8d51 update test 2018-01-14 18:14:07 -05:00
Rich Harris
0c158b9e1f Merge branch 'master' of https://github.com/freedmand/sapper into freedmand-master 2018-01-14 13:48:07 -05:00
Rich Harris
50011e2077 always print stdout/stderr, to avoid wild goose chase debugging 2018-01-14 13:47:23 -05:00
Rich Harris
f27b7973e3 Build before extracting 2018-01-14 13:46:25 -05:00
Rich Harris
2af2ab3cb9 Ensure output dir exists, return Promise 2018-01-14 13:45:47 -05:00
Rich Harris
6a4dc1901c Enforce prod mode, return a Promise so it can be used programmatically 2018-01-14 13:45:01 -05:00
Rich Harris
fbbc0e9e19 Merge branch 'master' of https://github.com/freedmand/sapper into freedmand-master 2018-01-14 12:29:15 -05:00
Rich Harris
1213c3da46 fix lockfile mess 2018-01-14 12:27:35 -05:00
Rich Harris
4cc2104088 add back test that got lost in a merge conflict somewhere 2018-01-14 12:23:07 -05:00
Rich Harris
d6dda371ca typo 2018-01-14 12:22:41 -05:00
Rich Harris
304c06085e Merge branch 'master' into master 2018-01-14 12:19:47 -05:00
Rich Harris
33b6450e34 Merge branch 'master' into master 2018-01-14 12:16:09 -05:00
Rich Harris
8faa98af6a -> v0.4.0 2018-01-14 12:07:06 -05:00
Rich Harris
14df138528 Merge pull request #87 from sveltejs/gh-3
Serialize preloaded data for initial page and serve to client
2018-01-14 11:50:18 -05:00
Rich Harris
44285cdb2f serialized preloaded data and send to client as initial payload - fixes #3 2018-01-14 11:22:00 -05:00
Rich Harris
bd656cfd5b Merge branch 'fix-tests-without-async' into gh-3 2018-01-14 10:45:55 -05:00
Rich Harris
c4b4bd587d use %sapper.scripts% 2018-01-14 10:45:21 -05:00
Rich Harris
2abfdb03d5 wait for window.READY so that this tests we dont double-preload 2018-01-14 10:44:38 -05:00
Rich Harris
a80ac3a8b8 change tests back to non-async versions 2018-01-14 00:43:57 -05:00
Rich Harris
887cb09386 merge 2018-01-14 00:21:55 -05:00
Rich Harris
cfeeafded4 Merge pull request #84 from sveltejs/test-in-node-6
test in node 6
2018-01-14 00:18:23 -05:00
Rich Harris
2cae674033 Merge branch 'master' into test-in-node-6 2018-01-14 00:14:40 -05:00
Rich Harris
7c0f32662d remove async from new test 2018-01-14 00:10:20 -05:00
Rich Harris
b4fb1c3268 Merge branch 'master' of github.com:sveltejs/sapper 2018-01-14 00:02:32 -05:00
Rich Harris
ecd0f673a9 test in node 6 2018-01-14 00:01:51 -05:00
Rich Harris
40d16852f7 prevent unnecessary promise chains, and hoist handler function 2018-01-14 00:01:10 -05:00
Rich Harris
133be03791 Merge pull request #72 from nolanlawson/fix-style-loader
Explicitly load style-loader/css-loader deps
2018-01-13 23:33:38 -05:00
Rich Harris
727a76ebb5 fall through to 404 if no handler exists for method 2018-01-13 23:29:41 -05:00
Rich Harris
e3c047831a add test for #77 2018-01-13 23:29:16 -05:00
Emil Tholin
81b5e0d764 Handle DELETE requests with del export 2018-01-10 19:24:22 +01:00
Emil Tholin
98e904dcfc Remove async/await from test.js 2018-01-07 12:22:40 +01:00
Nolan Lawson
ca51372150 Explicitly load style-loader/css-loader deps
This appears to fix sveltejs/sapper-template#27
2018-01-06 17:21:40 -08:00
Luke Edwards
7cef1f1120 only return from show-url if in server context 2018-01-06 16:11:39 -08:00
Luke Edwards
1b73baabce [temp - debug]: log out the preload value 2018-01-06 15:40:40 -08:00
Luke Edwards
5aa01b922b add page.title() Nightmare action 2018-01-06 15:16:18 -08:00
Luke Edwards
f0bc68be88 use built-in for document.title 2018-01-06 15:07:43 -08:00
Luke Edwards
be7c53becc quick attempt at fixing test for travis 2018-01-06 14:53:14 -08:00
freedmand
9ea4137b87 Add option to extract server-side routes at directories other than /api.
Also clarifies some texts and documentation.
2018-01-05 19:21:25 -08:00
freedmand
7588911108 Removes all async/await from the extraction pipeline, and adds unit tests for extracted client pages that match a regular expression 2018-01-05 14:56:58 -08:00
freedmand
fc8280adea Fixes small issue with reading chunk files 2018-01-05 14:42:04 -08:00
freedmand
d08f9eb5a4 Fixes funky indentation in extraction unit test 2018-01-05 14:29:46 -08:00
Emil Tholin
2b3472b1b1 Remove async/await from lib 2018-01-05 23:19:40 +01:00
freedmand
30ddb3dd7e Adds a sapper extract CLI command, which scrapes the server to run as a static website starting at the site's root.
TESTED=Basic unit test ensuring relevant routes are added.
2018-01-05 14:16:30 -08:00
Luke Edwards
0c891ba79e require chokidar lazily; dev-mode only 2018-01-05 00:39:17 -08:00
Rich Harris
ee94f355d5 update marked due to vulnerability 2018-01-04 17:17:08 -05:00
Rich Harris
bea9b7965a update package-lock 2018-01-04 17:13:17 -05:00
Rich Harris
1312aede1f -> v0.3.2 2018-01-03 13:48:03 -05:00
Rich Harris
50e307e0c0 add test for programmatic prefetch 2018-01-03 13:46:07 -05:00
Emil Tholin
e87ac1f367 Export prefetch 2018-01-03 18:39:51 +01:00
Rich Harris
5da9d0926a update README, add license 2018-01-01 16:50:21 -05:00
Rich Harris
9538499d51 -> v0.3.1 2017-12-31 16:36:02 -05:00
49 changed files with 1978 additions and 1245 deletions

12
.gitignore vendored
View File

@@ -1,5 +1,15 @@
.DS_Store
yarn.lock
node_modules
cypress/screenshots
test/app/.sapper
runtime.js
runtime.js
runtime.js.map
cli.js
cli.js.map
middleware.js
middleware.js.map
core.js
core.js.map
webpack/config.js
webpack/config.js.map

View File

@@ -3,6 +3,7 @@ sudo: false
language: node_js
node_js:
- "6"
- "stable"
env:

View File

@@ -1,5 +1,41 @@
# sapper changelog
## 0.6.0
* Hydrate on first load, and only on first load ([#93](https://github.com/sveltejs/sapper/pull/93))
* Identify clashes between page and server routes ([#96](https://github.com/sveltejs/sapper/pull/96))
* Remove Express-specific utilities, for compatbility with Polka et al ([#94](https://github.com/sveltejs/sapper/issues/94))
* Return a promise from `init` when first page has rendered ([#99](https://github.com/sveltejs/sapper/issues/99))
* Handle invalid hash links ([#104](https://github.com/sveltejs/sapper/pull/104))
* Avoid `URLSearchParams` ([#107](https://github.com/sveltejs/sapper/pull/107))
* Don't automatically set `Content-Type` for server routes ([#111](https://github.com/sveltejs/sapper/pull/111))
* Handle empty query string routes, e.g. `/?` ([#105](https://github.com/sveltejs/sapper/pull/105))
## 0.5.1
* Only write service-worker.js to filesystem in dev mode ([#90](https://github.com/sveltejs/sapper/issues/90))
## 0.5.0
* Experimental support for `sapper export` ([#9](https://github.com/sveltejs/sapper/issues/9))
* Lazily load chokidar, for faster startup ([#64](https://github.com/sveltejs/sapper/pull/64))
## 0.4.0
* `%sapper.main%` has been replaced with `%sapper.scripts%` ([#86](https://github.com/sveltejs/sapper/issues/86))
* Node 6 support ([#67](https://github.com/sveltejs/sapper/pull/67))
* Explicitly load css-loader and style-loader ([#72](https://github.com/sveltejs/sapper/pull/72))
* DELETE requests are handled with `del` exports ([#77](https://github.com/sveltejs/sapper/issues/77))
* Send preloaded data for first route to client, where possible ([#3](https://github.com/sveltejs/sapper/issues/3))
## 0.3.2
* Expose `prefetch` function ([#61](https://github.com/sveltejs/sapper/pull/61))
## 0.3.1
* Fix missing `runtime.js`
## 0.3.0
* Move `sapper/runtime/app.js` to `sapper/runtime.js`

9
LICENSE Normal file
View File

@@ -0,0 +1,9 @@
Copyright (c) 2017 [these people](https://github.com/sveltejs/sapper/graphs/contributors).
Permission is hereby granted by the authors of this software, to any person, to use the software for any purpose, free of charge, including the rights to run, read, copy, change, distribute and sell it, and including usage rights to any patents the authors may hold on it, subject to the following conditions:
This license, or a link to its text, must be included with all copies of the software and any derivative works.
Any modification to the software submitted to the authors may be incorporated into the software under the terms of this license.
The software is provided "as is", without warranty of any kind, including but not limited to the warranties of title, fitness, merchantability and non-infringement. The authors have no obligation to provide support or updates for the software, and may not be held liable for any damages, claims or other liability arising from its use.

151
README.md
View File

@@ -1,150 +1,37 @@
# sapper
Combat-ready apps, engineered by Svelte.
## This is not a thing yet
If you visit this README in a few weeks, hopefully it will have blossomed into the app development framework we deserve. Right now, it's just a set of ideas.
---
[Next.js](https://github.com/zeit/next.js/) introduced a beautiful idea — that you should be able to build your app as universal React components in a special `pages` directory, and the framework should take care of routing and rendering on both client and server. What if we did the same thing for Svelte?
High-level goals:
* Extreme ease of development
* Code-splitting and HMR out of the box (probably via webpack)
* Best-in-class performance
* As little magic as possible. Anyone should be able to understand how everything fits together, and e.g. make changes to the webpack config
* Links are just `<a>` tags, no special `<Link>` components
[Military-grade progressive web apps, powered by Svelte.](https://sapper.svelte.technology)
## Design
## What is Sapper?
A Sapper app is just an Express app (conventionally, `server.js`) that uses the `sapper` middleware:
Sapper is a framework for building high-performance universal web apps. [Read the guide](https://sapper.svelte.technology/guide) or the [introductory blog post](https://svelte.technology/blog/sapper-towards-the-ideal-web-app-framework) to learn more.
```js
const app = require('express')();
const sapper = require('sapper');
app.use(sapper());
## Get started
const { PORT = 3000 } = process.env;
app.listen(PORT, () => {
console.log(`listening on port ${PORT}`);
});
Clone the [starter project template](https://github.com/sveltejs/sapper-template) with [degit](https://github.com/rich-harris/degit)...
```bash
npx degit sveltejs/sapper-template my-app
```
The middleware serves pages that match files in the `routes` directory, and assets generated by webpack. In development mode, the middleware once activated watches `routes` to keep the app up-to-date.
...then install dependencies and start the dev server...
## Routing
Like Next, routes are defined by the project directory structure, but with some crucial differences:
* Files with an `.html` extension are treated as Svelte components. The `routes/about.html` (or `routes/about/index.html`) would create the `/about` route.
* Files with a `.js` or `.mjs` extension are more generic route handlers. These files should export functions corresponding to the HTTP methods they support (example below).
* Instead of route masking, we embed parameters in the filename. For example `post/[id].html` maps to `/post/:id`, and the component will be rendered with the appropriate parameter.
* Nested routes (read [this article](https://joshduff.com/2015-06-why-you-need-a-state-router.md)) can be handled by creating a file that matches the subroute — for example, `routes/app/settings/[submenu].html` would match `/app/settings/profile` *and* `app/settings`, but in the latter case the `submenu` parameter would be `null`.
An example of a generic route:
```js
// routes/api/post/[id].js
export async function get(req, res) {
try {
const data = await getPostFromDatabase(req.params.id);
const json = JSON.stringify(data);
res.set({
'Content-Type': 'application/json',
'Content-Length': json.length
});
res.send(json);
} catch (err) {
res.status(500).send(err.message);
}
}
```bash
cd my-app
npm install
npm run dev
```
Or, if you omit the `res` argument, it can use the return value:
...and navigate to [localhost:3000](http://localhost:3000). To build and run in production mode:
```js
// routes/api/post/[id].js
export async function get(req) {
return await getPostFromDatabase(req.params.id);
}
```bash
npm run build
npm start
```
## Client-side app
## License
Sapper will create (and in development mode, update) a barebones `main.js` file that dynamically imports individual routes and renders them — something like this:
```js
window.addEventListener('click', event => {
let a = event.target;
while (a && a.nodeName !== 'A') a = a.parentNode;
if (!a) return;
if (navigate(new URL(a.href))) event.preventDefault();
});
const target = document.querySelector('#sapper');
let component;
function navigate(url) {
if (url.origin !== window.location.origin) return;
let match;
let params = {};
const query = {};
function render(mod) {
if (component) {
component.destroy();
} else {
target.innerHTML = '';
}
component = new mod.default({
target,
data: { query, params },
hydrate: !!component
});
}
if (url.pathname === '/about') {
import('/about/index.html').then(render);
} else if (url.pathname === '/') {
import('/index.js').then(render);
} else if (match = /^\/post\/([^\/]+)$/.exec(url.pathname)) {
params.id = match[1];
import('/post/[id].html').then(render);
} else if (match = /^\/([^\/]+)$/.exec(url.pathname)) {
params.wildcard = match[1];
import('/[wildcard].html').then(render);
}
return true;
}
navigate(window.location);
```
We're glossing over a lot of important stuff here — e.g. handling `popstate` — but you get the idea. Knowledge of all the possible routes means we can generate optimal code, much in the same way that statically analysing Svelte templates allows the compiler to generate optimal code.
## Things to figure out
* How to customise the overall page template
* An equivalent of `getInitialProps`
* Critical CSS
* `store` integration
* Route transitions
* Equivalent of `next export`
* A good story for realtime/GraphQL stuff
* Service worker
* Using `Link...rel=preload` headers to push main.js/[route].js plus styles
* ...and lots of other things that haven't occurred to me yet.
[LIL](LICENSE)

View File

@@ -15,3 +15,7 @@ environment:
install:
- ps: Install-Product node $env:nodejs_version
- npm install
test_script:
- node --version && npm --version
- npm test

View File

@@ -1,8 +0,0 @@
#!/usr/bin/env node
const cmd = process.argv[2];
if (cmd === 'build') {
process.env.NODE_ENV = 'production';
require('../lib/build.js')();
}

View File

@@ -1,42 +0,0 @@
const fs = require('fs');
const path = require('path');
const mkdirp = require('mkdirp');
const rimraf = require('rimraf');
const { client, server } = require('./utils/compilers.js');
const create_app = require('./utils/create_app.js');
const generate_asset_cache = require('./utils/generate_asset_cache.js');
const { dest } = require('./config.js');
module.exports = () => {
mkdirp.sync(dest);
rimraf.sync(path.join(dest, '**/*'));
// create main.js and server-routes.js
create_app();
function handleErrors(err, stats) {
if (err) {
console.error(err ? err.details || err.stack || err.message || err : 'Unknown error');
process.exit(1);
}
if (stats.hasErrors()) {
console.log(stats.toString({ colors: true }));
process.exit(1);
}
}
client.run((err, clientStats) => {
handleErrors(err, clientStats);
const clientInfo = clientStats.toJson();
fs.writeFileSync(path.join(dest, 'stats.client.json'), JSON.stringify(clientInfo, null, ' '));
server.run((err, serverStats) => {
handleErrors(err, serverStats);
const serverInfo = serverStats.toJson();
fs.writeFileSync(path.join(dest, 'stats.server.json'), JSON.stringify(serverInfo, null, ' '));
generate_asset_cache(clientInfo, serverInfo);
});
});
};

View File

@@ -1,21 +0,0 @@
const path = require('path');
const mkdirp = require('mkdirp');
const rimraf = require('rimraf');
exports.dev = process.env.NODE_ENV !== 'production';
exports.templates = path.resolve(process.env.SAPPER_TEMPLATES || 'templates');
exports.src = path.resolve(process.env.SAPPER_ROUTES || 'routes');
exports.dest = path.resolve(process.env.SAPPER_DEST || '.sapper');
if (exports.dev) {
mkdirp.sync(exports.dest);
rimraf.sync(path.join(exports.dest, '**/*'));
}
exports.entry = {
client: path.resolve(exports.templates, '.main.rendered.js'),
server: path.resolve(exports.dest, 'server-entry.js')
};

View File

@@ -1,238 +0,0 @@
const fs = require('fs');
const path = require('path');
const route_manager = require('./route_manager.js');
const templates = require('./templates.js');
const create_app = require('./utils/create_app.js');
const create_watcher = require('./utils/create_watcher.js');
const compilers = require('./utils/compilers.js');
const generate_asset_cache = require('./utils/generate_asset_cache.js');
const escape_html = require('escape-html');
const { dest, dev } = require('./config.js');
function connect_dev() {
create_app();
const watcher = create_watcher();
let asset_cache;
const middleware = compose_handlers([
require('webpack-hot-middleware')(compilers.client, {
reload: true,
path: '/__webpack_hmr',
heartbeat: 10 * 1000
}),
async (req, res, next) => {
asset_cache = await watcher.ready;
next();
},
set_req_pathname,
get_asset_handler({
filter: pathname => pathname === '/index.html',
type: 'text/html',
cache: 'max-age=600',
fn: () => asset_cache.client.index
}),
get_asset_handler({
filter: pathname => pathname === '/service-worker.js',
type: 'application/javascript',
cache: 'max-age=600',
fn: () => asset_cache.client.service_worker
}),
get_asset_handler({
filter: pathname => pathname.startsWith('/client/'),
type: 'application/javascript',
cache: 'max-age=31536000',
fn: pathname => asset_cache.client.chunks[pathname]
}),
get_route_handler(() => asset_cache),
get_not_found_handler(() => asset_cache)
]);
middleware.close = () => {
watcher.close();
// TODO shut down chokidar
};
return middleware;
}
function connect_prod() {
const asset_cache = generate_asset_cache(
read_json(path.join(dest, 'stats.client.json')),
read_json(path.join(dest, 'stats.server.json'))
);
const middleware = compose_handlers([
set_req_pathname,
get_asset_handler({
filter: pathname => pathname === '/index.html',
type: 'text/html',
cache: 'max-age=600',
fn: () => asset_cache.client.index
}),
get_asset_handler({
filter: pathname => pathname === '/service-worker.js',
type: 'application/javascript',
cache: 'max-age=600',
fn: () => asset_cache.client.service_worker
}),
get_asset_handler({
filter: pathname => pathname.startsWith('/client/'),
type: 'application/javascript',
cache: 'max-age=31536000',
fn: pathname => asset_cache.client.chunks[pathname]
}),
get_route_handler(() => asset_cache),
get_not_found_handler(() => asset_cache)
]);
// here for API consistency between dev, and prod, but
// doesn't actually need to do anything
middleware.close = () => {};
return middleware;
}
module.exports = dev ? connect_dev : connect_prod;
function set_req_pathname(req, res, next) {
req.pathname = req.url.replace(/\?.+/, '');
next();
}
function get_asset_handler(opts) {
return (req, res, next) => {
if (!opts.filter(req.pathname)) return next();
res.set({
'Content-Type': opts.type,
'Cache-Control': opts.cache
});
res.end(opts.fn(req.pathname));
};
}
function get_route_handler(fn) {
return async function handle_route(req, res, next) {
const url = req.pathname;
const { client, server } = fn();
// whatever happens, we're going to serve some HTML
res.set({
'Content-Type': 'text/html'
});
try {
for (const route of route_manager.routes) {
if (route.test(url)) {
req.params = route.exec(url);
const mod = require(server.entry)[route.id];
if (route.type === 'page') {
// preload main.js and current route
// TODO detect other stuff we can preload? images, CSS, fonts?
res.set('Link', `<${client.main_file}>;rel="preload";as="script", <${client.routes[route.id]}>;rel="preload";as="script"`);
const data = { params: req.params, query: req.query };
if (mod.preload) {
const promise = Promise.resolve(mod.preload(req)).then(preloaded => {
Object.assign(data, preloaded);
return mod.render(data);
});
await templates.stream(res, 200, {
main: client.main_file,
html: promise.then(rendered => rendered.html),
head: promise.then(({ head }) => `<noscript id='sapper-head-start'></noscript>${head}<noscript id='sapper-head-end'></noscript>`),
styles: promise.then(({ css }) => (css && css.code ? `<style>${css.code}</style>` : ''))
});
} else {
const { html, head, css } = mod.render(data);
const page = templates.render(200, {
main: client.main_file,
html,
head: `<noscript id='sapper-head-start'></noscript>${head}<noscript id='sapper-head-end'></noscript>`,
styles: (css && css.code ? `<style>${css.code}</style>` : '')
});
res.end(page);
}
}
else {
const handler = mod[req.method.toLowerCase()];
if (handler) handler(req, res, next);
}
return;
}
}
next();
} catch(err) {
res.status(500);
res.end(templates.render(500, {
title: (err && err.name) || 'Internal server error',
url,
error: escape_html(err && (err.details || err.message || err) || 'Unknown error'),
stack: err && err.stack.split('\n').slice(1).join('\n')
}));
}
};
}
function get_not_found_handler(fn) {
return function handle_not_found(req, res) {
const asset_cache = fn();
res.status(404);
res.end(templates.render(404, {
title: 'Not found',
status: 404,
method: req.method,
main: asset_cache.client.main_file,
url: req.url
}));
};
}
function compose_handlers(handlers) {
return (req, res, next) => {
let i = 0;
function go() {
const handler = handlers[i];
if (handler) {
handler(req, res, () => {
i += 1;
go();
});
} else {
next();
}
}
go();
};
}
function read_json(file) {
return JSON.parse(fs.readFileSync(file, 'utf-8'));
}

View File

@@ -1,31 +0,0 @@
const glob = require('glob');
const chokidar = require('chokidar');
const create_routes = require('./utils/create_routes.js');
const { src, dev } = require('./config.js');
const callbacks = [];
exports.onchange = fn => {
callbacks.push(fn);
};
function update() {
exports.routes = create_routes(
glob.sync('**/*.+(html|js|mjs)', { cwd: src })
);
callbacks.forEach(fn => fn());
}
update();
if (dev) {
const watcher = chokidar.watch(`${src}/**/*.+(html|js|mjs)`, {
ignoreInitial: true,
persistent: false
});
watcher.on('add', update);
watcher.on('change', update);
watcher.on('unlink', update);
}

View File

@@ -1,11 +0,0 @@
const path = require('path');
const relative = require('require-relative');
const webpack = relative('webpack', process.cwd());
exports.client = webpack(
require(path.resolve('webpack.client.config.js'))
);
exports.server = webpack(
require(path.resolve('webpack.server.config.js'))
);

View File

@@ -1,83 +0,0 @@
const fs = require('fs');
const path = require('path');
const chokidar = require('chokidar');
const route_manager = require('../route_manager.js');
const { src, entry, dev } = require('../config.js');
function posixify(file) {
return file.replace(/[/\\]/g, '/');
}
function create_app() {
const { routes } = route_manager;
function create_client_main() {
const template = fs.readFileSync('templates/main.js', 'utf-8');
const code = `[${
routes
.filter(route => route.type === 'page')
.map(route => {
const params = route.dynamic.length === 0 ?
'{}' :
`{ ${route.dynamic.map((part, i) => `${part}: match[${i + 1}]`).join(', ') } }`;
const file = posixify(`${src}/${route.file}`);
return `{ pattern: ${route.pattern}, params: match => (${params}), load: () => import(/* webpackChunkName: "${route.id}" */ '${file}') }`
})
.join(', ')
}]`;
let main = template
.replace(/__app__/g, posixify(path.resolve(__dirname, '../../runtime/app.js')))
.replace(/__routes__/g, code)
.replace(/__dev__/g, String(dev));
if (dev) {
const hmr_client = posixify(require.resolve(`webpack-hot-middleware/client`));
main += `\n\nimport('${hmr_client}?path=/__webpack_hmr&timeout=20000'); if (module.hot) module.hot.accept();`
}
fs.writeFileSync(entry.client, main);
// need to fudge the mtime, because webpack is soft in the head
const { atime, mtime } = fs.statSync(entry.client);
fs.utimesSync(entry.client, new Date(atime.getTime() - 999999), new Date(mtime.getTime() - 999999));
}
function create_server_routes() {
const imports = routes
.map(route => {
const file = posixify(`${src}/${route.file}`);
return route.type === 'page' ?
`import ${route.id} from '${file}';` :
`import * as ${route.id} from '${file}';`;
})
.join('\n');
const exports = `export { ${routes.map(route => route.id)} };`;
fs.writeFileSync(entry.server, `${imports}\n\n${exports}`);
const { atime, mtime } = fs.statSync(entry.server);
fs.utimesSync(entry.server, new Date(atime.getTime() - 999999), new Date(mtime.getTime() - 999999));
}
create_client_main();
create_server_routes();
}
if (dev) {
route_manager.onchange(create_app);
const watcher = chokidar.watch(`templates/main.js`, {
ignoreInitial: true,
persistent: false
});
watcher.on('add', create_app);
watcher.on('change', create_app);
watcher.on('unlink', create_app);
}
module.exports = create_app;

View File

@@ -1,3 +1,4 @@
--require source-map-support/register
--recursive
test/unit/**/*.js
test/common/test.js

934
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,15 +1,16 @@
{
"name": "sapper",
"version": "0.3.0",
"version": "0.6.0",
"description": "Military-grade apps, engineered by Svelte",
"main": "lib/index.js",
"main": "middleware.js",
"bin": {
"sapper": "cli/index.js"
},
"files": [
"cli",
"lib",
"cli.js",
"middleware.js",
"runtime",
"runtime.js",
"webpack"
],
"directories": {
@@ -17,30 +18,44 @@
},
"dependencies": {
"chalk": "^2.3.0",
"cheerio": "^1.0.0-rc.2",
"chokidar": "^1.7.0",
"code-frame": "^5.0.0",
"escape-html": "^1.0.3",
"express": "^4.16.2",
"glob": "^7.1.2",
"locate-character": "^2.0.5",
"mkdirp": "^0.5.1",
"node-fetch": "^1.7.3",
"relative": "^3.0.2",
"require-relative": "^0.8.7",
"rimraf": "^2.6.2",
"sander": "^0.6.0",
"serialize-javascript": "^1.4.0",
"url-parse": "^1.2.0",
"walk-sync": "^0.3.2",
"webpack": "^3.10.0",
"webpack-hot-middleware": "^2.21.0"
},
"devDependencies": {
"@std/esm": "^0.19.7",
"@types/glob": "^5.0.34",
"@types/mkdirp": "^0.5.2",
"@types/rimraf": "^2.0.2",
"css-loader": "^0.28.7",
"eslint": "^4.13.1",
"eslint-plugin-import": "^2.8.0",
"express": "^4.16.2",
"get-port": "^3.2.0",
"mocha": "^4.0.1",
"nightmare": "^2.10.0",
"node-fetch": "^1.7.3",
"npm-run-all": "^4.1.2",
"rollup": "^0.53.0",
"rollup-plugin-typescript": "^0.8.1",
"source-map-support": "^0.5.2",
"style-loader": "^0.19.1",
"svelte": "^1.49.1",
"svelte-loader": "^2.3.2",
"ts-node": "^4.1.0",
"tslib": "^1.8.1",
"typescript": "^2.6.2",
"wait-on": "^2.0.2"

View File

@@ -1,13 +1,97 @@
import typescript from 'rollup-plugin-typescript';
import pkg from './package.json';
const external = [].concat(
Object.keys(pkg.dependencies),
Object.keys(process.binding('natives')),
'sapper/core.js'
);
const paths = {
'sapper/core.js': './core.js'
};
export default [
// cli.js
{
input: 'src/cli/index.ts',
output: {
file: 'cli.js',
format: 'cjs',
banner: '#!/usr/bin/env node',
paths,
sourcemap: true
},
external,
plugins: [
typescript({
typescript: require('typescript')
})
]
},
// core.js
{
input: 'src/core/index.ts',
output: {
file: 'core.js',
format: 'cjs',
banner: '#!/usr/bin/env node',
paths,
sourcemap: true
},
external,
plugins: [
typescript({
typescript: require('typescript')
})
]
},
// middleware.js
{
input: 'src/middleware/index.ts',
output: {
file: 'middleware.js',
format: 'cjs',
paths,
sourcemap: true
},
external,
plugins: [
typescript({
typescript: require('typescript')
})
]
},
// runtime.js
{
input: 'src/runtime/index.ts',
output: {
file: 'runtime.js',
format: 'es'
format: 'es',
paths,
sourcemap: true
},
external,
plugins: [
typescript({
typescript: require('typescript')
})
]
},
// webpack/config.js
{
input: 'src/webpack/index.ts',
output: {
file: 'webpack/config.js',
format: 'cjs',
paths,
sourcemap: true
},
external,
plugins: [
typescript({
typescript: require('typescript')

1
runtime/README.md Normal file
View File

@@ -0,0 +1 @@
This directory exists for legacy reasons and should be deleted before releasing version 1.

28
src/cli/index.ts Executable file
View File

@@ -0,0 +1,28 @@
import { build, export as exporter } from 'sapper/core.js';
import { dest, dev, entry, src } from '../config';
const cmd = process.argv[2];
const start = Date.now();
if (cmd === 'build') {
build({ dest, dev, entry, src })
.then(() => {
const elapsed = Date.now() - start;
console.error(`built in ${elapsed}ms`); // TODO beautify this, e.g. 'built in 4.7 seconds'
})
.catch(err => {
console.error(err ? err.details || err.stack || err.message || err : 'Unknown error');
});
} else if (cmd === 'export') {
const start = Date.now();
build({ dest, dev, entry, src })
.then(() => exporter({ src, dest }))
.then(() => {
const elapsed = Date.now() - start;
console.error(`extracted in ${elapsed}ms`); // TODO beautify this, e.g. 'built in 4.7 seconds'
})
.catch(err => {
console.error(err ? err.details || err.stack || err.message || err : 'Unknown error');
});
}

12
src/config.ts Normal file
View File

@@ -0,0 +1,12 @@
import * as path from 'path';
export const dev = process.env.NODE_ENV !== 'production';
export const templates = path.resolve(process.env.SAPPER_TEMPLATES || 'templates');
export const src = path.resolve(process.env.SAPPER_ROUTES || 'routes');
export const dest = path.resolve(process.env.SAPPER_DEST || '.sapper');
export const entry = {
client: path.resolve(templates, '.main.rendered.js'),
server: path.resolve(dest, 'server-entry.js')
};

62
src/core/build.ts Normal file
View File

@@ -0,0 +1,62 @@
import * as fs from 'fs';
import * as path from 'path';
import mkdirp from 'mkdirp';
import rimraf from 'rimraf';
import create_compilers from './create_compilers.js';
import create_app from './create_app.js';
import create_assets from './create_assets.js';
export default function build({
src,
dest,
dev,
entry
}: {
src: string;
dest: string;
dev: boolean;
entry: { client: string, server: string }
}) {
mkdirp.sync(dest);
rimraf.sync(path.join(dest, '**/*'));
// create main.js and server-routes.js
create_app({ dev, entry, src });
return new Promise((fulfil, reject) => {
function handleErrors(err, stats) {
if (err) {
reject(err);
process.exit(1);
}
if (stats.hasErrors()) {
console.error(stats.toString({ colors: true }));
reject(new Error(`Encountered errors while building app`));
}
}
const { client, server } = create_compilers();
client.run((err, client_stats) => {
handleErrors(err, client_stats);
const client_info = client_stats.toJson();
fs.writeFileSync(
path.join(dest, 'stats.client.json'),
JSON.stringify(client_info, null, ' ')
);
server.run((err, server_stats) => {
handleErrors(err, server_stats);
const server_info = server_stats.toJson();
fs.writeFileSync(
path.join(dest, 'stats.server.json'),
JSON.stringify(server_info, null, ' ')
);
create_assets({ src, dest, dev, client_info, server_info });
fulfil();
});
});
});
}

90
src/core/create_app.ts Normal file
View File

@@ -0,0 +1,90 @@
import * as fs from 'fs';
import * as path from 'path';
import create_routes from './create_routes';
function posixify(file: string) {
return file.replace(/[/\\]/g, '/');
}
function fudge_mtime(file: string) {
// need to fudge the mtime so that webpack doesn't go doolally
const { atime, mtime } = fs.statSync(file);
fs.utimesSync(
file,
new Date(atime.getTime() - 999999),
new Date(mtime.getTime() - 999999)
);
}
function create_app({
src,
dev,
entry
}: {
src: string;
dev: boolean;
entry: { client: string; server: string };
}) {
const routes = create_routes({ src });
function create_client_main() {
const code = `[${routes
.filter(route => route.type === 'page')
.map(route => {
const params =
route.dynamic.length === 0
? '{}'
: `{ ${route.dynamic
.map((part, i) => `${part}: match[${i + 1}]`)
.join(', ')} }`;
const file = posixify(`${src}/${route.file}`);
return `{ pattern: ${
route.pattern
}, params: match => (${params}), load: () => import(/* webpackChunkName: "${
route.id
}" */ '${file}') }`;
})
.join(', ')}]`;
let main = fs
.readFileSync('templates/main.js', 'utf-8')
.replace(
/__app__/g,
posixify(path.resolve(__dirname, '../../runtime/app.js'))
)
.replace(/__routes__/g, code)
.replace(/__dev__/g, String(dev));
if (dev) {
const hmr_client = posixify(
require.resolve(`webpack-hot-middleware/client`)
);
main += `\n\nimport('${hmr_client}?path=/__webpack_hmr&timeout=20000'); if (module.hot) module.hot.accept();`;
}
fs.writeFileSync(entry.client, main);
fudge_mtime(entry.client);
}
function create_server_routes() {
const imports = routes
.map(route => {
const file = posixify(`${src}/${route.file}`);
return route.type === 'page'
? `import ${route.id} from '${file}';`
: `import * as ${route.id} from '${file}';`;
})
.join('\n');
const exports = `export { ${routes.map(route => route.id)} };`;
fs.writeFileSync(entry.server, `${imports}\n\n${exports}`);
fudge_mtime(entry.server);
}
create_client_main();
create_server_routes();
}
export default create_app;

View File

@@ -1,23 +1,26 @@
const fs = require('fs');
const path = require('path');
const glob = require('glob');
const templates = require('../templates.js');
const route_manager = require('../route_manager.js');
const { dest, dev } = require('../config.js');
import * as fs from 'fs';
import * as path from 'path';
import glob from 'glob';
import { create_templates, render } from './templates';
import create_routes from './create_routes';
function ensure_array(thing) {
return Array.isArray(thing) ? thing : [thing]; // omg webpack what the HELL are you doing
}
module.exports = function generate_asset_cache(clientInfo, serverInfo) {
const main_file = `/client/${ensure_array(clientInfo.assetsByChunkName.main)[0]}`;
export default function create_assets({ src, dest, dev, client_info, server_info }) {
create_templates(); // TODO refactor this...
const chunk_files = clientInfo.assets.map(chunk => `/client/${chunk.name}`);
const main_file = `/client/${ensure_array(client_info.assetsByChunkName.main)[0]}`;
const service_worker = generate_service_worker(chunk_files);
const chunk_files = client_info.assets.map(chunk => `/client/${chunk.name}`);
const service_worker = generate_service_worker({ chunk_files, src });
const index = generate_index(main_file);
if (dev) {
const routes = create_routes({ src });
if (dev) { // TODO move this into calling code
fs.writeFileSync(path.join(dest, 'service-worker.js'), service_worker);
fs.writeFileSync(path.join(dest, 'index.html'), index);
}
@@ -33,8 +36,9 @@ module.exports = function generate_asset_cache(clientInfo, serverInfo) {
return lookup;
}, {}),
routes: route_manager.routes.reduce((lookup, route) => {
lookup[route.id] = `/client/${ensure_array(clientInfo.assetsByChunkName[route.id])[0]}`;
// TODO confusing that `routes` refers to an array *and* a lookup
routes: routes.reduce((lookup, route) => {
lookup[route.id] = `/client/${ensure_array(client_info.assetsByChunkName[route.id])[0]}`;
return lookup;
}, {}),
@@ -43,16 +47,20 @@ module.exports = function generate_asset_cache(clientInfo, serverInfo) {
},
server: {
entry: path.resolve(dest, 'server', serverInfo.assetsByChunkName.main)
}
};
};
entry: path.resolve(dest, 'server', server_info.assetsByChunkName.main)
},
function generate_service_worker(chunk_files) {
service_worker
};
}
function generate_service_worker({ chunk_files, src }) {
const assets = glob.sync('**', { cwd: 'assets', nodir: true });
const routes = create_routes({ src });
const route_code = `[${
route_manager.routes
routes
.filter(route => route.type === 'page')
.map(route => `{ pattern: ${route.pattern} }`)
.join(', ')
@@ -66,7 +74,7 @@ function generate_service_worker(chunk_files) {
}
function generate_index(main_file) {
return templates.render(200, {
return render(200, {
styles: '',
head: '',
html: '<noscript>Please enable JavaScript!</noscript>',

View File

@@ -0,0 +1,16 @@
import * as path from 'path';
import relative from 'require-relative';
export default function create_compilers() {
const webpack = relative('webpack', process.cwd());
return {
client: webpack(
require(path.resolve('webpack.client.config.js'))
),
server: webpack(
require(path.resolve('webpack.server.config.js'))
)
};
}

View File

@@ -1,6 +1,7 @@
const path = require('path');
import * as path from 'path';
import glob from 'glob';
module.exports = function create_matchers(files) {
export default function create_routes({ src, files = glob.sync('**/*.+(html|js|mjs)', { cwd: src }) }) {
const routes = files
.map(file => {
if (/(^|\/|\\)_/.test(file)) return;
@@ -31,7 +32,7 @@ module.exports = function create_matchers(files) {
}
}
const pattern = new RegExp(`^${pattern_string || '\\/'}$`);
const pattern = new RegExp(`^${pattern_string}\\/?$`);
const test = url => pattern.test(url);
@@ -78,7 +79,7 @@ module.exports = function create_matchers(files) {
const b_is_dynamic = b_part[0] === '[';
if (a_is_dynamic === b_is_dynamic) {
if (!a_is_dynamic) same = false;
if (!a_is_dynamic && a_part !== b_part) same = false;
continue;
}
@@ -87,4 +88,4 @@ module.exports = function create_matchers(files) {
});
return routes;
};
}

101
src/core/export.ts Normal file
View File

@@ -0,0 +1,101 @@
import * as path from 'path';
import * as sander from 'sander';
import express from 'express';
import cheerio from 'cheerio';
import fetch from 'node-fetch';
import URL from 'url-parse';
import create_assets from './create_assets.js';
// import middleware from '../middleware/index.js';
const { PORT = 3000, OUTPUT_DIR = 'dist' } = process.env;
const origin = `http://localhost:${PORT}`;
const app = express();
function read_json(file) {
return JSON.parse(sander.readFileSync(file, { encoding: 'utf-8' }));
}
export default function exporter({ src, dest }) { // TODO dest is a terrible name in this context
// Prep output directory
sander.rimrafSync(OUTPUT_DIR);
const { service_worker } = create_assets({
src, dest,
dev: false,
client_info: read_json(path.join(dest, 'stats.client.json')),
server_info: read_json(path.join(dest, 'stats.server.json'))
});
sander.copydirSync('assets').to(OUTPUT_DIR);
sander.copydirSync(dest, 'client').to(OUTPUT_DIR, 'client');
sander.writeFileSync(OUTPUT_DIR, 'service-worker.js', service_worker);
// Intercept server route fetches
function save(res) {
res = res.clone();
return res.text().then(body => {
const { pathname } = new URL(res.url);
let dest = OUTPUT_DIR + pathname;
const type = res.headers.get('Content-Type');
if (type.startsWith('text/html')) dest += '/index.html';
sander.writeFileSync(dest, body);
return body;
});
}
global.fetch = (url, opts) => {
if (url[0] === '/') {
url = `http://localhost:${PORT}${url}`;
return fetch(url, opts)
.then(r => {
save(r);
return r;
});
}
return fetch(url, opts);
};
app.use(require('./middleware')()); // TODO this is filthy
const server = app.listen(PORT);
const seen = new Set();
function handle(url) {
if (url.origin !== origin) return;
if (seen.has(url.pathname)) return;
seen.add(url.pathname);
return fetch(url.href)
.then(r => {
save(r);
return r.text();
})
.then(body => {
const $ = cheerio.load(body);
const hrefs = [];
$('a[href]').each((i, $a) => {
hrefs.push($a.attribs.href);
});
return hrefs.reduce((promise, href) => {
return promise.then(() => handle(new URL(href, url.href)));
}, Promise.resolve());
})
.catch(err => {
console.error(`Error rendering ${url.pathname}: ${err.message}`);
});
}
return handle(new URL(origin)) // TODO all static routes
.then(() => server.close());
}

11
src/core/index.ts Normal file
View File

@@ -0,0 +1,11 @@
import { create_templates, render, stream } from './templates'; // TODO templates is an anomaly... fix post-#91
export { default as build } from './build';
export { default as export } from './export.js';
export { default as create_app } from './create_app';
export { default as create_assets } from './create_assets';
export { default as create_compilers } from './create_compilers';
export { default as create_routes } from './create_routes';
export const templates = { create_templates, render, stream };

View File

@@ -1,18 +1,45 @@
const fs = require('fs');
const glob = require('glob');
const chokidar = require('chokidar');
const { dev } = require('./config.js');
import * as fs from 'fs';
import glob from 'glob';
import chalk from 'chalk';
import framer from 'code-frame';
import { locate } from 'locate-character';
let templates;
function create_templates() {
function error(e) {
if (e.title) console.error(chalk.bold.red(e.title));
if (e.body) console.error(chalk.red(e.body));
if (e.url) console.error(chalk.cyan(e.url));
if (e.frame) console.error(chalk.grey(e.frame));
process.exit(1);
}
export function create_templates() {
templates = glob.sync('*.html', { cwd: 'templates' })
.map(file => {
const template = fs.readFileSync(`templates/${file}`, 'utf-8');
const status = file.replace('.html', '').toLowerCase();
if (!/^[0-9x]{3}$/.test(status)) {
throw new Error(`Bad template — should be a valid status code like 404.html, or a wildcard like 2xx.html`);
error({
title: `templates/${file}`,
body: `Bad template — should be a valid status code like 404.html, or a wildcard like 2xx.html`
});
}
const index = template.indexOf('%sapper.main%');
if (index !== -1) {
// TODO remove this in a future version
const { line, column } = locate(template, index, { offsetLine: 1 });
const frame = framer(template, line, column);
error({
title: `templates/${file}`,
body: `<script src='%sapper.main%'> is unsupported — use %sapper.scripts% (without the <script> tag) instead`,
url: 'https://github.com/sveltejs/sapper/issues/86',
frame
});
}
const specificity = (
@@ -31,10 +58,14 @@ function create_templates() {
return key in data ? data[key] : '';
});
},
stream: async (res, data) => {
stream: (res, data) => {
let i = 0;
do {
function stream_inner() {
if (i >= template.length) {
return;
}
const start = template.indexOf('%sapper', i);
if (start === -1) {
@@ -53,38 +84,32 @@ function create_templates() {
const match = /sapper\.(\w+)/.exec(tag);
if (!match || !(match[1] in data)) throw new Error(`Bad template`); // TODO ditto
res.write(await data[match[1]]);
i = end + 1;
} while (i < template.length);
return Promise.resolve(data[match[1]]).then(datamatch => {
res.write(datamatch);
i = end + 1;
return stream_inner();
});
}
return Promise.resolve().then(stream_inner);
}
};
})
.sort((a, b) => b.specificity - a.specificity);
return templates;
}
create_templates();
if (dev) {
const watcher = chokidar.watch('templates/**.html', {
ignoreInitial: true,
persistent: false
});
watcher.on('add', create_templates);
watcher.on('change', create_templates);
watcher.on('unlink', create_templates);
}
exports.render = (status, data) => {
export function render(status, data) {
const template = templates.find(template => template.test(status));
if (template) return template.render(data);
return `Missing template for status code ${status}`;
};
}
exports.stream = (res, status, data) => {
export function stream(res, status, data) {
const template = templates.find(template => template.test(status));
if (template) return template.stream(res, data);
return `Missing template for status code ${status}`;
};
}

View File

@@ -1,9 +1,8 @@
const fs = require('fs');
const path = require('path');
const chalk = require('chalk');
const compilers = require('./compilers.js');
const generate_asset_cache = require('./generate_asset_cache.js');
const { dest } = require('../config.js');
import * as fs from 'fs';
import * as path from 'path';
import chalk from 'chalk';
import { create_app, create_assets, create_routes, templates } from 'sapper/core.js';
import { dest } from '../config.js';
function deferred() {
const d = {};
@@ -16,7 +15,7 @@ function deferred() {
return d;
}
module.exports = function create_watcher() {
export default function create_watcher({ compilers, dev, entry, src, onroutes }) {
const deferreds = {
client: deferred(),
server: deferred()
@@ -32,10 +31,11 @@ module.exports = function create_watcher() {
const server_info = server_stats.toJson();
fs.writeFileSync(path.join(dest, 'stats.server.json'), JSON.stringify(server_info, null, ' '));
return generate_asset_cache(
client_stats.toJson(),
server_stats.toJson()
);
return create_assets({
src, dest, dev,
client_info: client_stats.toJson(),
server_info: server_stats.toJson()
});
});
function watch_compiler(type) {
@@ -60,6 +60,34 @@ module.exports = function create_watcher() {
});
}
const chokidar = require('chokidar');
function watch_files(pattern, callback) {
const watcher = chokidar.watch(pattern, {
persistent: false
});
watcher.on('add', callback);
watcher.on('change', callback);
watcher.on('unlink', callback);
}
watch_files('routes/**/*.+(html|js|mjs)', () => {
const routes = create_routes({ src });
onroutes(routes);
create_app({ dev, entry, src }); // TODO this calls `create_routes` again, we should pass `routes` to `create_app` instead
});
watch_files('templates/main.js', () => {
create_app({ dev, entry, src });
});
watch_files('templates/**.html', () => {
templates.create_templates();
// TODO reload current page?
});
const watcher = {
ready: invalidate(),
client: watch_compiler('client'),
@@ -72,4 +100,4 @@ module.exports = function create_watcher() {
};
return watcher;
};
}

282
src/middleware/index.ts Normal file
View File

@@ -0,0 +1,282 @@
import * as fs from 'fs';
import * as path from 'path';
import mkdirp from 'mkdirp';
import rimraf from 'rimraf';
import serialize from 'serialize-javascript';
import escape_html from 'escape-html';
import { create_routes, templates, create_compilers, create_assets } from 'sapper/core.js';
import create_watcher from './create_watcher';
import { dest, dev, entry, src } from '../config';
function connect_dev() {
mkdirp.sync(dest);
rimraf.sync(path.join(dest, '**/*'));
const compilers = create_compilers();
let routes;
const watcher = create_watcher({
dev, entry, src,
compilers,
onroutes: _ => {
routes = _;
}
});
let asset_cache;
const middleware = compose_handlers([
require('webpack-hot-middleware')(compilers.client, {
reload: true,
path: '/__webpack_hmr',
heartbeat: 10 * 1000
}),
(req, res, next) => {
watcher.ready.then(cache => {
asset_cache = cache;
next();
});
},
set_req_pathname,
get_asset_handler({
filter: pathname => pathname === '/index.html',
type: 'text/html',
cache: 'max-age=600',
fn: () => asset_cache.client.index
}),
get_asset_handler({
filter: pathname => pathname === '/service-worker.js',
type: 'application/javascript',
cache: 'max-age=600',
fn: () => asset_cache.client.service_worker
}),
get_asset_handler({
filter: pathname => pathname.startsWith('/client/'),
type: 'application/javascript',
cache: 'max-age=31536000',
fn: pathname => asset_cache.client.chunks[pathname]
}),
get_route_handler(() => asset_cache, () => routes),
get_not_found_handler(() => asset_cache)
]);
middleware.close = () => {
watcher.close();
// TODO shut down chokidar
};
return middleware;
}
function connect_prod() {
const asset_cache = create_assets({
src, dest,
dev: false,
client_info: read_json(path.join(dest, 'stats.client.json')),
server_info: read_json(path.join(dest, 'stats.server.json'))
});
const routes = create_routes({ src }); // TODO rename update
const middleware = compose_handlers([
set_req_pathname,
get_asset_handler({
filter: pathname => pathname === '/index.html',
type: 'text/html',
cache: 'max-age=600',
fn: () => asset_cache.client.index
}),
get_asset_handler({
filter: pathname => pathname === '/service-worker.js',
type: 'application/javascript',
cache: 'max-age=600',
fn: () => asset_cache.client.service_worker
}),
get_asset_handler({
filter: pathname => pathname.startsWith('/client/'),
type: 'application/javascript',
cache: 'max-age=31536000',
fn: pathname => asset_cache.client.chunks[pathname]
}),
get_route_handler(() => asset_cache, () => routes),
get_not_found_handler(() => asset_cache)
]);
// here for API consistency between dev, and prod, but
// doesn't actually need to do anything
middleware.close = () => {};
return middleware;
}
export default dev ? connect_dev : connect_prod;
function set_req_pathname(req, res, next) {
req.pathname = req.url.replace(/\?.*/, '');
next();
}
function get_asset_handler(opts) {
return (req, res, next) => {
if (!opts.filter(req.pathname)) return next();
res.setHeader('Content-Type', opts.type);
res.setHeader('Cache-Control', opts.cache);
res.end(opts.fn(req.pathname));
};
}
const resolved = Promise.resolve();
function get_route_handler(get_assets, get_routes) {
function handle_route(route, req, res, next, { client, server }) {
req.params = route.exec(req.pathname);
const mod = require(server.entry)[route.id];
if (route.type === 'page') {
// for page routes, we're going to serve some HTML
res.setHeader('Content-Type', 'text/html');
// preload main.js and current route
// TODO detect other stuff we can preload? images, CSS, fonts?
res.setHeader('Link', `<${client.main_file}>;rel="preload";as="script", <${client.routes[route.id]}>;rel="preload";as="script"`);
const data = { params: req.params, query: req.query };
if (mod.preload) {
const promise = Promise.resolve(mod.preload(req)).then(preloaded => {
const serialized = try_serialize(preloaded);
Object.assign(data, preloaded);
return { rendered: mod.render(data), serialized };
});
return templates.stream(res, 200, {
scripts: promise.then(({ serialized }) => {
const main = `<script src='${client.main_file}'></script>`;
if (serialized) {
return `<script>__SAPPER__ = { preloaded: ${serialized} };</script>${main}`;
}
return main;
}),
html: promise.then(({ rendered }) => rendered.html),
head: promise.then(({ rendered }) => `<noscript id='sapper-head-start'></noscript>${rendered.head}<noscript id='sapper-head-end'></noscript>`),
styles: promise.then(({ rendered }) => (rendered.css && rendered.css.code ? `<style>${rendered.css.code}</style>` : ''))
});
} else {
const { html, head, css } = mod.render(data);
const page = templates.render(200, {
scripts: `<script src='${client.main_file}'></script>`,
html,
head: `<noscript id='sapper-head-start'></noscript>${head}<noscript id='sapper-head-end'></noscript>`,
styles: (css && css.code ? `<style>${css.code}</style>` : '')
});
res.end(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) {
handler(req, res, next);
} else {
// no matching handler for method — 404
next();
}
}
}
return function find_route(req, res, next) {
const url = req.pathname;
resolved
.then(() => {
const routes = get_routes();
for (const route of routes) {
if (route.test(url)) return handle_route(route, req, res, next, get_assets());
}
// no matching route — 404
next();
})
.catch(err => {
res.statusCode = 500;
res.end(templates.render(500, {
title: (err && err.name) || 'Internal server error',
url,
error: escape_html(err && (err.details || err.message || err) || 'Unknown error'),
stack: err && err.stack.split('\n').slice(1).join('\n')
}));
});
};
}
function get_not_found_handler(fn) {
return function handle_not_found(req, res) {
const asset_cache = fn();
res.statusCode = 404;
res.end(templates.render(404, {
title: 'Not found',
status: 404,
method: req.method,
scripts: `<script src='${asset_cache.client.main_file}'></script>`,
url: req.url
}));
};
}
function compose_handlers(handlers) {
return (req, res, next) => {
let i = 0;
function go() {
const handler = handlers[i];
if (handler) {
handler(req, res, () => {
i += 1;
go();
});
} else {
next();
}
}
go();
};
}
function read_json(file) {
return JSON.parse(fs.readFileSync(file, 'utf-8'));
}
function try_serialize(data) {
try {
return serialize(data);
} catch (err) {
return null;
}
}

View File

@@ -1,5 +1,5 @@
import { detach, findAnchor, scroll_state, which } from './utils';
import { Component, ComponentConstructor, Params, Query, Route, RouteData, ScrollPosition } from './interfaces';
import { Component, ComponentConstructor, Params, Query, Route, RouteData, ScrollPosition, Target } from './interfaces';
export let component: Component;
let target: Node;
@@ -19,7 +19,7 @@ if ('scrollRestoration' in history) {
history.scrollRestoration = 'manual';
}
function select_route(url: URL): { route: Route, data: RouteData } {
function select_route(url: URL): Target {
if (url.origin !== window.location.origin) return null;
for (const route of routes) {
@@ -28,9 +28,13 @@ function select_route(url: URL): { route: Route, data: RouteData } {
const params = route.params(match);
const query: Record<string, string | true> = {};
for (const [key, value] of url.searchParams) query[key] = value || true;
return { route, data: { params, query } };
if (url.search.length > 0) {
url.search.slice(1).split('&').forEach(searchParam => {
const [, key, value] = /([^=]+)=(.*)/.exec(searchParam);
query[key] = value || true;
})
}
return { url, route, data: { params, query } };
}
}
}
@@ -60,7 +64,7 @@ function render(Component: ComponentConstructor, data: any, scroll: ScrollPositi
component = new Component({
target,
data,
hydrate: !!component
hydrate: !component
});
if (scroll) {
@@ -68,44 +72,46 @@ function render(Component: ComponentConstructor, data: any, scroll: ScrollPositi
}
}
function prepare_route(Component, data) {
return Promise.resolve(
Component.preload ? Component.preload(data) : {}
).then(preloaded => {
function prepare_route(Component: ComponentConstructor, data: RouteData) {
if (!Component.preload) {
return { Component, data };
}
if (!component && window.__SAPPER__ && window.__SAPPER__.preloaded) {
return { Component, data: Object.assign(data, window.__SAPPER__.preloaded) };
}
return Promise.resolve(Component.preload(data)).then(preloaded => {
Object.assign(data, preloaded)
return { Component, data };
});
}
function navigate(url: URL, id: number) {
const selected = select_route(url);
if (selected) {
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 };
}
const loaded = prefetching && prefetching.href === url.href ?
prefetching.promise :
selected.route.load().then(mod => prepare_route(mod.default, selected.data));
prefetching = null;
const token = current_token = {};
loaded.then(({ Component, data }) => {
render(Component, data, scroll_history[id], token);
});
function navigate(target: Target, id: number) {
if (id) {
// popstate or initial navigation
cid = id;
return true;
} 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;
const loaded = prefetching && prefetching.href === target.url.href ?
prefetching.promise :
target.route.load().then(mod => prepare_route(mod.default, target.data));
prefetching = null;
const token = current_token = {};
return loaded.then(({ Component, data }) => {
render(Component, data, scroll_history[id], token);
});
}
function handle_click(event: MouseEvent) {
@@ -141,7 +147,9 @@ function handle_click(event: MouseEvent) {
// Don't handle hash changes
if (url.pathname === window.location.pathname && url.search === window.location.search) return;
if (navigate(url, null)) {
const target = select_route(url);
if (target) {
navigate(target, null);
event.preventDefault();
history.pushState({ id: cid }, '', url.href);
}
@@ -151,7 +159,9 @@ function handle_popstate(event: PopStateEvent) {
scroll_history[cid] = scroll_state();
if (event.state) {
navigate(new URL(window.location.href), event.state.id);
const url = new URL(window.location.href);
const target = select_route(url);
navigate(target, event.state.id);
} else {
// hashchange
cid = ++uid;
@@ -164,20 +174,24 @@ let prefetching: {
promise: Promise<{ Component: ComponentConstructor, data: any }>;
} = null;
function prefetch(event: MouseEvent | TouchEvent) {
const a: HTMLAnchorElement = <HTMLAnchorElement>findAnchor(<Node>event.target);
if (!a || a.rel !== 'prefetch') return;
const selected = select_route(new URL(a.href));
export function prefetch(href: string) {
const selected = select_route(new URL(href));
if (selected) {
prefetching = {
href: a.href,
href,
promise: selected.route.load().then(mod => prepare_route(mod.default, selected.data))
};
}
}
function handle_touchstart_mouseover(event: MouseEvent | TouchEvent) {
const a: HTMLAnchorElement = <HTMLAnchorElement>findAnchor(<Node>event.target);
if (!a || a.rel !== 'prefetch') return;
prefetch(a.href);
}
let inited: boolean;
export function init(_target: Node, _routes: Route[]) {
@@ -189,29 +203,33 @@ export function init(_target: Node, _routes: Route[]) {
window.addEventListener('popstate', handle_popstate);
// prefetch
window.addEventListener('touchstart', prefetch);
window.addEventListener('mouseover', prefetch);
window.addEventListener('touchstart', handle_touchstart_mouseover);
window.addEventListener('mouseover', handle_touchstart_mouseover);
inited = true;
}
setTimeout(() => {
return Promise.resolve().then(() => {
const { hash, href } = window.location;
const deep_linked = hash && document.querySelector(hash);
const deep_linked = hash && document.getElementById(hash.slice(1));
scroll_history[uid] = deep_linked ?
{ x: 0, y: deep_linked.getBoundingClientRect().top } :
scroll_state();
history.replaceState({ id: uid }, '', href);
navigate(new URL(window.location.href), uid);
const target = select_route(new URL(window.location.href));
return navigate(target, uid);
});
}
export function goto(href: string, opts = { replaceState: false }) {
if (navigate(new URL(href, window.location.href), null)) {
const 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;
}
}
}

View File

@@ -20,4 +20,10 @@ export type Route = {
export type ScrollPosition = {
x: number;
y: number;
};
export type Target = {
url: URL;
route: Route;
data: RouteData;
};

View File

@@ -1,12 +1,17 @@
const { dest, dev, entry } = require('../lib/config.js');
import { dest, dev, entry } from '../config';
module.exports = {
export default {
dev,
client: {
entry: () => {
return {
main: entry.client
main: [
entry.client,
// workaround for https://github.com/webpack-contrib/extract-text-webpack-plugin/issues/456
'style-loader/lib/addStyles',
'css-loader/lib/css-base'
]
};
},
@@ -36,4 +41,4 @@ module.exports = {
};
}
}
};
};

3
test/app/.gitignore vendored
View File

@@ -3,4 +3,5 @@ node_modules
.sapper
yarn.lock
cypress/screenshots
templates/.*
templates/.*
dist

View File

@@ -2722,9 +2722,9 @@
"integrity": "sha1-5WqpTEyAVaFkBKBnS3jyFffI4ZQ="
},
"marked": {
"version": "0.3.7",
"resolved": "https://registry.npmjs.org/marked/-/marked-0.3.7.tgz",
"integrity": "sha512-zBEP4qO1YQp5aXHt8S5wTiOv9i2X74V/LQL0zhUNvVaklt6Ywa6lChxIvS+ibYlCGgADwKwZFhjC3+XfpsvQvQ=="
"version": "0.3.9",
"resolved": "https://registry.npmjs.org/marked/-/marked-0.3.9.tgz",
"integrity": "sha512-nW5u0dxpXxHfkHzzrveY45gCbi+R4PaO4WRZYqZNl+vB0hVGeqlFn0aOg1c8AKL63TrNFn9Bm2UP4AdiZ9TPLw=="
},
"math-expression-evaluator": {
"version": "1.2.17",

View File

@@ -15,7 +15,7 @@
"express": "^4.16.2",
"extract-text-webpack-plugin": "^3.0.2",
"glob": "^7.1.2",
"marked": "^0.3.7",
"marked": "^0.3.9",
"node-fetch": "^1.7.3",
"npm-run-all": "^4.1.2",
"serve-static": "^1.13.1",

View File

@@ -7,12 +7,13 @@
<p>This is the 'about' page. There's not much here.</p>
<button on:click='goto("/blog/what-is-sapper")'>What is Sapper?</button>
<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>
<script>
import Layout from './_components/Layout.html';
import { goto } from '../../../runtime.js';
import { goto, prefetch } from '../../../runtime.js';
export default {
components: {
@@ -20,7 +21,8 @@
},
methods: {
goto
goto,
prefetch
}
};
</script>

View File

@@ -0,0 +1,9 @@
export function del(req, res) {
res.set({
'Content-Type': 'application/json'
});
res.end(JSON.stringify({
id: req.params.id
}));
}

View File

@@ -32,7 +32,7 @@
},
preload({ params, query }) {
return fetch(`/api/blog`).then(r => r.json()).then(posts => {
return fetch(`/api/blog/contents`).then(r => r.json()).then(posts => {
return { posts };
});
}

View File

@@ -0,0 +1,15 @@
<button class='del' on:click='del()'>delete</button>
<script>
export default {
methods: {
del() {
fetch(`/api/delete/42`, { method: 'DELETE' })
.then(r => r.json())
.then(data => {
window.deleted = data;
});
}
}
};
</script>

View File

@@ -6,13 +6,15 @@
<h1>Great success!</h1>
<figure>
<img src='/great-success.png'>
<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>
<div class='hydrate-test'></div>
<style>
h1, figure, p {
text-align: center;

View File

@@ -3,7 +3,7 @@
<script>
export default {
preload({ url }) {
return { url };
if (url) return { url };
}
};
</script>
</script>

View File

@@ -4,7 +4,11 @@
export default {
preload() {
return new Promise(fulfil => {
window.fulfil = fulfil;
if (typeof window !== 'undefined') {
window.fulfil = fulfil;
} else {
fulfil({});
}
});
}
};

View File

@@ -32,6 +32,6 @@
<!-- Sapper creates a <script> tag containing `templates/main.js`
and anything else it needs to hydrate the app and
initialise the router -->
<script src='%sapper.main%'></script>
%sapper.scripts%
</body>
</html>

View File

@@ -1,6 +1,5 @@
import { init } from '../../../runtime.js';
// `routes` is an array of route objects injected by Sapper
init(document.querySelector('#sapper'), __routes__);
window.READY = true;
window.init = () => {
return init(document.querySelector('#sapper'), __routes__);
};

View File

@@ -1,14 +1,20 @@
const path = require('path');
const assert = require('assert');
const Nightmare = require('nightmare');
const express = require('express');
const serve = require('serve-static');
const Nightmare = require('nightmare');
const getPort = require('get-port');
const walkSync = require('walk-sync');
const fetch = require('node-fetch');
run('production');
run('development');
Nightmare.action('page', {
title(done) {
this.evaluate_now(() => document.querySelector('h1').textContent, done);
}
});
function run(env) {
describe(`env=${env}`, function () {
this.timeout(20000);
@@ -34,7 +40,11 @@ function run(env) {
};
const res = {
set: (headers, value) => {
setHeader(header, value) {
result.headers[header] = value;
},
set(headers, value) {
if (typeof headers === 'string') {
return res.set({ [headers]: value });
}
@@ -42,15 +52,15 @@ function run(env) {
Object.assign(result.headers, headers);
},
status: code => {
status(code) {
result.status = code;
},
write: data => {
write(data) {
result.body += data;
},
end: data => {
end(data) {
result.body += data;
fulfil(result);
}
@@ -62,52 +72,66 @@ function run(env) {
});
}
before(async () => {
before(() => {
process.chdir(path.resolve(__dirname, '../app'));
process.env.NODE_ENV = env;
let exec_promise = Promise.resolve();
let sapper;
if (env === 'production') {
const cli = path.resolve(__dirname, '../../cli/index.js');
await exec(`${cli} build`);
const cli = path.resolve(__dirname, '../../cli.js');
exec_promise = exec(`node ${cli} export`);
}
const resolved = require.resolve('../..');
delete require.cache[resolved];
const sapper = require(resolved);
return exec_promise.then(() => {
const resolved = require.resolve('../../middleware.js');
delete require.cache[resolved];
delete require.cache[require.resolve('../../core.js')]; // TODO remove this
PORT = await getPort();
base = `http://localhost:${PORT}`;
sapper = require(resolved);
global.fetch = (url, opts) => {
if (url[0] === '/') url = `${base}${url}`;
return fetch(url, opts);
};
return require('get-port')();
}).then(port => {
PORT = port;
base = `http://localhost:${PORT}`;
let captured;
capture = async fn => {
const result = captured = [];
await fn();
captured = null;
return result;
};
Nightmare.action('init', function(done) {
this.evaluate_now(() => window.init(), done);
});
const app = express();
global.fetch = (url, opts) => {
if (url[0] === '/') url = `${base}${url}`;
return fetch(url, opts);
};
app.use(serve('assets'));
let captured;
capture = fn => {
const result = captured = [];
return fn().then(() => {
captured = null;
return result;
});
};
app.use((req, res, next) => {
if (captured) captured.push(req);
next();
});
const app = express();
middleware = sapper();
app.use(middleware);
app.use(serve('assets'));
return new Promise((fulfil, reject) => {
server = app.listen(PORT, err => {
if (err) reject(err);
else fulfil();
app.use((req, res, next) => {
if (captured) captured.push(req);
next();
});
middleware = sapper();
app.use(middleware);
return new Promise((fulfil, reject) => {
server = app.listen(PORT, err => {
if (err) reject(err);
else fulfil();
});
});
});
});
@@ -137,175 +161,284 @@ function run(env) {
});
});
afterEach(async () => {
await nightmare.end();
afterEach(() => {
return nightmare.end();
});
it('serves /', async () => {
const title = await nightmare
.goto(base)
.evaluate(() => document.querySelector('h1').textContent);
assert.equal(title, 'Great success!');
});
it('serves static route', async () => {
const title = await nightmare
.goto(`${base}/about`)
.evaluate(() => document.querySelector('h1').textContent);
assert.equal(title, 'About this site');
});
it('serves dynamic route', async () => {
const title = await nightmare
.goto(`${base}/blog/what-is-sapper`)
.evaluate(() => document.querySelector('h1').textContent);
assert.equal(title, 'What is Sapper?');
});
it('navigates to a new page without reloading', async () => {
await nightmare.goto(base).wait(() => window.READY).wait(100);
const requests = await capture(async () => {
await nightmare.click('a[href="/about"]');
it('serves /', () => {
return nightmare.goto(base).page.title().then(title => {
assert.equal(title, 'Great success!');
});
assert.equal(
await nightmare.path(),
'/about'
);
assert.equal(
await nightmare.evaluate(() => document.title),
'About'
);
assert.deepEqual(requests.map(r => r.url), []);
});
it('navigates programmatically', async () => {
await nightmare
it('serves /?', () => {
return nightmare.goto(`${base}?`).page.title().then(title => {
assert.equal(title, 'Great success!');
});
});
it('serves static route', () => {
return nightmare.goto(`${base}/about`).page.title().then(title => {
assert.equal(title, 'About this site');
});
});
it('serves dynamic route', () => {
return nightmare.goto(`${base}/blog/what-is-sapper`).page.title().then(title => {
assert.equal(title, 'What is Sapper?');
});
});
it('navigates to a new page without reloading', () => {
return nightmare.goto(base).init().wait(100)
.then(() => {
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');
return nightmare.title();
})
.then(title => {
assert.equal(title, 'About');
});
});
it('navigates programmatically', () => {
return nightmare
.goto(`${base}/about`)
.wait(() => window.READY)
.click('button')
.init()
.click('.goto')
.wait(() => window.location.pathname === '/blog/what-is-sapper')
.wait(100);
assert.equal(
await nightmare.evaluate(() => document.title),
'What is Sapper?'
);
});
it('scrolls to active deeplink', async () => {
const scrollY = await nightmare
.goto(`${base}/blog/a-very-long-post#four`)
.wait(() => window.READY)
.wait(100)
.evaluate(() => window.scrollY);
assert.ok(scrollY > 0, scrollY);
.title()
.then(title => {
assert.equal(title, 'What is Sapper?');
});
});
it('reuses prefetch promise', async () => {
await nightmare
it('prefetches programmatically', () => {
return nightmare
.goto(`${base}/about`)
.init()
.then(() => {
return capture(() => {
return nightmare
.click('.prefetch')
.wait(100);
});
})
.then(requests => {
assert.ok(!!requests.find(r => r.url === '/api/blog/why-the-name'));
});
});
it('scrolls to active deeplink', () => {
return nightmare
.goto(`${base}/blog/a-very-long-post#four`)
.init()
.evaluate(() => window.scrollY)
.then(scrollY => {
assert.ok(scrollY > 0, scrollY);
});
});
it('reuses prefetch promise', () => {
return nightmare
.goto(`${base}/blog`)
.wait(() => window.READY)
.wait(200);
.init().wait(100)
.then(() => {
return capture(() => {
return nightmare
.mouseover('[href="/blog/what-is-sapper"]')
.wait(200);
});
})
.then(mouseover_requests => {
assert.deepEqual(mouseover_requests.map(r => r.url), [
'/api/blog/what-is-sapper'
]);
const mouseover_requests = (await capture(async () => {
await nightmare
.mouseover('[href="/blog/what-is-sapper"]')
.wait(200);
})).map(r => r.url);
assert.deepEqual(mouseover_requests, [
'/api/blog/what-is-sapper'
]);
const click_requests = (await capture(async () => {
await nightmare
.click('[href="/blog/what-is-sapper"]')
.wait(200);
})).map(r => r.url);
assert.deepEqual(click_requests, []);
return capture(() => {
return nightmare
.click('[href="/blog/what-is-sapper"]')
.wait(200);
});
})
.then(click_requests => {
assert.deepEqual(click_requests.map(r => r.url), []);
});
});
it('cancels navigation if subsequent navigation occurs during preload', async () => {
await nightmare
it('cancels navigation if subsequent navigation occurs during preload', () => {
return nightmare
.goto(base)
.wait(() => window.READY)
.init()
.click('a[href="/slow-preload"]')
.wait(100)
.click('a[href="/about"]')
.wait(100);
assert.equal(
await nightmare.path(),
'/about'
);
assert.equal(
await nightmare.evaluate(() => document.querySelector('h1').textContent),
'About this site'
);
await nightmare
.evaluate(() => window.fulfil({}))
.wait(100);
assert.equal(
await nightmare.path(),
'/about'
);
assert.equal(
await nightmare.evaluate(() => document.querySelector('h1').textContent),
'About this site'
);
.wait(100)
.then(() => nightmare.path())
.then(path => {
assert.equal(path, '/about');
return nightmare.title();
})
.then(title => {
assert.equal(title, 'About');
return nightmare.evaluate(() => window.fulfil({})).wait(100);
})
.then(() => nightmare.path())
.then(path => {
assert.equal(path, '/about');
return nightmare.title();
})
.then(title => {
assert.equal(title, 'About');
});
});
it('passes entire request object to preload', async () => {
const html = await nightmare
it('passes entire request object to preload', () => {
return nightmare
.goto(`${base}/show-url`)
.evaluate(() => document.querySelector('p').innerHTML);
.init()
.evaluate(() => document.querySelector('p').innerHTML)
.end().then(html => {
assert.equal(html, `URL is /show-url`);
});
});
assert.equal(html, `URL is /show-url`);
it('calls a delete handler', () => {
return nightmare
.goto(`${base}/delete-test`)
.init()
.click('.del')
.wait(() => window.deleted)
.evaluate(() => window.deleted.id)
.then(id => {
assert.equal(id, 42);
});
});
it('hydrates initial route', () => {
return nightmare.goto(base)
.wait('.hydrate-test')
.evaluate(() => {
window.el = document.querySelector('.hydrate-test');
})
.init()
.evaluate(() => {
return document.querySelector('.hydrate-test') === window.el;
})
.then(matches => {
assert.ok(matches);
});
});
});
describe('headers', () => {
it('sets Content-Type and Link...preload headers', async () => {
const { headers } = await get('/');
it('sets Content-Type and Link...preload headers', () => {
return get('/').then(({ headers }) => {
assert.equal(
headers['Content-Type'],
'text/html'
);
assert.equal(
headers['Content-Type'],
'text/html'
);
assert.ok(
/<\/client\/main.\w+\.js>;rel="preload";as="script", <\/client\/_.\d+.\w+.js>;rel="preload";as="script"/.test(headers['Link']),
headers['Link']
);
assert.ok(
/<\/client\/main.\w+\.js>;rel="preload";as="script", <\/client\/_.\d+.\w+.js>;rel="preload";as="script"/.test(headers['Link']),
headers['Link']
);
});
});
});
if (env === 'production') {
describe('export', () => {
it('export all pages', () => {
const dest = path.resolve(__dirname, '../app/dist');
// Pages that should show up in the extraction directory.
const expectedPages = [
'index.html',
'about/index.html',
'slow-preload/index.html',
'blog/index.html',
'blog/a-very-long-post/index.html',
'blog/how-can-i-get-involved/index.html',
'blog/how-is-sapper-different-from-next/index.html',
'blog/how-to-use-sapper/index.html',
'blog/what-is-sapper/index.html',
'blog/why-the-name/index.html',
'api/blog/contents',
'api/blog/a-very-long-post',
'api/blog/how-can-i-get-involved',
'api/blog/how-is-sapper-different-from-next',
'api/blog/how-to-use-sapper',
'api/blog/what-is-sapper',
'api/blog/why-the-name',
'favicon.png',
'global.css',
'great-success.png',
'manifest.json',
'service-worker.js',
'svelte-logo-192.png',
'svelte-logo-512.png',
];
// Client scripts that should show up in the extraction directory.
const expectedClientRegexes = [
/client\/_\..*?\.js/,
/client\/about\..*?\.js/,
/client\/blog_\$slug\$\..*?\.js/,
/client\/blog\..*?\.js/,
/client\/main\..*?\.js/,
/client\/show_url\..*?\.js/,
/client\/slow_preload\..*?\.js/,
];
const allPages = walkSync(dest);
expectedPages.forEach((expectedPage) => {
assert.ok(allPages.includes(expectedPage),
`Could not find page matching ${expectedPage}`);
});
expectedClientRegexes.forEach((expectedRegex) => {
// Ensure each client page regular expression matches at least one
// generated page.
let matched = false;
for (const page of allPages) {
if (expectedRegex.test(page)) {
matched = true;
break;
}
}
assert.ok(matched,
`Could not find client page matching ${expectedRegex}`);
});
});
});
}
});
}
function exec(cmd) {
return new Promise((fulfil, reject) => {
require('child_process').exec(cmd, (err, stdout, stderr) => {
if (err) {
process.stdout.write(stdout);
process.stderr.write(stderr);
const parts = cmd.split(' ');
const proc = require('child_process').spawn(parts.shift(), parts);
return reject(err);
}
fulfil();
proc.stdout.on('data', data => {
process.stdout.write(data);
});
proc.stderr.on('data', data => {
process.stderr.write(data);
});
proc.on('error', reject);
proc.on('close', () => fulfil());
});
}
}

View File

@@ -1,11 +1,11 @@
const path = require('path');
const assert = require('assert');
const create_routes = require('../../lib/utils/create_routes.js');
const { create_routes } = require('../../core.js');
describe('create_routes', () => {
it('sorts routes correctly', () => {
const routes = create_routes(['index.html', 'about.html', '[wildcard].html', 'post/foo.html', 'post/[id].html', 'post/bar.html']);
const routes = create_routes({
files: ['index.html', 'about.html', '[wildcard].html', 'post/foo.html', 'post/[id].html', 'post/bar.html']
});
assert.deepEqual(
routes.map(r => r.file),
@@ -21,7 +21,9 @@ describe('create_routes', () => {
});
it('generates params', () => {
const routes = create_routes(['index.html', 'about.html', '[wildcard].html', 'post/[id].html']);
const routes = create_routes({
files: ['index.html', 'about.html', '[wildcard].html', 'post/[id].html']
});
let file;
let params;
@@ -40,7 +42,9 @@ describe('create_routes', () => {
});
it('ignores files and directories with leading underscores', () => {
const routes = create_routes(['index.html', '_foo.html', 'a/_b/c/d.html', 'e/f/g/h.html', 'i/_j.html']);
const routes = create_routes({
files: ['index.html', '_foo.html', 'a/_b/c/d.html', 'e/f/g/h.html', 'i/_j.html']
});
assert.deepEqual(
routes.map(r => r.file),
@@ -52,8 +56,12 @@ describe('create_routes', () => {
});
it('matches /foo/:bar before /:baz/qux', () => {
const a = create_routes(['foo/[bar].html', '[baz]/qux.html']);
const b = create_routes(['[baz]/qux.html', 'foo/[bar].html']);
const a = create_routes({
files: ['foo/[bar].html', '[baz]/qux.html']
});
const b = create_routes({
files: ['[baz]/qux.html', 'foo/[bar].html']
});
assert.deepEqual(
a.map(r => r.file),
@@ -68,12 +76,22 @@ describe('create_routes', () => {
it('fails if routes are indistinguishable', () => {
assert.throws(() => {
create_routes(['[foo].html', '[bar]/index.html']);
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/);
});
it('matches nested routes', () => {
const route = create_routes(['settings/[submenu].html'])[0];
const route = create_routes({
files: ['settings/[submenu].html']
})[0];
assert.deepEqual(route.exec('/settings/foo'), {
submenu: 'foo'
@@ -85,7 +103,9 @@ describe('create_routes', () => {
});
it('prefers index routes to nested routes', () => {
const routes = create_routes(['settings/[submenu].html', 'settings.html']);
const routes = create_routes({
files: ['settings/[submenu].html', 'settings.html']
});
assert.deepEqual(
routes.map(r => r.file),
@@ -94,7 +114,9 @@ describe('create_routes', () => {
});
it('matches deeply nested routes', () => {
const route = create_routes(['settings/[a]/[b]/index.html'])[0];
const route = create_routes({
files: ['settings/[a]/[b]/index.html']
})[0];
assert.deepEqual(route.exec('/settings/foo/bar'), {
a: 'foo',

View File

@@ -1 +0,0 @@
import 'webpack-hot-middleware/client?path=/__webpack_hmr&timeout=20000';