diff --git a/package.json b/package.json index 62df424..0194551 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "express": "^4.16.2", "glob": "^7.1.2", "locate-character": "^2.0.5", + "mime": "^2.2.0", "mkdirp": "^0.5.1", "mri": "^1.1.0", "node-fetch": "^1.7.3", @@ -36,8 +37,7 @@ "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" + "webpack": "^3.10.0" }, "devDependencies": { "@std/esm": "^0.19.7", @@ -63,9 +63,7 @@ "svelte-loader": "^2.3.2", "ts-node": "^4.1.0", "tslib": "^1.8.1", - "typescript": "^2.6.2", - "wait-on": "^2.0.2", - "wait-port": "^0.2.2" + "typescript": "^2.6.2" }, "scripts": { "cy:open": "cypress open", diff --git a/src/cli/dev.ts b/src/cli/dev.ts index 5615186..a7e02f3 100644 --- a/src/cli/dev.ts +++ b/src/cli/dev.ts @@ -1,12 +1,17 @@ import * as fs from 'fs'; import * as path from 'path'; +import * as net from 'net'; import * as chalk from 'chalk'; import * as child_process from 'child_process'; +import * as http from 'http'; +import mkdirp from 'mkdirp'; +import rimraf from 'rimraf'; +import { wait_for_port } from './utils'; import { create_compilers, create_app, create_routes, create_serviceworker, create_template } from 'sapper/core.js'; type Deferred = { promise?: Promise; - fulfil?: (value: any) => void; + fulfil?: (value?: any) => void; reject?: (err: Error) => void; } @@ -21,61 +26,61 @@ function deferred() { return d; } +function create_hot_update_server(port: number, interval = 10000) { + const clients = new Set(); + + const server = http.createServer((req, res) => { + if (req.url !== '/hmr') return; + + req.socket.setKeepAlive(true); + res.writeHead(200, { + 'Access-Control-Allow-Origin': '*', + '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'); + + 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 default function create_watcher(src: string, dir: string) { + rimraf.sync(dir); + mkdirp.sync(dir); + + const chokidar = require('chokidar'); + // initial build const routes = create_routes({ src }); create_app({ routes, src, dev: true }); + const hot_update_server = create_hot_update_server(23456); // TODO robustify port selection + + // TODO watch the configs themselves? const compilers = create_compilers(); - const deferreds = { - client: deferred(), - server: deferred() - }; - - const invalidate = () => Promise.all([ - deferreds.client.promise, - deferreds.server.promise - ]).then(([client_stats, server_stats]) => { - const client_info = client_stats.toJson(); - fs.writeFileSync(path.join(dir, 'stats.client.json'), JSON.stringify(client_info, null, ' ')); - - const server_info = server_stats.toJson(); - fs.writeFileSync(path.join(dir, 'stats.server.json'), JSON.stringify(server_info, null, ' ')); - - const client_files = client_info.assets.map((chunk: { name: string }) => `/client/${chunk.name}`); - - return create_serviceworker({ - routes: create_routes({ src }), - client_files, - src - }); - }); - - function watch_compiler(type: 'client' | 'server', callback: (err: Error) => void) { - const compiler = compilers[type]; - - compiler.plugin('invalid', (filename: string) => { - console.log(chalk.cyan(`${type} bundle invalidated, file changed: ${chalk.bold(filename)}`)); - deferreds[type] = deferred(); - watcher.ready = invalidate(); - }); - - compiler.plugin('failed', (err: Error) => { - deferreds[type].reject(err); - }); - - return compiler.watch({}, (err: Error, stats: any) => { - if (stats.hasErrors()) { - deferreds[type].reject(stats.toJson().errors[0]); - } else { - deferreds[type].fulfil(stats); - } - }); - } - - const chokidar = require('chokidar'); - function watch_files(pattern: string, callback: () => void) { const watcher = chokidar.watch(pattern, { persistent: false @@ -96,11 +101,114 @@ export default function create_watcher(src: string, dir: string) { // TODO reload current page? }); - watch_compiler('client', () => { + let proc: child_process.ChildProcess; + const deferreds = { + server: deferred(), + client: deferred() + }; + + const times = { + client_start: Date.now(), + server_start: Date.now(), + serviceworker_start: Date.now() + }; + + compilers.server.plugin('invalid', () => { + times.server_start = Date.now(); + // TODO print message + deferreds.server = deferred(); }); - watch_compiler('server', () => { + compilers.server.watch({}, (err: Error, stats: any) => { + if (err) { + console.error(chalk.red(err.message)); + } else if (stats.hasErrors()) { + // print errors. TODO notify client + stats.toJson().errors.forEach((error: Error) => { + console.error(error); // TODO make this look nice + }); + } else { + console.log(`built server in ${Date.now() - times.server_start}ms`); // TODO prettify + const server_info = stats.toJson(); + fs.writeFileSync(path.join(dir, 'server_info.json'), JSON.stringify(server_info, null, ' ')); + + deferreds.client.promise.then(() => { + if (proc) proc.kill(); + + proc = child_process.fork(`${dir}/server.js`, [], { + cwd: process.cwd(), + env: Object.assign({}, process.env) + }); + + wait_for_port(3000, deferreds.server.fulfil); // TODO control port + }); + } }); + + compilers.client.plugin('invalid', (filename: string) => { + times.client_start = Date.now(); + + deferreds.client = deferred(); + + // TODO print message + fs.readdirSync(path.join(dir, 'client')).forEach(file => { + fs.unlinkSync(path.join(dir, 'client', file)); + }); + }); + + compilers.client.watch({}, (err: Error, stats: any) => { + if (err) { + console.error(chalk.red(err.message)); + } else if (stats.hasErrors()) { + // print errors. TODO notify client + stats.toJson().errors.forEach((error: Error) => { + console.error(error); // TODO make this look nice + }); + } else { + console.log(`built client in ${Date.now() - times.client_start}ms`); // TODO prettify + + const client_info = stats.toJson(); + fs.writeFileSync(path.join(dir, 'client_info.json'), JSON.stringify(client_info, null, ' ')); + deferreds.client.fulfil(); + + const client_files = client_info.assets.map((chunk: { name: string }) => `/client/${chunk.name}`); + + deferreds.server.promise.then(() => { + hot_update_server.send({ + status: 'completed' + }); + }); + + return create_serviceworker({ + routes: create_routes({ src }), + client_files, + src + }); + } + }); + + if (compilers.serviceworker) { + compilers.serviceworker.plugin('invalid', (filename: string) => { + times.serviceworker_start = Date.now(); + }); + + compilers.client.watch({}, (err: Error, stats: any) => { + if (err) { + // TODO notify client + } else if (stats.hasErrors()) { + // print errors. TODO notify client + stats.toJson().errors.forEach((error: Error) => { + console.error(error); // TODO make this look nice + }); + } else { + console.log(`built service worker in ${Date.now() - times.serviceworker_start}ms`); // TODO prettify + + const serviceworker_info = stats.toJson(); + fs.writeFileSync(path.join(dir, 'serviceworker_info.json'), JSON.stringify(serviceworker_info, null, ' ')); + // TODO trigger reload? + } + }); + } } \ No newline at end of file diff --git a/src/cli/export.ts b/src/cli/export.ts index 557e32b..50960bc 100644 --- a/src/cli/export.ts +++ b/src/cli/export.ts @@ -5,6 +5,7 @@ import express from 'express'; import cheerio from 'cheerio'; import URL from 'url-parse'; import fetch from 'node-fetch'; +import { wait_for_port } from './utils'; const { OUTPUT_DIR = 'dist' } = process.env; @@ -55,8 +56,6 @@ export default async function exporter(dir: string) { // dir === '.sapper' } }); - await require('wait-port')({ port }); - function handle(url: URL) { if (url.origin !== origin) return; @@ -85,6 +84,8 @@ export default async function exporter(dir: string) { // dir === '.sapper' }); } - return handle(new URL(origin)) // TODO all static routes - .then(() => proc.kill()); + wait_for_port(port, () => { + handle(new URL(origin)) // TODO all static routes + .then(() => proc.kill()) + }); } \ No newline at end of file diff --git a/src/cli/utils.ts b/src/cli/utils.ts new file mode 100644 index 0000000..6fd1a1e --- /dev/null +++ b/src/cli/utils.ts @@ -0,0 +1,18 @@ +import * as net from 'net'; + +export function wait_for_port(port: number, cb: () => void) { + const socket = net.createConnection({ port }, () => { + cb(); + socket.destroy(); + }); + + socket.on('error', err => { + setTimeout(() => { + wait_for_port(port, cb); + }, 100); + }); + + setTimeout(() => { + socket.destroy(); + }, 100); +} \ No newline at end of file diff --git a/src/core/create_app.ts b/src/core/create_app.ts index c934653..4430f47 100644 --- a/src/core/create_app.ts +++ b/src/core/create_app.ts @@ -18,7 +18,8 @@ export default function create_app({ routes, src, dev }: { function generate_client(routes: Route[], src: string, dev: boolean) { let code = ` - // This file is generated by Sapper — do not edit it!\nexport const routes = [ + // This file is generated by Sapper — do not edit it! + export const routes = [ ${routes .filter(route => route.type === 'page') .map(route => { @@ -34,10 +35,18 @@ function generate_client(routes: Route[], src: string, dev: boolean) { if (dev) { const hmr_client = posixify( - require.resolve(`webpack-hot-middleware/client`) + path.resolve(__dirname, 'src/hmr-client.js') ); - code += `\n\nimport('${hmr_client}?path=/__webpack_hmr&timeout=20000'); if (module.hot) module.hot.accept();`; + const PORT = 23456; // TODO robustify this — needs to be controlled by the dev task + + code += ` + + if (module.hot) { + import('${hmr_client}').then(client => { + client.connect(${PORT}); + }); + }`.replace(/^\t{3}/gm, ''); } return code; diff --git a/src/core/create_serviceworker.ts b/src/core/create_serviceworker.ts index d6841c8..1e07182 100644 --- a/src/core/create_serviceworker.ts +++ b/src/core/create_serviceworker.ts @@ -13,6 +13,7 @@ export default function create_serviceworker({ routes, client_files, src }: { const assets = glob.sync('**', { cwd: 'assets', nodir: true }); let code = ` + // This file is generated by Sapper — do not edit it! export const timestamp = ${Date.now()}; export const assets = [\n\t${assets.map((x: string) => `"${x}"`).join(',\n\t')}\n]; @@ -20,7 +21,7 @@ export default function create_serviceworker({ routes, client_files, src }: { export const shell = [\n\t${client_files.map((x: string) => `"${x}"`).join(',\n\t')}\n]; export const routes = [\n\t${routes.filter((r: Route) => r.type === 'page').map((r: Route) => `{ pattern: ${r.pattern} }`).join(',\n\t')}\n]; - `.replace(/^\t\t/gm, ''); + `.replace(/^\t\t/gm, '').trim(); write('app/manifest/service-worker.js', code); } \ No newline at end of file diff --git a/src/hmr-client.js b/src/hmr-client.js new file mode 100644 index 0000000..88938dc --- /dev/null +++ b/src/hmr-client.js @@ -0,0 +1,28 @@ +let source; + +function check() { + if (module.hot.status() === 'idle') { + module.hot.check(true).then(modules => { + console.log(`HMR updated`); + }); + } +} + +export function connect(port) { + if (source || !window.EventSource) return; + + source = new EventSource(`http://localhost:${port}/hmr`); + + source.onopen = function(event) { + console.log(`HMR connected`); + }; + + source.onmessage = function(event) { + const data = JSON.parse(event.data); + if (!data) return; // just a heartbeat + + if (data.status === 'completed') { + check(); + } + }; +} \ No newline at end of file diff --git a/src/middleware/TO_MOVE_create_watcher.ts b/src/middleware/TO_MOVE_create_watcher.ts deleted file mode 100644 index 3c4f1b4..0000000 --- a/src/middleware/TO_MOVE_create_watcher.ts +++ /dev/null @@ -1,109 +0,0 @@ -import * as fs from 'fs'; -import * as path from 'path'; -import chalk from 'chalk'; -import { create_app, create_serviceworker, create_routes, create_template } from 'sapper/core.js'; -import { dest } from '../config.js'; - -type Deferred = { - promise?: Promise; - fulfil?: (value: any) => void; - reject?: (err: Error) => void; -} - -function deferred() { - const d: Deferred = {}; - - d.promise = new Promise((fulfil, reject) => { - d.fulfil = fulfil; - d.reject = reject; - }); - - return d; -} - -export default function create_watcher({ compilers, dev, entry, src, onroutes, ontemplate }) { - const deferreds = { - client: deferred(), - server: deferred() - }; - - const invalidate = () => Promise.all([ - deferreds.client.promise, - deferreds.server.promise - ]).then(([client_stats, server_stats]) => { - const client_info = client_stats.toJson(); - fs.writeFileSync(path.join(dest, 'stats.client.json'), JSON.stringify(client_info, null, ' ')); - - const server_info = server_stats.toJson(); - fs.writeFileSync(path.join(dest, 'stats.server.json'), JSON.stringify(server_info, null, ' ')); - - const client_files = client_info.assets.map((chunk: { name: string }) => `/client/${chunk.name}`); - - return create_serviceworker({ - routes: create_routes({ src }), - client_files, - src - }); - }); - - function watch_compiler(type: 'client' | 'server') { - const compiler = compilers[type]; - - compiler.plugin('invalid', (filename: string) => { - console.log(chalk.cyan(`${type} bundle invalidated, file changed: ${chalk.bold(filename)}`)); - deferreds[type] = deferred(); - watcher.ready = invalidate(); - }); - - compiler.plugin('failed', (err: Error) => { - deferreds[type].reject(err); - }); - - return compiler.watch({}, (err: Error, stats: any) => { - if (stats.hasErrors()) { - deferreds[type].reject(stats.toJson().errors[0]); - } else { - deferreds[type].fulfil(stats); - } - }); - } - - const chokidar = require('chokidar'); - - function watch_files(pattern: string, callback: () => void) { - 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({ routes, src, dev }); - }); - - watch_files('app/template.html', () => { - const template = create_template(); - ontemplate(template); - - // TODO reload current page? - }); - - const watcher = { - ready: invalidate(), - client: watch_compiler('client'), - server: watch_compiler('server'), - - close: () => { - watcher.client.close(); - watcher.server.close(); - } - }; - - return watcher; -} \ No newline at end of file diff --git a/src/middleware/index.ts b/src/middleware/index.ts index 2838d45..56822af 100644 --- a/src/middleware/index.ts +++ b/src/middleware/index.ts @@ -1,5 +1,6 @@ import * as fs from 'fs'; import * as path from 'path'; +// import * as mime from 'mime'; import mkdirp from 'mkdirp'; import rimraf from 'rimraf'; import serialize from 'serialize-javascript'; @@ -49,12 +50,26 @@ export default function middleware({ routes }: { fn: () => assets.service_worker }), - get_asset_handler({ - filter: (pathname: string) => pathname.startsWith('/client/'), - type: 'application/javascript', - cache: 'max-age=31536000', - fn: (pathname: string) => assets.client[pathname.replace('/client/', '')] - }), + (req, res, next) => { + if (req.pathname.startsWith('/client/')) { + // const type = mime.getType(req.pathname); + const type = 'application/javascript'; // TODO might not be, if using e.g. CSS plugin + + // TODO cache? + const rs = fs.createReadStream(path.join(dest, req.pathname.slice(1))); + + rs.on('error', error => { + res.statusCode = 404; + res.end('not found'); + }); + + res.setHeader('Content-Type', type); + res.setHeader('Cache-Control', 'max-age=31536000'); + rs.pipe(res); + } else { + next(); + } + }, get_route_handler(client_info.assetsByChunkName, () => assets, () => routes, () => template),