hot-reload: initial

This commit is contained in:
nkzawa 2016-10-15 00:05:08 +09:00
parent facd19b5a7
commit 26e6193a97
16 changed files with 285 additions and 30 deletions

View file

@ -26,6 +26,6 @@ const bin = resolve(__dirname, 'next-' + cmd)
const proc = spawn(bin, args, { stdio: 'inherit', customFds: [0, 1, 2] })
proc.on('close', (code) => process.exit(code))
proc.on('error', (err) => {
console.log(err)
console.error(err)
process.exit(1)
})

View file

@ -4,6 +4,8 @@ import { resolve } from 'path'
import parseArgs from 'minimist'
import Server from '../server'
import build from '../server/build'
import HotReloader from '../server/hot-reloader'
import webpack from '../server/build/webpack'
const argv = parseArgs(process.argv.slice(2), {
alias: {
@ -20,7 +22,9 @@ const dir = resolve(argv._[0] || '.')
build(dir)
.then(async () => {
const srv = new Server({ dir, dev: true })
const compiler = await webpack(dir, { hotReload: true })
const hotReloader = new HotReloader(compiler)
const srv = new Server({ dir, dev: true, hotReloader })
await srv.start(argv.port)
console.log('> Ready on http://localhost:%d', argv.port);
})

View file

@ -1 +1,7 @@
import './next'
import 'react-hot-loader/patch'
import 'webpack-dev-server/client?http://localhost:3030'
import * as next from './next'
module.exports = next
window.next = next

View file

@ -13,10 +13,11 @@ const {
const App = app ? evalScript(app).default : DefaultApp
const Component = evalScript(component).default
const router = new Router(location.href, { Component })
export const router = new Router(location.href, { Component })
const headManager = new HeadManager()
const container = document.getElementById('__next')
const appProps = { Component, props, router, headManager }
StyleSheet.rehydrate(classNames)
render(createElement(App, { ...appProps }), container)
render(createElement(App, appProps), container)

View file

@ -65,6 +65,16 @@ gulp.task('compile-test', () => {
.pipe(notify('Compiled test files'))
})
gulp.task('copy', [
'copy-pages',
'copy-test-fixtures'
]);
gulp.task('copy-pages', () => {
return gulp.src('pages/**/*.js')
.pipe(gulp.dest('dist/pages'))
})
gulp.task('copy-test-fixtures', () => {
return gulp.src('test/fixtures/**/*')
.pipe(gulp.dest('dist/test/fixtures'))
@ -83,7 +93,21 @@ gulp.task('build-dev-client', ['compile-lib', 'compile-client'], () => {
.src('dist/client/next-dev.js')
.pipe(webpack({
quiet: true,
output: { filename: 'next-dev.bundle.js' }
output: { filename: 'next-dev.bundle.js' },
module: {
loaders: [
{
test: /eval-script\.js$/,
exclude: /node_modules/,
loader: 'babel',
query: {
plugins: [
'babel-plugin-transform-remove-strict-mode'
]
}
}
]
},
}))
.pipe(gulp.dest('dist/client'))
.pipe(notify('Built dev client'))
@ -102,7 +126,21 @@ gulp.task('build-release-client', ['compile-lib', 'compile-client'], () => {
}
}),
new webpack.webpack.optimize.UglifyJsPlugin()
],
module: {
loaders: [
{
test: /eval-script\.js$/,
exclude: /node_modules/,
loader: 'babel',
query: {
plugins: [
'babel-plugin-transform-remove-strict-mode'
]
}
}
]
}
}))
.pipe(gulp.dest('dist/client'))
.pipe(notify('Built release client'))
@ -157,6 +195,7 @@ gulp.task('clean-test', () => {
gulp.task('default', [
'compile',
'build',
'copy',
'test',
'watch'
])
@ -165,6 +204,7 @@ gulp.task('release', (cb) => {
sequence('clean', [
'compile',
'build-release',
'copy',
'test'
], 'clean-test', cb)
})

View file

@ -1,4 +1,5 @@
import React, { Component, PropTypes } from 'react'
import { AppContainer } from 'react-hot-loader'
export default class App extends Component {
static childContextTypes = {
@ -28,6 +29,7 @@ export default class App extends Component {
const props = data.props || this.state.props
const state = propsToState({
...data,
props,
router
})
@ -50,7 +52,15 @@ export default class App extends Component {
render () {
const { Component, props } = this.state
return React.createElement(Component, { ...props })
if ('undefined' === typeof window) {
// workaround for https://github.com/gaearon/react-hot-loader/issues/283
return <Component { ...props }/>
}
return <AppContainer>
<Component { ...props }/>
</AppContainer>
}
}

View file

@ -36,13 +36,16 @@
"path-match": "1.2.4",
"react": "15.3.2",
"react-dom": "15.3.2",
"react-hot-loader": "3.0.0-beta.6",
"resolve": "1.1.7",
"run-sequence": "1.2.2",
"send": "0.14.1",
"url": "0.11.0",
"webpack": "1.13.2"
"webpack": "1.13.2",
"webpack-dev-server": "1.16.2"
},
"devDependencies": {
"babel-plugin-transform-remove-strict-mode": "0.0.2",
"del": "2.2.2",
"gulp": "3.9.1",
"gulp-ava": "0.14.1",

View file

@ -5,7 +5,7 @@ import bundle from './bundle'
export default async function build (dir) {
const dstDir = resolve(dir, '.next')
const templateDir = resolve(__dirname, '..', '..', 'lib', 'pages')
const templateDir = resolve(__dirname, '..', '..', 'pages')
// create `.next/pages/_error.js`
// which may be overwriten by the user sciprt, `pages/_error.js`

105
server/build/webpack.js Normal file
View file

@ -0,0 +1,105 @@
import { resolve } from 'path'
import webpack from 'webpack'
import glob from 'glob-promise'
export default async function createCompiler(dir, { hotReload = false } = {}) {
const pages = await glob('**/*.js', { cwd: resolve(dir, 'pages') })
const entry = {}
const defaultEntries = hotReload ? ['webpack/hot/only-dev-server'] : []
for (const p of pages) {
entry[p] = defaultEntries.concat(['./pages/' + p])
}
if (!entry['_error.js']) {
entry._error = resolve(__dirname, '..', '..', 'pages', '_error.js')
}
const nodeModulesDir = resolve(__dirname, '..', '..', '..', 'node_modules')
const plugins = hotReload
? [new webpack.HotModuleReplacementPlugin()]
: [
new webpack.optimize.UglifyJsPlugin({
compress: { warnings: false },
sourceMap: false
})
]
const babelRuntimePath = require.resolve('babel-runtime/package')
.replace(/[\\\/]package\.json$/, '');
const loaders = [{
test: /\.js$/,
loader: 'babel',
include: [
resolve(dir),
resolve(__dirname, '..', '..', 'pages')
],
exclude: /node_modules/,
query: {
presets: ['es2015', 'react'],
plugins: [
'transform-async-to-generator',
'transform-object-rest-spread',
'transform-class-properties',
'transform-runtime',
[
'module-alias',
[
{ src: `npm:${babelRuntimePath}`, expose: 'babel-runtime' },
{ src: `npm:${require.resolve('react')}`, expose: 'react' },
{ src: `npm:${require.resolve('../../lib/link')}`, expose: 'next/link' },
{ src: `npm:${require.resolve('../../lib/css')}`, expose: 'next/css' },
{ src: `npm:${require.resolve('../../lib/head')}`, expose: 'next/head' }
]
]
]
}
}]
.concat(hotReload ? [{
test: /\.js$/,
loader: 'hot-self-accept-loader',
include: resolve(dir, 'pages')
}] : [])
return webpack({
context: dir,
entry,
output: {
path: resolve(dir, '.next', '_bundles', 'pages'),
filename: '[name]',
libraryTarget: 'commonjs2',
publicPath: hotReload ? 'http://localhost:3030/' : null
},
externals: [
'react',
'react-dom',
{
[require.resolve('react')]: 'react',
[require.resolve('../../lib/link')]: 'next/link',
[require.resolve('../../lib/css')]: 'next/css',
[require.resolve('../../lib/head')]: 'next/head'
}
],
resolve: {
root: [
nodeModulesDir,
resolve(dir, 'node_modules')
]
},
resolveLoader: {
root: [
nodeModulesDir,
resolve(__dirname, '..', 'loaders')
]
},
plugins,
module: {
preLoaders: [
{ test: /\.json$/, loader: 'json-loader' }
],
loaders
}
})
}

44
server/hot-reloader.js Normal file
View file

@ -0,0 +1,44 @@
import WebpackDevServer from 'webpack-dev-server'
export default class HotReloader {
constructor (compiler) {
this.server = new WebpackDevServer(compiler, {
publicPath: '/',
hot: true,
noInfo: true,
clientLogLevel: 'warning'
})
}
async start () {
await this.waitBuild()
await this.listen()
}
async waitBuild () {
const stats = await new Promise((resolve) => {
this.server.middleware.waitUntilValid(resolve)
})
const jsonStats = stats.toJson()
if (jsonStats.errors.length > 0) {
const err = new Error(jsonStats.errors[0])
err.errors = jsonStats.errors
err.warnings = jsonStats.warnings
throw err
}
}
listen () {
return new Promise((resolve, reject) => {
this.server.listen(3030, (err) => {
if (err) return reject(err)
resolve()
})
})
}
get fileSystem () {
return this.server.middleware.fileSystem
}
}

View file

@ -3,11 +3,13 @@ import { resolve } from 'path'
import send from 'send'
import Router from './router'
import { render, renderJSON } from './render'
import HotReloader from './hot-reloader'
export default class Server {
constructor ({ dir = '.', dev = false }) {
constructor ({ dir = '.', dev = false, hotReloader }) {
this.dir = resolve(dir)
this.dev = dev
this.hotReloader = hotReloader
this.router = new Router()
this.http = http.createServer((req, res) => {
@ -39,6 +41,10 @@ export default class Server {
await this.render(req, res)
})
if (this.hotReloader) {
await this.hotReloader.start()
}
await new Promise((resolve, reject) => {
this.http.listen(port, (err) => {
if (err) return reject(err)
@ -57,10 +63,11 @@ export default class Server {
}
async render (req, res) {
const { dir, dev } = this
const { dir, dev, hotReloader } = this
const mfs = hotReloader ? hotReloader.fileSystem : null
let html
try {
html = await render(req.url, { req, res }, { dir, dev })
html = await render(req.url, { req, res }, { dir, dev, mfs })
} catch (err) {
if ('ENOENT' === err.code) {
res.statusCode = 404
@ -68,17 +75,18 @@ export default class Server {
console.error(err)
res.statusCode = 500
}
html = await render('/_error', { req, res, err }, { dir, dev })
html = await render('/_error', { req, res, err }, { dir, dev, mfs })
}
sendHTML(res, html)
}
async renderJSON (req, res) {
const { dir } = this
const { dir, hotReloader } = this
const mfs = hotReloader ? hotReloader.fileSystem : null
let json
try {
json = await renderJSON(req.url, { dir })
json = await renderJSON(req.url, { dir, mfs })
} catch (err) {
if ('ENOENT' === err.code) {
res.statusCode = 404
@ -86,7 +94,7 @@ export default class Server {
console.error(err)
res.statusCode = 500
}
json = await renderJSON('/_error.json', { dir })
json = await renderJSON('/_error.json', { dir, mfs })
}
const data = JSON.stringify(json)

View file

@ -0,0 +1,14 @@
module.exports = function (content) {
this.cacheable()
return content + `
if (module.hot) {
module.hot.accept()
if ('idle' !== module.hot.status()) {
const Component = module.exports.default || module.exports
next.router.notify({ Component })
}
}
`
}

View file

@ -8,14 +8,34 @@ const cache = {}
* and read and cache the file content
*/
async function read (path) {
const f = await resolve(path)
async function read (path, { mfs }) {
const f = await (mfs ? resolveFromMFS(path, mfs) : resolve(path))
if (mfs) {
return mfs.readFileSync(f, 'utf8')
} else {
let promise = cache[f]
if (!promise) {
promise = cache[f] = fs.readFile(f, 'utf8')
}
return promise
}
}
function resolveFromMFS (path, mfs) {
const isFile = (file, cb) => {
if (!mfs.existsSync(file)) return cb(null, false)
let stat
try {
stat = mfs.statSync(file)
} catch (err) {
return cb(err)
}
cb(null, stat.isFile() || stat.isFIFO())
}
const readFile = mfs.readFile.bind(mfs)
return resolve(path, { isFile, readFile })
}
module.exports = read

View file

@ -14,7 +14,8 @@ import { StyleSheetServer } from '../lib/css'
export async function render (url, ctx = {}, {
dir = process.cwd(),
dev = false,
staticMarkup = false
staticMarkup = false,
mfs
} = {}) {
const path = getPath(url)
const p = await requireResolve(resolve(dir, '.next', 'pages', path))
@ -22,7 +23,7 @@ export async function render (url, ctx = {}, {
const Component = mod.default || mod
const props = await (Component.getInitialProps ? Component.getInitialProps(ctx) : {})
const component = await read(resolve(dir, '.next', '_bundles', 'pages', path))
const component = await read(resolve(dir, '.next', '_bundles', 'pages', path), { mfs })
const { html, css } = StyleSheetServer.renderStatic(() => {
const app = createElement(App, {
@ -53,9 +54,9 @@ export async function render (url, ctx = {}, {
return '<!DOCTYPE html>' + renderToStaticMarkup(doc)
}
export async function renderJSON (url, { dir = process.cwd() } = {}) {
export async function renderJSON (url, { dir = process.cwd(), mfs } = {}) {
const path = getPath(url)
const component = await read(resolve(dir, '.next', '_bundles', 'pages', path))
const component = await read(resolve(dir, '.next', '_bundles', 'pages', path), { mfs })
return { component }
}

View file

@ -4,9 +4,8 @@ export default function resolve (id, opts) {
return new Promise((resolve, reject) => {
_resolve(id, opts, (err, path) => {
if (err) {
const e = new Error(err)
e.code = 'ENOENT'
return reject(e)
err.code = 'ENOENT'
return reject(err)
}
resolve(path)
})