2017-02-26 20:45:16 +01:00
import DynamicEntryPlugin from 'webpack/lib/DynamicEntryPlugin'
import { EventEmitter } from 'events'
2019-03-19 04:24:21 +01:00
import { join , posix } from 'path'
2019-02-19 22:45:07 +01:00
import { parse } from 'url'
import { pageNotFoundError } from 'next-server/dist/server/require'
import { normalizePagePath } from 'next-server/dist/server/normalize-page-path'
2018-10-02 00:55:31 +02:00
import { ROUTE _NAME _REGEX , IS _BUNDLED _PAGE _REGEX } from 'next-server/constants'
2019-02-19 22:45:07 +01:00
import { stringify } from 'querystring'
2019-02-24 22:08:35 +01:00
import { findPageFile } from './lib/find-page-file'
import { isWriteable } from '../build/is-writeable'
2017-02-26 20:45:16 +01:00
2017-03-03 01:17:41 +01:00
const ADDED = Symbol ( 'added' )
const BUILDING = Symbol ( 'building' )
const BUILT = Symbol ( 'built' )
2017-02-26 20:45:16 +01:00
2018-09-16 16:06:02 +02:00
// Based on https://github.com/webpack/webpack/blob/master/lib/DynamicEntryPlugin.js#L29-L37
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 ( )
} )
} )
}
export default function onDemandEntryHandler ( devMiddleware , multiCompiler , {
2018-07-25 13:45:42 +02:00
buildId ,
2017-02-26 20:45:16 +01:00
dir ,
2017-06-07 00:32:02 +02:00
reload ,
2018-02-14 16:20:41 +01:00
pageExtensions ,
2019-01-01 01:07:10 +01:00
maxInactiveAge ,
2019-02-19 21:58:47 +01:00
pagesBufferLength
2017-02-26 20:45:16 +01:00
} ) {
2019-02-24 22:08:35 +01:00
const pagesDir = join ( dir , 'pages' )
2019-03-02 23:51:14 +01:00
const clients = new Map ( )
2019-02-19 21:58:47 +01:00
const evtSourceHeaders = {
'Access-Control-Allow-Origin' : '*' ,
'Content-Type' : 'text/event-stream;charset=utf-8' ,
'Cache-Control' : 'no-cache, no-transform' ,
// 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' ,
'Connection' : 'keep-alive'
}
2019-02-19 22:45:07 +01:00
const { compilers } = multiCompiler
2018-09-16 16:06:02 +02:00
const invalidator = new Invalidator ( devMiddleware , multiCompiler )
2017-06-07 00:32:02 +02:00
let entries = { }
let lastAccessPages = [ '' ]
let doneCallbacks = new EventEmitter ( )
let reloading = false
let stopped = false
let reloadCallbacks = new EventEmitter ( )
2018-01-30 16:40:52 +01:00
2018-09-16 16:06:02 +02:00
for ( const compiler of compilers ) {
compiler . hooks . make . tapPromise ( 'NextJsOnDemandEntries' , ( compilation ) => {
2018-01-30 16:40:52 +01:00
invalidator . startBuilding ( )
2018-07-24 11:24:40 +02:00
const allEntries = Object . keys ( entries ) . map ( async ( page ) => {
2019-01-08 23:10:32 +01:00
const { name , absolutePagePath } = entries [ page ]
2019-02-24 22:08:35 +01:00
const pageExists = await isWriteable ( absolutePagePath )
if ( ! pageExists ) {
2019-01-08 23:10:32 +01:00
console . warn ( 'Page was removed' , page )
delete entries [ page ]
return
2018-07-24 11:24:40 +02:00
}
2019-01-08 23:10:32 +01:00
2018-01-30 16:40:52 +01:00
entries [ page ] . status = BUILDING
2019-02-19 22:45:07 +01:00
return addEntry ( compilation , compiler . context , name , [ compiler . name === 'client' ? ` next-client-pages-loader? ${ stringify ( { page , absolutePagePath } )}! ` : absolutePagePath ] )
2018-01-30 16:40:52 +01:00
} )
2017-02-26 20:45:16 +01:00
2018-09-16 16:06:02 +02:00
return Promise . all ( allEntries )
2017-02-26 20:45:16 +01:00
} )
2018-09-16 16:06:02 +02:00
}
2017-02-26 20:45:16 +01:00
2018-09-16 16:06:02 +02:00
multiCompiler . hooks . done . tap ( 'NextJsOnDemandEntries' , ( multiStats ) => {
const clientStats = multiStats . stats [ 0 ]
const { compilation } = clientStats
const hardFailedPages = compilation . errors
. filter ( e => {
// Make sure to only pick errors which marked with missing modules
const hasNoModuleFoundError = /ENOENT/ . test ( e . message ) || /Module not found/ . test ( e . message )
if ( ! hasNoModuleFoundError ) return false
// The page itself is missing. So this is a failed page.
if ( IS _BUNDLED _PAGE _REGEX . test ( e . module . name ) ) return true
// No dependencies means this is a top level page.
// So this is a failed page.
return e . module . dependencies . length === 0
} )
. map ( e => e . module . chunks )
. reduce ( ( a , b ) => [ ... a , ... b ] , [ ] )
. map ( c => {
const pageName = ROUTE _NAME _REGEX . exec ( c . name ) [ 1 ]
return normalizePage ( ` / ${ pageName } ` )
} )
2017-02-26 20:45:16 +01:00
2018-09-16 16:06:02 +02:00
// compilation.entrypoints is a Map object, so iterating over it 0 is the key and 1 is the value
for ( const [ , entrypoint ] of compilation . entrypoints . entries ( ) ) {
const result = ROUTE _NAME _REGEX . exec ( entrypoint . name )
if ( ! result ) {
continue
}
2017-06-07 00:32:02 +02:00
2018-09-16 16:06:02 +02:00
const pagePath = result [ 1 ]
2017-06-07 00:32:02 +02:00
2018-09-16 16:06:02 +02:00
if ( ! pagePath ) {
continue
}
2018-01-30 16:40:52 +01:00
2018-09-16 16:06:02 +02:00
const page = normalizePage ( '/' + pagePath )
2018-01-30 16:40:52 +01:00
2018-09-16 16:06:02 +02:00
const entry = entries [ page ]
if ( ! entry ) {
continue
}
2017-06-07 00:32:02 +02:00
2018-09-16 16:06:02 +02:00
if ( entry . status !== BUILDING ) {
continue
2017-02-26 20:45:16 +01:00
}
2018-09-16 16:06:02 +02:00
entry . status = BUILT
entry . lastActiveTime = Date . now ( )
doneCallbacks . emit ( page )
}
invalidator . doneBuilding ( )
if ( hardFailedPages . length > 0 && ! reloading ) {
console . log ( ` > Reloading webpack due to inconsistant state of pages(s): ${ hardFailedPages . join ( ', ' ) } ` )
reloading = true
reload ( )
. then ( ( ) => {
console . log ( '> Webpack reloaded.' )
reloadCallbacks . emit ( 'done' )
stop ( )
} )
. catch ( err => {
console . error ( ` > Webpack reloading failed: ${ err . message } ` )
console . error ( err . stack )
process . exit ( 1 )
} )
}
2017-02-26 20:45:16 +01:00
} )
2017-06-07 00:32:02 +02:00
const disposeHandler = setInterval ( function ( ) {
if ( stopped ) return
2017-02-26 20:45:16 +01:00
disposeInactiveEntries ( devMiddleware , entries , lastAccessPages , maxInactiveAge )
} , 5000 )
2018-01-31 12:37:41 +01:00
disposeHandler . unref ( )
2017-06-07 00:32:02 +02:00
function stop ( ) {
2019-03-02 23:51:14 +01:00
clients . forEach ( ( id , client ) => client . end ( ) )
2017-06-07 00:32:02 +02:00
clearInterval ( disposeHandler )
stopped = true
doneCallbacks = null
reloadCallbacks = null
}
2019-02-19 21:58:47 +01:00
function handlePing ( pg ) {
2019-02-15 22:22:21 +01:00
const page = normalizePage ( pg )
const entryInfo = entries [ page ]
2019-02-19 21:58:47 +01:00
let toSend
2019-02-15 22:22:21 +01:00
// 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 )
2019-02-19 21:58:47 +01:00
return { invalid : true }
2019-02-15 22:22:21 +01:00
}
// 404 is an on demand entry but when a new page is added we have to refresh the page
if ( page === '/_error' ) {
2019-02-19 21:58:47 +01:00
toSend = { invalid : true }
2019-02-15 22:22:21 +01:00
} else {
2019-02-19 21:58:47 +01:00
toSend = { success : true }
2019-02-15 22:22:21 +01:00
}
// We don't need to maintain active state of anything other than BUILT entries
if ( entryInfo . status !== BUILT ) return
// If there's an entryInfo
if ( ! lastAccessPages . includes ( page ) ) {
lastAccessPages . unshift ( page )
// Maintain the buffer max length
if ( lastAccessPages . length > pagesBufferLength ) {
lastAccessPages . pop ( )
}
}
entryInfo . lastActiveTime = Date . now ( )
2019-02-19 21:58:47 +01:00
return toSend
2019-02-15 22:22:21 +01:00
}
2017-02-26 20:45:16 +01:00
return {
2017-06-07 00:32:02 +02:00
waitUntilReloaded ( ) {
if ( ! reloading ) return Promise . resolve ( true )
return new Promise ( ( resolve ) => {
reloadCallbacks . once ( 'done' , function ( ) {
resolve ( )
} )
} )
} ,
2019-03-19 04:24:21 +01:00
async ensurePage ( page , amp , ampEnabled ) {
2017-06-07 00:32:02 +02:00
await this . waitUntilReloaded ( )
2018-02-14 16:20:41 +01:00
let normalizedPagePath
try {
normalizedPagePath = normalizePagePath ( page )
} catch ( err ) {
console . error ( err )
throw pageNotFoundError ( normalizedPagePath )
}
2019-03-19 04:24:21 +01:00
let pagePath = await findPageFile ( pagesDir , normalizedPagePath , pageExtensions , amp , ampEnabled )
const isAmp = pagePath && pageExtensions . some ( ext => pagePath . endsWith ( 'amp.' + ext ) )
2019-02-03 15:34:28 +01:00
// Default the /_error route to the Next.js provided default page
2019-02-24 22:08:35 +01:00
if ( page === '/_error' && pagePath === null ) {
pagePath = 'next/dist/pages/_error'
2019-02-03 15:34:28 +01:00
}
2018-02-14 16:20:41 +01:00
2019-02-24 22:08:35 +01:00
if ( pagePath === null ) {
2018-02-14 16:20:41 +01:00
throw pageNotFoundError ( normalizedPagePath )
}
2019-02-24 22:08:35 +01:00
let pageUrl = ` / ${ pagePath . replace ( new RegExp ( ` \\ .+(?: ${ pageExtensions . join ( '|' ) } ) $ ` ) , '' ) . replace ( /\\/g , '/' ) } ` . replace ( /\/index$/ , '' )
2019-01-08 23:10:32 +01:00
pageUrl = pageUrl === '' ? '/' : pageUrl
const bundleFile = pageUrl === '/' ? '/index.js' : ` ${ pageUrl } .js `
const name = join ( 'static' , buildId , 'pages' , bundleFile )
2019-02-03 15:34:28 +01:00
const absolutePagePath = pagePath . startsWith ( 'next/dist/pages' ) ? require . resolve ( pagePath ) : join ( pagesDir , pagePath )
2017-02-26 20:45:16 +01:00
2019-03-19 04:24:21 +01:00
page = posix . normalize ( pageUrl )
2019-03-19 20:01:42 +01:00
const result = {
isAmp ,
pathname : page ,
hasAmp : ! isAmp && await findPageFile ( pagesDir , normalizedPagePath , pageExtensions , ! isAmp , ampEnabled )
}
2019-03-19 04:24:21 +01:00
2017-02-26 20:45:16 +01:00
await new Promise ( ( resolve , reject ) => {
2019-02-26 15:57:45 +01:00
// Makes sure the page that is being kept in on-demand-entries matches the webpack output
const normalizedPage = normalizePage ( page )
const entryInfo = entries [ normalizedPage ]
2017-02-26 20:45:16 +01:00
if ( entryInfo ) {
if ( entryInfo . status === BUILT ) {
resolve ( )
return
}
if ( entryInfo . status === BUILDING ) {
2019-02-26 15:57:45 +01:00
doneCallbacks . once ( normalizedPage , handleCallback )
2017-02-26 20:45:16 +01:00
return
}
}
2019-02-26 15:57:45 +01:00
console . log ( ` > Building page: ${ normalizedPage } ` )
2017-02-26 20:45:16 +01:00
2019-02-26 15:57:45 +01:00
entries [ normalizedPage ] = { name , absolutePagePath , status : ADDED }
doneCallbacks . once ( normalizedPage , handleCallback )
2017-02-26 20:45:16 +01:00
2017-02-27 21:05:10 +01:00
invalidator . invalidate ( )
2017-02-26 20:45:16 +01:00
2018-01-30 16:40:52 +01:00
function handleCallback ( err ) {
2017-02-26 20:45:16 +01:00
if ( err ) return reject ( err )
resolve ( )
}
} )
2019-03-19 04:24:21 +01:00
return result
2017-02-26 20:45:16 +01:00
} ,
middleware ( ) {
2017-06-07 00:32:02 +02:00
return ( req , res , next ) => {
if ( stopped ) {
// If this handler is stopped, we need to reload the user's browser.
// So the user could connect to the actually running handler.
res . statusCode = 302
res . setHeader ( 'Location' , req . url )
res . end ( '302' )
} else if ( reloading ) {
// Webpack config is reloading. So, we need to wait until it's done and
// reload user's browser.
// So the user could connect to the new handler and webpack setup.
this . waitUntilReloaded ( )
. then ( ( ) => {
res . statusCode = 302
res . setHeader ( 'Location' , req . url )
res . end ( '302' )
} )
} else {
if ( ! /^\/_next\/on-demand-entries-ping/ . test ( req . url ) ) return next ( )
2019-02-15 22:22:21 +01:00
const { query } = parse ( req . url , true )
2019-02-19 21:58:47 +01:00
const page = query . page
if ( ! page ) return next ( )
// Upgrade request to EventSource
req . socket . setKeepAlive ( true )
res . writeHead ( 200 , evtSourceHeaders )
res . write ( '\n' )
2019-03-02 23:51:14 +01:00
const startId = req . headers [ 'user-agent' ] + req . connection . remoteAddress
let clientId = startId
let numSameClient = 0
while ( clients . has ( clientId ) ) {
numSameClient ++
clientId = startId + numSameClient
}
if ( numSameClient > 1 ) {
// If the user has too many tabs with Next.js open in the same browser,
// they might be exceeding the max number of concurrent request.
// This varies per browser so we can only guess if this is the cause of
// a slow request and show a warning that this might be why
console . warn ( ` \n Warn: You are opening multiple tabs of the same site in the same browser, this could cause requests to stall. https://err.sh/zeit/next.js/multi-tabs ` )
}
clients . set ( clientId , res )
2019-02-19 21:58:47 +01:00
const runPing = ( ) => {
const data = handlePing ( query . page )
2019-02-22 10:22:23 +01:00
if ( ! data ) return
2019-02-19 21:58:47 +01:00
res . write ( 'data: ' + JSON . stringify ( data ) + '\n\n' )
2019-02-15 22:22:21 +01:00
}
2019-02-19 21:58:47 +01:00
const pingInterval = setInterval ( ( ) => runPing ( ) , 5000 )
req . on ( 'close' , ( ) => {
2019-03-02 23:51:14 +01:00
clients . delete ( clientId )
2019-02-19 21:58:47 +01:00
clearInterval ( pingInterval )
} )
// Do initial ping right after EventSource is finished being set up
runPing ( )
2017-06-07 00:32:02 +02:00
}
2017-02-26 20:45:16 +01:00
}
}
}
}
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
2017-09-28 14:51:03 +02:00
if ( lastAccessPages . includes ( page ) ) return
2017-02-26 20:45:16 +01:00
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.
2018-07-24 11:24:40 +02:00
export function normalizePage ( page ) {
2018-09-16 16:06:02 +02:00
const unixPagePath = page . replace ( /\\/g , '/' )
if ( unixPagePath === '/index' || unixPagePath === '/' ) {
2018-07-24 11:24:40 +02:00
return '/'
}
2018-09-16 16:06:02 +02:00
return unixPagePath . replace ( /\/index$/ , '' )
2017-02-26 20:45:16 +01:00
}
2017-02-27 21:05:10 +01:00
// Make sure only one invalidation happens at a time
// Otherwise, webpack hash gets changed and it'll force the client to reload.
class Invalidator {
2018-09-16 16:06:02 +02:00
constructor ( devMiddleware , multiCompiler ) {
this . multiCompiler = multiCompiler
2017-02-27 21:05:10 +01:00
this . devMiddleware = devMiddleware
2018-01-30 16:40:52 +01:00
// contains an array of types of compilers currently building
2017-02-27 21:05:10 +01:00
this . building = false
this . rebuildAgain = false
}
invalidate ( ) {
// If there's a current build is processing, we won't abort it by invalidating.
// (If aborted, it'll cause a client side hard reload)
// But let it to invalidate just after the completion.
// So, it can re-build the queued pages at once.
if ( this . building ) {
this . rebuildAgain = true
return
}
this . building = true
2018-09-16 16:06:02 +02:00
// Work around a bug in webpack, calling `invalidate` on Watching.js
// doesn't trigger the invalid call used to keep track of the `.done` hook on multiCompiler
for ( const compiler of this . multiCompiler . compilers ) {
compiler . hooks . invalid . call ( )
}
2017-02-27 21:05:10 +01:00
this . devMiddleware . invalidate ( )
}
startBuilding ( ) {
this . building = true
}
doneBuilding ( ) {
this . building = false
2018-01-30 16:40:52 +01:00
2017-02-27 21:05:10 +01:00
if ( this . rebuildAgain ) {
this . rebuildAgain = false
this . invalidate ( )
}
}
}