Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement "on demand entries" #1111

Merged
merged 35 commits into from
Feb 26, 2017
Merged
Show file tree
Hide file tree
Changes from 32 commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
b295c24
Add a plan for dynamic entry middleware.
arunoda Feb 13, 2017
3d975ef
Use dynamic pages middleware to load pages in dev.
arunoda Feb 13, 2017
93b0967
Add the first version of middleware but not tested.
arunoda Feb 13, 2017
e237580
Integrated.
arunoda Feb 13, 2017
04754a8
Disable prefetching in development.
arunoda Feb 13, 2017
b2383e1
Build custom document and error always.
arunoda Feb 13, 2017
93fc9d6
Refactor code base.
arunoda Feb 13, 2017
dd5fb13
Change branding as on-demand entries.
arunoda Feb 13, 2017
39219d0
Fix tests.
arunoda Feb 14, 2017
fc0a54f
Add a client side pinger for on-demand-entries.
arunoda Feb 14, 2017
617c33f
Dispose inactive entries.
arunoda Feb 14, 2017
cf0da3f
Add proper logs.
arunoda Feb 14, 2017
f40d425
Update grammer changes.
arunoda Feb 14, 2017
0ab1c28
Resolve conflicts.
arunoda Feb 14, 2017
620e838
Add integration tests for ondemand entries.
arunoda Feb 15, 2017
bea21d5
Improve ondemand entry disposing logic.
arunoda Feb 15, 2017
d32c56a
Try to improve testing.
arunoda Feb 15, 2017
8a495aa
Make sure entries are not getting disposed in basic integration tests.
arunoda Feb 15, 2017
30b6351
Resolve conflicts.
arunoda Feb 15, 2017
fbca518
Resolve conflicts.
arunoda Feb 15, 2017
44d65b2
Fix tests.
arunoda Feb 15, 2017
e5cf79b
Fix issue when running Router.onRouteChangeComplete
arunoda Feb 15, 2017
acaa301
Simplify state management.
arunoda Feb 16, 2017
328cf2e
Conflicts resolved.
arunoda Feb 23, 2017
0256e9d
Make sure we don't dispose the last active page.
arunoda Feb 24, 2017
349579a
Reload invalid pages detected with the client side ping.
arunoda Feb 24, 2017
ddf6a0c
Improve the pinger code.
arunoda Feb 25, 2017
d11c5b3
Touch the first page to speed up the future rebuild times.
arunoda Feb 25, 2017
f706a49
Add Websockets based pinger.
arunoda Feb 25, 2017
a6df1cc
Resolve conflicts on yarn.lock.
arunoda Feb 26, 2017
3166858
Revert "Add Websockets based pinger."
arunoda Feb 26, 2017
291c827
Resolve conflicts.
arunoda Feb 26, 2017
07605a1
Do not send requests per every route change.
arunoda Feb 26, 2017
4278a18
Make sure we are completing the middleware request always.
arunoda Feb 26, 2017
f87f796
Make sure test pages are prebuilt.
arunoda Feb 26, 2017
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions client/on-demand-entries-client.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/* global location */

import Router from '../lib/router'
import fetch from 'unfetch'

// Ping on every page change
const originalOnRouteChangeComplete = Router.onRouteChangeComplete
Router.onRouteChangeComplete = function (...args) {
ping()
if (originalOnRouteChangeComplete) originalOnRouteChangeComplete(...args)
}

async function ping () {
try {
const url = `/on-demand-entries-ping?page=${Router.pathname}`
const res = await fetch(url)
const payload = await res.json()
if (payload.invalid) {
location.reload()
}
} catch (err) {
console.error(`Error with on-demand-entries-ping: ${err.message}`)
}
}

async function runPinger () {
while (true) {
await new Promise((resolve) => setTimeout(resolve, 5000))
await ping()
}
}

runPinger()
.catch((err) => {
console.error(err)
})
4 changes: 4 additions & 0 deletions lib/router/router.js
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,10 @@ export default class Router extends EventEmitter {
}

async prefetch (url) {
// We don't add support for prefetch in the development mode.
// If we do that, our on-demand-entries optimization won't performs better
if (process.env.NODE_ENV === 'development') return
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some other places test with process.env.NODE_ENV !== 'production' instead.

Developers might have forgotten to define process.env.NODE_ENV but still expect to have a development behaviour in that case.

I think it would be nice if all the code tested whether it's in a production mode or not, in a coherent way.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if we could use the dev provided when setting up the app (server side). Not sure if we pass it through to the client side though, will allow for consistent behaviour not based on NODE_ENV.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@sedubois we set NODE_ENV always. See: https://github.com/zeit/next.js/pull/1111/files#diff-0f2f34c098f5954f99483c9bd61e439dR94

We didn't use 'production' check here because, we need to allow prefetching in JEST.
So, this is safe.


const { pathname } = parse(url)
const route = toRoute(pathname)
return this.prefetchQueue.add(() => this.fetchRoute(route))
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@
"source-map-support": "0.4.11",
"strip-ansi": "3.0.1",
"styled-jsx": "0.5.7",
"touch": "1.0.0",
"unfetch": "2.1.0",
"url": "0.11.0",
"uuid": "3.0.1",
Expand Down
24 changes: 16 additions & 8 deletions server/build/webpack.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import WriteFilePlugin from 'write-file-webpack-plugin'
import FriendlyErrorsWebpackPlugin from 'friendly-errors-webpack-plugin'
import CaseSensitivePathPlugin from 'case-sensitive-paths-webpack-plugin'
import UnlinkFilePlugin from './plugins/unlink-file-plugin'
import WatchPagesPlugin from './plugins/watch-pages-plugin'
import JsonPagesPlugin from './plugins/json-pages-plugin'
import getConfig from '../config'
import * as babelCore from 'babel-core'
Expand Down Expand Up @@ -40,8 +39,18 @@ export default async function createCompiler (dir, { dev = false, quiet = false,
const entries = { 'main.js': mainJS }

const pages = await glob('pages/**/*.js', { cwd: dir })
for (const p of pages) {
entries[join('bundles', p)] = [...defaultEntries, `./${p}?entry`]
const devPages = pages.filter((p) => p === 'pages/_document.js' || p === 'pages/_error.js')

// In the dev environment, on-demand-entry-handler will take care of
// managing pages.
if (dev) {
for (const p of devPages) {
entries[join('bundles', p)] = [...defaultEntries, `./${p}?entry`]
}
} else {
for (const p of pages) {
entries[join('bundles', p)] = [...defaultEntries, `./${p}?entry`]
}
}

for (const p of defaultPages) {
Expand Down Expand Up @@ -81,6 +90,9 @@ export default async function createCompiler (dir, { dev = false, quiet = false,
return count >= minChunks
}
}),
new webpack.DefinePlugin({
'process.env.NODE_ENV': JSON.stringify(dev ? 'development' : 'production')
}),
new JsonPagesPlugin(),
new CaseSensitivePathPlugin()
]
Expand All @@ -89,17 +101,13 @@ export default async function createCompiler (dir, { dev = false, quiet = false,
plugins.push(
new webpack.HotModuleReplacementPlugin(),
new webpack.NoEmitOnErrorsPlugin(),
new UnlinkFilePlugin(),
new WatchPagesPlugin(dir)
new UnlinkFilePlugin()
)
if (!quiet) {
plugins.push(new FriendlyErrorsWebpackPlugin())
}
} else {
plugins.push(
new webpack.DefinePlugin({
'process.env.NODE_ENV': JSON.stringify('production')
}),
new webpack.optimize.UglifyJsPlugin({
compress: { warnings: false },
sourceMap: false
Expand Down
16 changes: 15 additions & 1 deletion server/hot-reloader.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { join, relative, sep } from 'path'
import webpackDevMiddleware from 'webpack-dev-middleware'
import webpackHotMiddleware from 'webpack-hot-middleware'
import onDemandEntryHandler from './on-demand-entry-handler'
import isWindowsBash from 'is-windows-bash'
import webpack from './build/webpack'
import clean from './build/clean'
import readPage from './read-page'
import getConfig from './config'

export default class HotReloader {
constructor (dir, { quiet } = {}) {
Expand All @@ -20,6 +22,8 @@ export default class HotReloader {
this.prevChunkNames = null
this.prevFailedChunkNames = null
this.prevChunkHashes = null

this.config = getConfig(dir)
}

async run (req, res) {
Expand Down Expand Up @@ -145,10 +149,16 @@ export default class HotReloader {
})

this.webpackHotMiddleware = webpackHotMiddleware(compiler, { log: false })
this.onDemandEntries = onDemandEntryHandler(this.webpackDevMiddleware, compiler, {
dir: this.dir,
dev: true,
...this.config.onDemandEntries
})

this.middlewares = [
this.webpackDevMiddleware,
this.webpackHotMiddleware
this.webpackHotMiddleware,
this.onDemandEntries.middleware()
]
}

Expand Down Expand Up @@ -184,6 +194,10 @@ export default class HotReloader {
send (action, ...args) {
this.webpackHotMiddleware.publish({ action, data: args })
}

ensurePage (page) {
return this.onDemandEntries.ensurePage(page)
}
}

function deleteCache (path) {
Expand Down
3 changes: 2 additions & 1 deletion server/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,9 @@ export default class Server {
this.dir = resolve(dir)
this.dev = dev
this.quiet = quiet
this.renderOpts = { dir: this.dir, dev, staticMarkup }
this.router = new Router()
this.hotReloader = dev ? new HotReloader(this.dir, { quiet }) : null
this.renderOpts = { dir: this.dir, dev, staticMarkup, hotReloader: this.hotReloader }
this.http = null
this.config = getConfig(this.dir)

Expand Down Expand Up @@ -107,6 +107,7 @@ export default class Server {

const paths = params.path || ['index']
const pathname = `/${paths.join('/')}`

await this.renderJSON(req, res, pathname)
},

Expand Down
184 changes: 184 additions & 0 deletions server/on-demand-entry-handler.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
import DynamicEntryPlugin from 'webpack/lib/DynamicEntryPlugin'
import { EventEmitter } from 'events'
import { join } from 'path'
import { parse } from 'url'
import resolvePath from './resolve'
import touch from 'touch'

const ADDED = Symbol()
const BUILDING = Symbol()
const BUILT = Symbol()

export default function onDemandEntryHandler (devMiddleware, compiler, {
dir,
dev,
maxInactiveAge = 1000 * 25
}) {
const entries = {}
const lastAccessPages = ['']
const doneCallbacks = new EventEmitter()
let touchedAPage = false

compiler.plugin('make', function (compilation, done) {
const allEntries = Object.keys(entries).map((page) => {
const { name, entry } = entries[page]
entries[page].status = BUILDING
return addEntry(compilation, this.context, name, entry)
})

Promise.all(allEntries)
.then(() => done())
.catch(done)
})

compiler.plugin('done', function (stats) {
// Call all the doneCallbacks
Object.keys(entries).forEach((page) => {
const entryInfo = entries[page]
if (entryInfo.status !== BUILDING) return

// With this, we are triggering a filesystem based watch trigger
// It'll memorize some timestamp related info related to common files used
// in the page
// That'll reduce the page building time significantly.
if (!touchedAPage) {
setTimeout(() => {
touch.sync(entryInfo.pathname)
}, 0)
touchedAPage = true
}

entryInfo.status = BUILT
entries[page].lastActiveTime = Date.now()
doneCallbacks.emit(page)
})
})

setInterval(function () {
disposeInactiveEntries(devMiddleware, entries, lastAccessPages, maxInactiveAge)
}, 5000)

return {
async ensurePage (page) {
page = normalizePage(page)

const pagePath = join(dir, 'pages', page)
const pathname = await resolvePath(pagePath)
const name = join('bundles', pathname.substring(dir.length))

const entry = [
join(__dirname, '..', 'client/webpack-hot-middleware-client'),
join(__dirname, '..', 'client', 'on-demand-entries-client'),
`${pathname}?entry`
]

await new Promise((resolve, reject) => {
const entryInfo = entries[page]

if (entryInfo) {
if (entryInfo.status === BUILT) {
resolve()
return
}

if (entryInfo.status === BUILDING) {
doneCallbacks.on(page, processCallback)
return
}
}

console.log(`> Building page: ${page}`)

entries[page] = { name, entry, pathname, status: ADDED }
doneCallbacks.on(page, processCallback)

devMiddleware.invalidate()

function processCallback (err) {
if (err) return reject(err)
resolve()
}
})
},

middleware () {
return function (req, res, next) {
if (!/^\/on-demand-entries-ping/.test(req.url)) return next()

const { query } = parse(req.url, true)
const page = normalizePage(query.page)
const entryInfo = entries[page]

// If there's no entry.
// Then it seems like an weird issue.
if (!entryInfo) {
const message = `Client pings, but there's no entry for page: ${page}`
console.error(message)
sendJson(res, { invalid: true })
return
}

// We don't need to maintain active state of anything other than BUILT entries
if (entryInfo.status !== BUILT) return

// If there's an entryInfo
lastAccessPages.pop()
lastAccessPages.unshift(page)
entryInfo.lastActiveTime = Date.now()

sendJson(res, { success: true })
}
}
}
}

function addEntry (compilation, context, name, entry) {
return new Promise((resolve, reject) => {
const dep = DynamicEntryPlugin.createDependency(entry, name)
compilation.addEntry(context, dep, name, (err) => {
if (err) return reject(err)
resolve()
})
Copy link
Contributor

@nkzawa nkzawa Feb 14, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should not use private plugins.
Webpack v2 supports dynamic entries by setting a function to the entry option. So what we'd like to do is to change returned entries according to page requests. I think we can do that with an optional entry function on server/build/webpack.js.

Copy link
Contributor Author

@arunoda arunoda Feb 14, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is not a private function. It's documented by webpack.

Anyway, I'll try to use dynamic functions in the refactoring stage.
For now, this seems like the best option for now as we do more stuff related to entry management.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I meant about plugins 'webpack/lib/DynamicEntryPlugin'. I'd like to fix that in this PR if possible or I will do it later.

})
}

function disposeInactiveEntries (devMiddleware, entries, lastAccessPages, maxInactiveAge) {
const disposingPages = []

Object.keys(entries).forEach((page) => {
const { lastActiveTime, status } = entries[page]

// This means this entry is currently building or just added
// We don't need to dispose those entries.
if (status !== BUILT) return

// We should not build the last accessed page even we didn't get any pings
// Sometimes, it's possible our XHR ping to wait before completing other requests.
// In that case, we should not dispose the current viewing page
if (lastAccessPages[0] === page) return

if (Date.now() - lastActiveTime > maxInactiveAge) {
disposingPages.push(page)
}
})

if (disposingPages.length > 0) {
disposingPages.forEach((page) => {
delete entries[page]
})
console.log(`> Disposing inactive page(s): ${disposingPages.join(', ')}`)
devMiddleware.invalidate()
}
}

// /index and / is the same. So, we need to identify both pages as the same.
// This also applies to sub pages as well.
function normalizePage (page) {
return page.replace(/\/index$/, '/')
}

function sendJson (res, payload) {
res.setHeader('Content-Type', 'application/json')
res.status = 200
res.end(JSON.stringify(payload))
}
Loading