rsnext/packages/next/server/dev/on-demand-entry-handler.ts
Shu Ding 201f98e81a
Per-page runtime (#35011)
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`
2022-03-08 20:55:14 +00:00

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()
}
}
}