201f98e81a
Partially implements #31317 and #31506. There're also some trade-offs made with this PR: since we can't know if a certain runtime will be used or not beforehand, we have to start both runtime compilers (Node.js and Edge) and then generate entrypoints correspondingly. Note that with this PR, the global runtime is still required to use the per-page runtime. ## Bug - [ ] Related issues linked using `fixes #number` - [x] Integration tests added - [ ] Errors have helpful link attached, see `contributing.md` ## Feature - [ ] Implements an existing feature request or RFC. Make sure the feature request has been accepted for implementation before opening a PR. - [ ] Related issues linked using `fixes #number` - [ ] Integration tests added - [ ] Documentation added - [ ] Telemetry added. In case of a feature if it's used or not. - [ ] Errors have helpful link attached, see `contributing.md` ## Documentation / Examples - [ ] Make sure the linting passes by running `yarn lint`
367 lines
10 KiB
TypeScript
367 lines
10 KiB
TypeScript
import { EventEmitter } from 'events'
|
|
import { join, posix } from 'path'
|
|
import type { webpack5 as webpack } from 'next/dist/compiled/webpack/webpack'
|
|
import { normalizePagePath, normalizePathSep } from '../normalize-page-path'
|
|
import { pageNotFoundError } from '../require'
|
|
import { findPageFile } from '../lib/find-page-file'
|
|
import getRouteFromEntrypoint from '../get-route-from-entrypoint'
|
|
import { API_ROUTE, MIDDLEWARE_ROUTE } from '../../lib/constants'
|
|
import { reportTrigger } from '../../build/output'
|
|
import type ws from 'ws'
|
|
import { NextConfigComplete } from '../config-shared'
|
|
import { isCustomErrorPage } from '../../build/utils'
|
|
import { getPageRuntime } from '../../build/entries'
|
|
|
|
export const ADDED = Symbol('added')
|
|
export const BUILDING = Symbol('building')
|
|
export const BUILT = Symbol('built')
|
|
|
|
export const entries: {
|
|
[page: string]: {
|
|
bundlePath: string
|
|
absolutePagePath: string
|
|
status?: typeof ADDED | typeof BUILDING | typeof BUILT
|
|
lastActiveTime?: number
|
|
dispose?: boolean
|
|
}
|
|
} = {}
|
|
|
|
export default function onDemandEntryHandler(
|
|
watcher: any,
|
|
multiCompiler: webpack.MultiCompiler,
|
|
{
|
|
pagesDir,
|
|
nextConfig,
|
|
maxInactiveAge,
|
|
pagesBufferLength,
|
|
}: {
|
|
pagesDir: string
|
|
nextConfig: NextConfigComplete
|
|
maxInactiveAge: number
|
|
pagesBufferLength: number
|
|
}
|
|
) {
|
|
const { compilers } = multiCompiler
|
|
const invalidator = new Invalidator(watcher, multiCompiler)
|
|
|
|
let lastClientAccessPages = ['']
|
|
let doneCallbacks: EventEmitter | null = new EventEmitter()
|
|
|
|
for (const compiler of compilers) {
|
|
compiler.hooks.make.tap(
|
|
'NextJsOnDemandEntries',
|
|
(_compilation: webpack.Compilation) => {
|
|
invalidator.startBuilding()
|
|
}
|
|
)
|
|
}
|
|
|
|
function getPagePathsFromEntrypoints(
|
|
type: string,
|
|
entrypoints: any
|
|
): string[] {
|
|
const pagePaths = []
|
|
for (const entrypoint of entrypoints.values()) {
|
|
const page = getRouteFromEntrypoint(entrypoint.name)
|
|
if (page) {
|
|
pagePaths.push(`${type}${page}`)
|
|
}
|
|
}
|
|
|
|
return pagePaths
|
|
}
|
|
|
|
multiCompiler.hooks.done.tap('NextJsOnDemandEntries', (multiStats) => {
|
|
if (invalidator.rebuildAgain) {
|
|
return invalidator.doneBuilding()
|
|
}
|
|
const [clientStats, serverStats, edgeServerStats] = multiStats.stats
|
|
const pagePaths = [
|
|
...getPagePathsFromEntrypoints(
|
|
'client',
|
|
clientStats.compilation.entrypoints
|
|
),
|
|
...getPagePathsFromEntrypoints(
|
|
'server',
|
|
serverStats.compilation.entrypoints
|
|
),
|
|
...(edgeServerStats
|
|
? getPagePathsFromEntrypoints(
|
|
'edge-server',
|
|
edgeServerStats.compilation.entrypoints
|
|
)
|
|
: []),
|
|
]
|
|
|
|
for (const page of pagePaths) {
|
|
const entry = entries[page]
|
|
if (!entry) {
|
|
continue
|
|
}
|
|
|
|
if (entry.status !== BUILDING) {
|
|
continue
|
|
}
|
|
|
|
entry.status = BUILT
|
|
doneCallbacks!.emit(page)
|
|
}
|
|
|
|
invalidator.doneBuilding()
|
|
})
|
|
|
|
const pingIntervalTime = Math.max(1000, Math.min(5000, maxInactiveAge))
|
|
|
|
const disposeHandler = setInterval(function () {
|
|
disposeInactiveEntries(watcher, lastClientAccessPages, maxInactiveAge)
|
|
}, pingIntervalTime + 1000)
|
|
|
|
disposeHandler.unref()
|
|
|
|
function handlePing(pg: string) {
|
|
const page = normalizePathSep(pg)
|
|
const pageKey = `client${page}`
|
|
const entryInfo = entries[pageKey]
|
|
let toSend
|
|
|
|
// If there's no entry, it may have been invalidated and needs to be re-built.
|
|
if (!entryInfo) {
|
|
// if (page !== lastEntry) client pings, but there's no entry for page
|
|
return { invalid: true }
|
|
}
|
|
|
|
// 404 is an on demand entry but when a new page is added we have to refresh the page
|
|
if (page === '/_error') {
|
|
toSend = { invalid: true }
|
|
} else {
|
|
toSend = { success: true }
|
|
}
|
|
|
|
// 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 (!lastClientAccessPages.includes(pageKey)) {
|
|
lastClientAccessPages.unshift(pageKey)
|
|
|
|
// Maintain the buffer max length
|
|
if (lastClientAccessPages.length > pagesBufferLength) {
|
|
lastClientAccessPages.pop()
|
|
}
|
|
}
|
|
entryInfo.lastActiveTime = Date.now()
|
|
entryInfo.dispose = false
|
|
return toSend
|
|
}
|
|
|
|
return {
|
|
async ensurePage(page: string, clientOnly: boolean) {
|
|
let normalizedPagePath: string
|
|
try {
|
|
normalizedPagePath = normalizePagePath(page)
|
|
} catch (err) {
|
|
console.error(err)
|
|
throw pageNotFoundError(page)
|
|
}
|
|
|
|
let pagePath = await findPageFile(
|
|
pagesDir,
|
|
normalizedPagePath,
|
|
nextConfig.pageExtensions
|
|
)
|
|
|
|
// Default the /_error route to the Next.js provided default page
|
|
if (page === '/_error' && pagePath === null) {
|
|
pagePath = 'next/dist/pages/_error'
|
|
}
|
|
|
|
if (pagePath === null) {
|
|
throw pageNotFoundError(normalizedPagePath)
|
|
}
|
|
|
|
let bundlePath: string
|
|
let absolutePagePath: string
|
|
if (pagePath.startsWith('next/dist/pages/')) {
|
|
bundlePath = page
|
|
absolutePagePath = require.resolve(pagePath)
|
|
} else {
|
|
let pageUrl = pagePath.replace(/\\/g, '/')
|
|
|
|
pageUrl = `${pageUrl[0] !== '/' ? '/' : ''}${pageUrl
|
|
.replace(
|
|
new RegExp(`\\.+(?:${nextConfig.pageExtensions.join('|')})$`),
|
|
''
|
|
)
|
|
.replace(/\/index$/, '')}`
|
|
|
|
pageUrl = pageUrl === '' ? '/' : pageUrl
|
|
const bundleFile = normalizePagePath(pageUrl)
|
|
bundlePath = posix.join('pages', bundleFile)
|
|
absolutePagePath = join(pagesDir, pagePath)
|
|
page = posix.normalize(pageUrl)
|
|
}
|
|
|
|
const normalizedPage = normalizePathSep(page)
|
|
|
|
const isMiddleware = normalizedPage.match(MIDDLEWARE_ROUTE)
|
|
const isApiRoute = normalizedPage.match(API_ROUTE) && !isMiddleware
|
|
const pageRuntimeConfig = await getPageRuntime(
|
|
absolutePagePath,
|
|
nextConfig.experimental.runtime
|
|
)
|
|
const isEdgeServer = pageRuntimeConfig === 'edge'
|
|
|
|
const isCustomError = isCustomErrorPage(page)
|
|
|
|
let entriesChanged = false
|
|
const addPageEntry = (type: 'client' | 'server' | 'edge-server') => {
|
|
return new Promise<void>((resolve, reject) => {
|
|
// Makes sure the page that is being kept in on-demand-entries matches the webpack output
|
|
const pageKey = `${type}${page}`
|
|
const entryInfo = entries[pageKey]
|
|
|
|
if (entryInfo) {
|
|
entryInfo.lastActiveTime = Date.now()
|
|
entryInfo.dispose = false
|
|
if (entryInfo.status === BUILT) {
|
|
resolve()
|
|
return
|
|
}
|
|
|
|
doneCallbacks!.once(pageKey, handleCallback)
|
|
return
|
|
}
|
|
|
|
entriesChanged = true
|
|
|
|
entries[pageKey] = {
|
|
bundlePath,
|
|
absolutePagePath,
|
|
status: ADDED,
|
|
lastActiveTime: Date.now(),
|
|
dispose: false,
|
|
}
|
|
doneCallbacks!.once(pageKey, handleCallback)
|
|
|
|
function handleCallback(err: Error) {
|
|
if (err) return reject(err)
|
|
resolve()
|
|
}
|
|
})
|
|
}
|
|
|
|
const isClientOrMiddleware = clientOnly || isMiddleware
|
|
|
|
const promise = isApiRoute
|
|
? addPageEntry('server')
|
|
: isClientOrMiddleware
|
|
? addPageEntry('client')
|
|
: Promise.all([
|
|
addPageEntry('client'),
|
|
addPageEntry(
|
|
isEdgeServer && !isCustomError ? 'edge-server' : 'server'
|
|
),
|
|
])
|
|
|
|
if (entriesChanged) {
|
|
reportTrigger(
|
|
isApiRoute || isMiddleware || clientOnly
|
|
? normalizedPage
|
|
: `${normalizedPage} (client and server)`
|
|
)
|
|
invalidator.invalidate()
|
|
}
|
|
|
|
return promise
|
|
},
|
|
|
|
onHMR(client: ws) {
|
|
client.addEventListener('message', ({ data }) => {
|
|
data = typeof data !== 'string' ? data.toString() : data
|
|
try {
|
|
const parsedData = JSON.parse(data)
|
|
|
|
if (parsedData.event === 'ping') {
|
|
const result = handlePing(parsedData.page)
|
|
client.send(
|
|
JSON.stringify({
|
|
...result,
|
|
event: 'pong',
|
|
})
|
|
)
|
|
}
|
|
} catch (_) {}
|
|
})
|
|
},
|
|
}
|
|
}
|
|
|
|
function disposeInactiveEntries(
|
|
_watcher: any,
|
|
lastClientAccessPages: any,
|
|
maxInactiveAge: number
|
|
) {
|
|
Object.keys(entries).forEach((page) => {
|
|
const { lastActiveTime, status, dispose } = entries[page]
|
|
|
|
// Skip pages already scheduled for disposing
|
|
if (dispose) return
|
|
|
|
// 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 (lastClientAccessPages.includes(page)) return
|
|
|
|
if (lastActiveTime && Date.now() - lastActiveTime > maxInactiveAge) {
|
|
entries[page].dispose = true
|
|
}
|
|
})
|
|
}
|
|
|
|
// Make sure only one invalidation happens at a time
|
|
// Otherwise, webpack hash gets changed and it'll force the client to reload.
|
|
class Invalidator {
|
|
private multiCompiler: webpack.MultiCompiler
|
|
private watcher: any
|
|
private building: boolean
|
|
public rebuildAgain: boolean
|
|
|
|
constructor(watcher: any, multiCompiler: webpack.MultiCompiler) {
|
|
this.multiCompiler = multiCompiler
|
|
this.watcher = watcher
|
|
// contains an array of types of compilers currently building
|
|
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
|
|
this.watcher.invalidate()
|
|
}
|
|
|
|
startBuilding() {
|
|
this.building = true
|
|
}
|
|
|
|
doneBuilding() {
|
|
this.building = false
|
|
|
|
if (this.rebuildAgain) {
|
|
this.rebuildAgain = false
|
|
this.invalidate()
|
|
}
|
|
}
|
|
}
|