diff --git a/.gitignore b/.gitignore index 8ac5504..733b0b1 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ test/app/app/manifest test/app/export test/app/build sapper +api.js runtime.js dist !rollup.config.js \ No newline at end of file diff --git a/package.json b/package.json index 01bce03..3089182 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "files": [ "*.js", "*.ts.js", + "api", "runtime", "webpack", "sapper", diff --git a/rollup.config.js b/rollup.config.js index 2a2c7e9..856c34f 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -25,7 +25,13 @@ export default [ }, { - input: [`src/cli.ts`, `src/core.ts`, `src/middleware.ts`, `src/webpack.ts`], + input: [ + `src/api.ts`, + `src/cli.ts`, + `src/core.ts`, + `src/middleware.ts`, + `src/webpack.ts` + ], output: { dir: 'dist', format: 'cjs', diff --git a/src/api.ts b/src/api.ts new file mode 100644 index 0000000..9cde40e --- /dev/null +++ b/src/api.ts @@ -0,0 +1,5 @@ +import { dev } from './api/dev'; +import { build } from './api/build'; +import { exporter } from './api/export'; + +export { dev, build, exporter }; \ No newline at end of file diff --git a/src/api/build.ts b/src/api/build.ts index 6b19040..2560fee 100644 --- a/src/api/build.ts +++ b/src/api/build.ts @@ -6,16 +6,17 @@ import { EventEmitter } from 'events'; import { minify_html } from './utils/minify_html'; import { create_compilers, create_main_manifests, create_routes, create_serviceworker_manifest } from '../core' import { locations } from '../config'; +import * as events from './interfaces'; -export default function build(opts: {}) { +export function build(opts: {}) { const emitter = new EventEmitter(); execute(emitter, opts).then( () => { - emitter.emit('done', {}); // TODO do we need to pass back any info? + emitter.emit('done', {}); // TODO do we need to pass back any info? }, error => { - emitter.emit('error', { + emitter.emit('error', { error }); } @@ -54,7 +55,7 @@ async function execute(emitter: EventEmitter, { const { client, server, serviceworker } = create_compilers({ webpack }); const client_stats = await compile(client); - emitter.emit('build', { + emitter.emit('build', { type: 'client', // TODO duration/warnings webpack_stats: client_stats @@ -65,7 +66,7 @@ async function execute(emitter: EventEmitter, { })); const server_stats = await compile(server); - emitter.emit('build', { + emitter.emit('build', { type: 'server', // TODO duration/warnings webpack_stats: server_stats @@ -81,7 +82,7 @@ async function execute(emitter: EventEmitter, { serviceworker_stats = await compile(serviceworker); - emitter.emit('build', { + emitter.emit('build', { type: 'serviceworker', // TODO duration/warnings webpack_stats: serviceworker_stats diff --git a/src/api/dev.ts b/src/api/dev.ts index e69de29..6a9aff9 100644 --- a/src/api/dev.ts +++ b/src/api/dev.ts @@ -0,0 +1,394 @@ +import * as path from 'path'; +import * as fs from 'fs'; +import * as http from 'http'; +import * as child_process from 'child_process'; +import * as ports from 'port-authority'; +import mkdirp from 'mkdirp'; +import rimraf from 'rimraf'; +import format_messages from 'webpack-format-messages'; +import prettyMs from 'pretty-ms'; +import { locations } from '../config'; +import { EventEmitter } from 'events'; +import { create_routes, create_main_manifests, create_compilers, create_serviceworker_manifest } from '../core'; +import * as events from '../interfaces'; + +export function dev(opts) { + return new Watcher(opts); +} + +class Watcher extends EventEmitter { + dirs: { + app: string; + dest: string; + routes: string; + webpack: string; + } + port: number; + closed: boolean; + + dev_server: DevServer; + proc: child_process.ChildProcess; + filewatchers: Array<{ close: () => void }>; + deferreds: { + client: Deferred; + server: Deferred; + }; + + restarting: boolean; + current_build: { + changed: Set; + rebuilding: Set; + unique_warnings: Set; + unique_errors: Set; + } + + constructor({ + app = locations.app(), + dest = locations.dest(), + routes = locations.routes(), + webpack = 'webpack', + port = +process.env.PORT + }: { + app: string, + dest: string, + routes: string, + webpack: string, + port: number + }) { + super(); + + this.dirs = { app, dest, routes, webpack }; + this.port = port; + this.closed = false; + + this.filewatchers = []; + + this.current_build = { + changed: new Set(), + rebuilding: new Set(), + unique_errors: new Set(), + unique_warnings: new Set() + }; + + // remove this in a future version + const template = fs.readFileSync(path.join(app, 'template.html'), 'utf-8'); + if (template.indexOf('%sapper.base%') === -1) { + const error = new Error(`As of Sapper v0.10, your template.html file must include %sapper.base% in the `); + error.code = `missing-sapper-base`; + throw error; + } + + process.env.NODE_ENV = 'development'; + + process.on('exit', () => { + this.close(); + }); + + this.init(); + } + + async init() { + if (this.port) { + if (!await ports.check(this.port)) { + this.emit('fatal', { + error: new Error(`Port ${this.port} is unavailable`) + }); + return; + } + } else { + this.port = await ports.find(3000); + } + + const { dest } = this.dirs; + rimraf.sync(dest); + mkdirp.sync(dest); + + const dev_port = await ports.find(10000); + + const routes = create_routes(); + create_main_manifests({ routes, dev_port }); + + this.dev_server = new DevServer(dev_port); + + this.filewatchers.push( + watch_files(locations.routes(), ['add', 'unlink'], () => { + const routes = create_routes(); + create_main_manifests({ routes, dev_port }); + }), + + watch_files(`${locations.app()}/template.html`, ['change'], () => { + this.dev_server.send({ + action: 'reload' + }); + }) + ); + + this.deferreds = { + server: new Deferred(), + client: new Deferred() + }; + + // TODO watch the configs themselves? + const compilers = create_compilers({ webpack: this.dirs.webpack }); + + this.watch(compilers.server, { + name: 'server', + + invalid: filename => { + this.restart(filename, 'server'); + this.deferreds.server = new Deferred(); + }, + + result: info => { + fs.writeFileSync(path.join(dest, 'server_info.json'), JSON.stringify(info, null, ' ')); + + this.deferreds.client.promise.then(() => { + const restart = () => { + ports.wait(this.port).then(this.deferreds.server.fulfil); + }; + + if (this.proc) { + this.proc.kill(); + this.proc.on('exit', restart); + } else { + restart(); + } + + this.proc = child_process.fork(`${dest}/server.js`, [], { + cwd: process.cwd(), + env: Object.assign({ + PORT: this.port + }, process.env) + }); + }); + } + }); + + let first = true; + + this.watch(compilers.client, { + name: 'client', + + invalid: filename => { + this.restart(filename, 'client'); + this.deferreds.client = new Deferred(); + + // TODO we should delete old assets. due to a webpack bug + // i don't even begin to comprehend, this is apparently + // quite difficult + }, + + result: info => { + fs.writeFileSync(path.join(dest, 'client_info.json'), JSON.stringify({ + assets: info.assetsByChunkName + }, null, ' ')); + this.deferreds.client.fulfil(); + + const client_files = info.assets.map((chunk: { name: string }) => `client/${chunk.name}`); + + this.deferreds.server.promise.then(() => { + this.dev_server.send({ + status: 'completed' + }); + + this.emit('ready', { + port: this.port + }); + }); + + create_serviceworker_manifest({ + routes: create_routes(), + client_files + }); + + watch_serviceworker(); + } + }); + + let watch_serviceworker = compilers.serviceworker + ? () => { + watch_serviceworker = noop; + + this.watch(compilers.serviceworker, { + name: 'service worker', + + result: info => { + fs.writeFileSync(path.join(dest, 'serviceworker_info.json'), JSON.stringify(info, null, ' ')); + } + }); + } + : noop; + } + + close() { + if (this.closed) return; + this.closed = true; + + this.dev_server.close(); + + if (this.proc) this.proc.kill(); + this.filewatchers.forEach(watcher => { + watcher.close(); + }); + } + + restart(filename: string, type: string) { + if (this.restarting) { + this.current_build.changed.add(filename); + this.current_build.rebuilding.add(type); + } else { + this.restarting = true; + + this.current_build = { + changed: new Set(), + rebuilding: new Set(), + unique_warnings: new Set(), + unique_errors: new Set() + }; + + process.nextTick(() => { + this.emit('invalid', { + changed: Array.from(this.current_build.changed), + invalid: { + server: this.current_build.rebuilding.has('server'), + client: this.current_build.rebuilding.has('client'), + serviceworker: this.current_build.rebuilding.has('serviceworker'), + } + }); + + this.restarting = false; + }); + } + } + + watch(compiler: any, { name, invalid = noop, result }: { + name: string, + invalid?: (filename: string) => void; + result: (stats: any) => void; + }) { + compiler.hooks.invalid.tap('sapper', (filename: string) => { + invalid(filename); + }); + + compiler.watch({}, (err: Error, stats: any) => { + if (err) { + this.emit('error', { + type: name, + error: err + }); + } else { + const messages = format_messages(stats); + const info = stats.toJson(); + + this.emit('build', { + type: name, + + duration: info.time, + + errors: messages.errors.map((message: string) => { + const duplicate = this.current_build.unique_errors.has(message); + this.current_build.unique_errors.add(message); + + return { + message, + duplicate + } + }), + + warnings: messages.warnings.map((message: string) => { + const duplicate = this.current_build.unique_warnings.has(message); + this.current_build.unique_warnings.add(message); + + return { + message, + duplicate + } + }), + }); + + result(info); + } + }); + } +} + +class Deferred { + promise: Promise; + fulfil: (value?: any) => void; + reject: (error: Error) => void; + + constructor() { + this.promise = new Promise((fulfil, reject) => { + this.fulfil = fulfil; + this.reject = reject; + }); + } +} + +class DevServer { + clients: Set; + _: http.Server; + + constructor(port: number, interval = 10000) { + this.clients = new Set(); + + this._ = http.createServer((req, res) => { + if (req.url !== '/__sapper__') return; + + req.socket.setKeepAlive(true); + res.writeHead(200, { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Headers': 'Cache-Control', + 'Content-Type': 'text/event-stream;charset=utf-8', + 'Cache-Control': 'no-cache, no-transform', + 'Connection': 'keep-alive', + // While behind nginx, event stream should not be buffered: + // http://nginx.org/docs/http/ngx_http_proxy_module.html#proxy_buffering + 'X-Accel-Buffering': 'no' + }); + + res.write('\n'); + + this.clients.add(res); + req.on('close', () => { + this.clients.delete(res); + }); + }); + + this._.listen(port); + + setInterval(() => { + this.send(null); + }); + } + + close() { + this._.close(); + } + + send(data: any) { + this.clients.forEach(client => { + client.write(`data: ${JSON.stringify(data)}\n\n`); + }); + } +} + +function noop() {} + +function watch_files(pattern: string, events: string[], callback: () => void) { + const chokidar = require('chokidar'); + + const watcher = chokidar.watch(pattern, { + persistent: true, + ignoreInitial: true, + disableGlobbing: true + }); + + events.forEach(event => { + watcher.on(event, callback); + }); + + return { + close: () => watcher.close() + }; +} diff --git a/src/api/export.ts b/src/api/export.ts index 7fb90df..9e764c4 100644 --- a/src/api/export.ts +++ b/src/api/export.ts @@ -8,16 +8,17 @@ import * as ports from 'port-authority'; import { EventEmitter } from 'events'; import { minify_html } from './utils/minify_html'; import { locations } from '../config'; +import * as events from './interfaces'; -export default function exporter(opts: {}) { +export function exporter(opts: {}) { const emitter = new EventEmitter(); execute(emitter, opts).then( () => { - emitter.emit('done', {}); // TODO do we need to pass back any info? + emitter.emit('done', {}); // TODO do we need to pass back any info? }, error => { - emitter.emit('error', { + emitter.emit('error', { error }); } @@ -80,7 +81,7 @@ async function execute(emitter: EventEmitter, { body = minify_html(body); } - emitter.emit('file', { + emitter.emit('file', { file, size: body.length }); @@ -93,7 +94,7 @@ async function execute(emitter: EventEmitter, { const range = ~~(r.status / 100); if (range >= 4) { - emitter.emit('failure', { + emitter.emit('failure', { status: r.status, pathname: url.pathname }); diff --git a/src/api/interfaces.ts b/src/api/interfaces.ts new file mode 100644 index 0000000..9daef0a --- /dev/null +++ b/src/api/interfaces.ts @@ -0,0 +1,40 @@ +export type ReadyEvent = { + port: number; +}; + +export type ErrorEvent = { + type: string; + error: Error; +}; + +export type FatalEvent = { + error: Error; +}; + +export type InvalidEvent = { + changed: string[]; + invalid: { + client: boolean; + server: boolean; + serviceworker: boolean; + } +}; + +export type BuildEvent = { + type: string; + errors: Array<{ message: string, duplicate: boolean }>; + warnings: Array<{ message: string, duplicate: boolean }>; + duration: number; + webpack_stats: any; +} + +export type FileEvent = { + file: string; + size: number; +} + +export type FailureEvent = { + +} + +export type DoneEvent = {} \ No newline at end of file diff --git a/src/cli/build.ts b/src/cli/build.ts index 2311dfc..3d050b8 100644 --- a/src/cli/build.ts +++ b/src/cli/build.ts @@ -1,4 +1,4 @@ -import _build from '../api/build'; +import { build as _build } from '../api/build'; import * as colors from 'ansi-colors'; import { locations } from '../config'; diff --git a/src/cli/dev.ts b/src/cli/dev.ts index 5a95ad7..fbd743b 100644 --- a/src/cli/dev.ts +++ b/src/cli/dev.ts @@ -1,323 +1,67 @@ -import * as fs from 'fs'; import * as path from 'path'; -import * as net from 'net'; import * as colors from 'ansi-colors'; import * as child_process from 'child_process'; -import * as http from 'http'; -import mkdirp from 'mkdirp'; -import rimraf from 'rimraf'; -import format_messages from 'webpack-format-messages'; import prettyMs from 'pretty-ms'; -import * as ports from 'port-authority'; -import { locations } from '../config'; -import { create_compilers, create_main_manifests, create_routes, create_serviceworker_manifest } from '../core'; +import { dev as _dev } from '../api/dev'; +import * as events from '../api/interfaces'; -type Deferred = { - promise?: Promise; - fulfil?: (value?: any) => void; - reject?: (err: Error) => void; -} +export function dev(opts: { port: number, open: boolean }) { + try { + const watcher = _dev(opts); -function deferred() { - const d: Deferred = {}; + let first = true; - d.promise = new Promise((fulfil, reject) => { - d.fulfil = fulfil; - d.reject = reject; - }); - - return d; -} - -function create_hot_update_server(port: number, interval = 10000) { - const clients = new Set(); - - const server = http.createServer((req, res) => { - if (req.url !== '/__sapper__') return; - - req.socket.setKeepAlive(true); - res.writeHead(200, { - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Headers': 'Cache-Control', - 'Content-Type': 'text/event-stream;charset=utf-8', - 'Cache-Control': 'no-cache, no-transform', - 'Connection': 'keep-alive', - // While behind nginx, event stream should not be buffered: - // http://nginx.org/docs/http/ngx_http_proxy_module.html#proxy_buffering - 'X-Accel-Buffering': 'no' - }); - - res.write('\n'); - - clients.add(res); - req.on('close', () => { - clients.delete(res); - }); - }); - - server.listen(port); - - function send(data: any) { - clients.forEach(client => { - client.write(`data: ${JSON.stringify(data)}\n\n`); - }); - } - - setInterval(() => { - send(null) - }, interval); - - return { send }; -} - -export async function dev(opts: { port: number, open: boolean }) { - // remove this in a future version - const template = fs.readFileSync(path.join(locations.app(), 'template.html'), 'utf-8'); - if (template.indexOf('%sapper.base%') === -1) { - console.log(`${colors.bold.red(`> As of Sapper v0.10, your template.html file must include %sapper.base% in the `)}`); - process.exit(1); - } - - process.env.NODE_ENV = 'development'; - - let port = opts.port || +process.env.PORT; - - if (port) { - if (!await ports.check(port)) { - console.log(`${colors.bold.red(`> Port ${port} is unavailable`)}`); - return; - } - } else { - port = await ports.find(3000); - } - - const dir = locations.dest(); - rimraf.sync(dir); - mkdirp.sync(dir); - - const dev_port = await ports.find(10000); - - const routes = create_routes(); - create_main_manifests({ routes, dev_port }); - - const hot_update_server = create_hot_update_server(dev_port); - - watch_files(locations.routes(), ['add', 'unlink'], () => { - const routes = create_routes(); - create_main_manifests({ routes, dev_port }); - }); - - watch_files(`${locations.app()}/template.html`, ['change'], () => { - hot_update_server.send({ - action: 'reload' - }); - }); - - let proc: child_process.ChildProcess; - - process.on('exit', () => { - // sometimes webpack crashes, so we need to kill our children - if (proc) proc.kill(); - }); - - const deferreds = { - server: deferred(), - client: deferred() - }; - - let restarting = false; - let build = { - unique_warnings: new Set(), - unique_errors: new Set() - }; - - function restart_build(filename: string) { - if (restarting) return; - - restarting = true; - build = { - unique_warnings: new Set(), - unique_errors: new Set() - }; - - process.nextTick(() => { - restarting = false; - }); - - console.log(`\n${colors.bold.cyan(path.relative(process.cwd(), filename))} changed. rebuilding...`); - } - - // TODO watch the configs themselves? - const compilers = create_compilers(); - - function watch(compiler: any, { name, invalid = noop, error = noop, result }: { - name: string, - invalid?: (filename: string) => void; - error?: (error: Error) => void; - result: (stats: any) => void; - }) { - compiler.hooks.invalid.tap('sapper', (filename: string) => { - invalid(filename); - }); - - compiler.watch({}, (err: Error, stats: any) => { - if (err) { - console.log(`${colors.red(`✗ ${name}`)}`); - console.log(`${colors.red(err.message)}`); - error(err); - } else { - const messages = format_messages(stats); - const info = stats.toJson(); - - if (messages.errors.length > 0) { - console.log(`${colors.bold.red(`✗ ${name}`)}`); - - const filtered = messages.errors.filter((message: string) => { - return !build.unique_errors.has(message); - }); - - filtered.forEach((message: string) => { - build.unique_errors.add(message); - console.log(message); - }); - - const hidden = messages.errors.length - filtered.length; - if (hidden > 0) { - console.log(`${hidden} duplicate ${hidden === 1 ? 'error' : 'errors'} hidden\n`); - } - } else { - if (messages.warnings.length > 0) { - console.log(`${colors.bold.yellow(`• ${name}`)}`); - - const filtered = messages.warnings.filter((message: string) => { - return !build.unique_warnings.has(message); - }); - - filtered.forEach((message: string) => { - build.unique_warnings.add(message); - console.log(`${message}\n`); - }); - - const hidden = messages.warnings.length - filtered.length; - if (hidden > 0) { - console.log(`${hidden} duplicate ${hidden === 1 ? 'warning' : 'warnings'} hidden\n`); - } - } else { - console.log(`${colors.bold.green(`✔ ${name}`)} ${colors.gray(`(${prettyMs(info.time)})`)}`); - } - - result(info); - } + watcher.on('ready', (event: events.ReadyEvent) => { + if (first) { + console.log(`${colors.bold.cyan(`> Listening on http://localhost:${event.port}`)}`); + if (opts.open) child_process.exec(`open http://localhost:${event.port}`); + first = false; } }); + + watcher.on('invalid', (event: events.InvalidEvent) => { + const changed = event.changed.map(filename => path.relative(process.cwd(), filename)).join(', '); + console.log(`\n${colors.bold.cyan(changed)} changed. rebuilding...`); + }); + + watcher.on('error', (event: events.ErrorEvent) => { + console.log(`${colors.red(`✗ ${event.type}`)}`); + console.log(`${colors.red(event.error.message)}`); + }); + + watcher.on('fatal', (event: events.FatalEvent) => { + console.log(`${colors.bold.red(`> ${event.error.message}`)}`); + }); + + watcher.on('build', (event: events.BuildEvent) => { + if (event.errors.length) { + console.log(`${colors.bold.red(`✗ ${event.type}`)}`); + + event.errors.filter(e => !e.duplicate).forEach(error => { + console.log(error.message); + }); + + const hidden = event.errors.filter(e => e.duplicate).length; + if (hidden > 0) { + console.log(`${hidden} duplicate ${hidden === 1 ? 'error' : 'errors'} hidden\n`); + } + } else if (event.warnings.length) { + console.log(`${colors.bold.yellow(`• ${event.type}`)}`); + + event.warnings.filter(e => !e.duplicate).forEach(warning => { + console.log(warning.message); + }); + + const hidden = event.warnings.filter(e => e.duplicate).length; + if (hidden > 0) { + console.log(`${hidden} duplicate ${hidden === 1 ? 'warning' : 'warnings'} hidden\n`); + } + } else { + console.log(`${colors.bold.green(`✔ ${event.type}`)} ${colors.gray(`(${prettyMs(event.duration)})`)}`); + } + }); + } catch (err) { + console.log(`${colors.bold.red(`> ${err.message}`)}`); + process.exit(1); } - - watch(compilers.server, { - name: 'server', - - invalid: filename => { - restart_build(filename); - // TODO print message - deferreds.server = deferred(); - }, - - result: info => { - // TODO log compile errors/warnings - - fs.writeFileSync(path.join(dir, 'server_info.json'), JSON.stringify(info, null, ' ')); - - deferreds.client.promise.then(() => { - function restart() { - ports.wait(port).then(deferreds.server.fulfil); - } - - if (proc) { - proc.kill(); - proc.on('exit', restart); - } else { - restart(); - } - - proc = child_process.fork(`${dir}/server.js`, [], { - cwd: process.cwd(), - env: Object.assign({ - PORT: port - }, process.env) - }); - }); - } - }); - - let first = true; - - watch(compilers.client, { - name: 'client', - - invalid: filename => { - restart_build(filename); - deferreds.client = deferred(); - - // TODO we should delete old assets. due to a webpack bug - // i don't even begin to comprehend, this is apparently - // quite difficult - }, - - result: info => { - fs.writeFileSync(path.join(dir, 'client_info.json'), JSON.stringify({ - assets: info.assetsByChunkName - }, null, ' ')); - deferreds.client.fulfil(); - - const client_files = info.assets.map((chunk: { name: string }) => `client/${chunk.name}`); - - deferreds.server.promise.then(() => { - hot_update_server.send({ - status: 'completed' - }); - - if (first) { - first = false; - console.log(`${colors.bold.cyan(`> Listening on http://localhost:${port}`)}`); - if (opts.open) child_process.exec(`open http://localhost:${port}`); - } - }); - - create_serviceworker_manifest({ - routes: create_routes(), - client_files - }); - - watch_serviceworker(); - } - }); - - let watch_serviceworker = compilers.serviceworker - ? function() { - watch_serviceworker = noop; - - watch(compilers.serviceworker, { - name: 'service worker', - - result: info => { - fs.writeFileSync(path.join(dir, 'serviceworker_info.json'), JSON.stringify(info, null, ' ')); - } - }); - } - : noop; -} - -function noop() {} - -function watch_files(pattern: string, events: string[], callback: () => void) { - const chokidar = require('chokidar'); - - const watcher = chokidar.watch(pattern, { - persistent: true, - ignoreInitial: true, - disableGlobbing: true - }); - - events.forEach(event => { - watcher.on(event, callback); - }); -} +} \ No newline at end of file diff --git a/src/cli/export.ts b/src/cli/export.ts index 66cc2cb..68445ed 100644 --- a/src/cli/export.ts +++ b/src/cli/export.ts @@ -1,4 +1,4 @@ -import _exporter from '../api/export'; +import { exporter as _exporter } from '../api/export'; import * as colors from 'ansi-colors'; import prettyBytes from 'pretty-bytes'; import { locations } from '../config';