feat: HMR support for serverMiddleware (#6881)

This commit is contained in:
Pooya Parsa 2020-01-19 09:34:35 +01:00 committed by GitHub
parent 6891c31d81
commit 8907e1553f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 193 additions and 55 deletions

View File

@ -7,6 +7,7 @@ import hash from 'hash-sum'
import pify from 'pify' import pify from 'pify'
import upath from 'upath' import upath from 'upath'
import semver from 'semver' import semver from 'semver'
import chalk from 'chalk'
import debounce from 'lodash/debounce' import debounce from 'lodash/debounce'
import omit from 'lodash/omit' import omit from 'lodash/omit'
@ -21,10 +22,8 @@ import {
waitFor, waitFor,
determineGlobals, determineGlobals,
stripWhitespace, stripWhitespace,
isString,
isIndexFileAndFolder, isIndexFileAndFolder,
isPureObject, scanRequireTree
clearRequireCache
} from '@nuxt/utils' } from '@nuxt/utils'
import Ignore from './ignore' import Ignore from './ignore'
@ -60,6 +59,9 @@ export default class Builder {
this.watchRestart() this.watchRestart()
}) })
// Enable HMR for serverMiddleware
this.serverMiddlewareHMR()
// Close hook // Close hook
this.nuxt.hook('close', () => this.close()) this.nuxt.hook('close', () => this.close())
} }
@ -649,6 +651,9 @@ export default class Builder {
assignWatcher (key) { assignWatcher (key) {
return (watcher) => { return (watcher) => {
if (this.watchers[key]) {
this.watchers[key].close()
}
this.watchers[key] = watcher this.watchers[key] = watcher
} }
} }
@ -690,25 +695,61 @@ export default class Builder {
this.createFileWatcher([r(this.options.srcDir, this.options.dir.app)], ['add', 'change', 'unlink'], refreshFiles, this.assignWatcher('app')) this.createFileWatcher([r(this.options.srcDir, this.options.dir.app)], ['add', 'change', 'unlink'], refreshFiles, this.assignWatcher('app'))
} }
getServerMiddlewarePaths () { serverMiddlewareHMR () {
return this.options.serverMiddleware // Check nuxt.server dependency
.map((serverMiddleware) => { if (!this.nuxt.server) {
if (isString(serverMiddleware)) { return
return serverMiddleware }
// Get registered server middleware with path
const entries = this.nuxt.server.serverMiddlewarePaths()
// Resolve dependency tree
const deps = new Set()
const dep2Entry = {}
for (const entry of entries) {
for (const dep of scanRequireTree(entry)) {
deps.add(dep)
if (!dep2Entry[dep]) {
dep2Entry[dep] = new Set()
} }
if (isPureObject(serverMiddleware) && isString(serverMiddleware.handler)) { dep2Entry[dep].add(entry)
return serverMiddleware.handler }
}
// Create watcher
this.createFileWatcher(
Array.from(deps),
['all'],
debounce((event, fileName) => {
for (const entry of dep2Entry[fileName]) {
// Reload entry
let newItem
try {
newItem = this.nuxt.server.replaceMiddleware(entry, entry)
} catch (error) {
consola.error(error)
consola.error(`[HMR Error]: ${error}`)
}
if (!newItem) {
// Full reload if HMR failed
return this.nuxt.callHook('watch:restart', { event, path: fileName })
}
// Log
consola.info(`[HMR] ${chalk.cyan(newItem.route)} (${chalk.grey(fileName)})`)
} }
}) // Tree may be changed so recreate watcher
.filter(Boolean) this.serverMiddlewareHMR()
.map(p => path.extname(p) ? p : this.nuxt.resolver.resolvePath(p)) }, 200),
this.assignWatcher('serverMiddleware')
)
} }
watchRestart () { watchRestart () {
const serverMiddlewarePaths = this.getServerMiddlewarePaths()
const nuxtRestartWatch = [ const nuxtRestartWatch = [
// Server middleware
...serverMiddlewarePaths,
// Custom watchers // Custom watchers
...this.options.watch ...this.options.watch
].map(this.nuxt.resolver.resolveAlias) ].map(this.nuxt.resolver.resolveAlias)
@ -732,11 +773,6 @@ export default class Builder {
if (['add', 'change', 'unlink'].includes(event) === false) { if (['add', 'change', 'unlink'].includes(event) === false) {
return return
} }
/* istanbul ignore if */
if (serverMiddlewarePaths.includes(fileName)) {
consola.debug(`Clear cache for ${fileName}`)
clearRequireCache(fileName)
}
await this.nuxt.callHook('watch:fileChanged', this, fileName) // Legacy await this.nuxt.callHook('watch:fileChanged', this, fileName) // Legacy
await this.nuxt.callHook('watch:restart', { event, path: fileName }) await this.nuxt.callHook('watch:restart', { event, path: fileName })
}, },

View File

@ -290,8 +290,6 @@ describe('builder: builder watch', () => {
expect(chokidar.watch).toBeCalledTimes(1) expect(chokidar.watch).toBeCalledTimes(1)
expect(chokidar.watch).toBeCalledWith( expect(chokidar.watch).toBeCalledWith(
[ [
'resolveAlias(resolvePath(/var/nuxt/src/serverMiddleware/test))',
'resolveAlias(resolvePath(/var/nuxt/src/serverMiddleware/test-handler))',
'resolveAlias(/var/nuxt/src/watch/test)', 'resolveAlias(/var/nuxt/src/watch/test)',
'/var/nuxt/src/.nuxtignore', '/var/nuxt/src/.nuxtignore',
path.join('/var/nuxt/src/var/nuxt/src/store') // because store == false + using path.join() path.join('/var/nuxt/src/var/nuxt/src/store') // because store == false + using path.join()

View File

@ -171,40 +171,128 @@ export default class Server {
})) }))
} }
useMiddleware (middleware) { _normalizeMiddleware (middleware) {
let handler = middleware.handler || middleware // Normalize plain function
if (typeof middleware === 'function') {
middleware = { handle: middleware }
}
// Resolve handler setup as string (path) // If a plain string provided as path to middleware
if (typeof handler === 'string') { if (typeof middleware === 'string') {
try { middleware = this._requireMiddleware(middleware)
const requiredModuleFromHandlerPath = this.nuxt.resolver.requireModule(handler) }
// In case the "handler" is not derived from an object but is a normal string, another object with // Normalize handler to handle (backward compatiblity)
// path and handler could be the result if (middleware.handler && !middleware.handle) {
middleware.handle = middleware.handler
delete middleware.handler
}
// If the required module has handler, treat the module as new "middleware" object // Normalize path to route (backward compatiblity)
if (requiredModuleFromHandlerPath.handler) { if (middleware.path && !middleware.route) {
middleware = requiredModuleFromHandlerPath middleware.route = middleware.path
} delete middleware.path
}
handler = requiredModuleFromHandlerPath.handler || requiredModuleFromHandlerPath // If handle is a string pointing to path
} catch (err) { if (typeof middleware.handle === 'string') {
consola.error(err) Object.assign(middleware, this._requireMiddleware(middleware.handle))
// Throw error in production mode }
if (!this.options.dev) {
throw err // No handle
} if (!middleware.handle) {
middleware.handle = (req, res, next) => {
next(new Error('ServerMiddleware should expose a handle: ' + middleware.entry))
} }
} }
// Resolve path return middleware
const path = ( }
_requireMiddleware (entry) {
// Resolve entry
entry = this.nuxt.resolver.resolvePath(entry)
// Require middleware
let middleware
try {
middleware = this.nuxt.resolver.requireModule(entry)
} catch (error) {
// Show full error
consola.error('ServerMiddleware Error:', error)
// Placeholder for error
middleware = {
route: '#error',
handle: (req, res, next) => { next(error) }
}
}
// Normalize
middleware = this._normalizeMiddleware(middleware)
// Set entry
middleware.entry = entry
return middleware
}
resolveMiddleware (middleware) {
// Ensure middleware is normalized
middleware = this._normalizeMiddleware(middleware)
// Resolve final route
middleware.route = (
(middleware.prefix !== false ? this.options.router.base : '') + (middleware.prefix !== false ? this.options.router.base : '') +
(typeof middleware.path === 'string' ? middleware.path : '') (typeof middleware.route === 'string' ? middleware.route : '')
).replace(/\/\//g, '/') ).replace(/\/\//g, '/')
// Use middleware // Assign _middleware to handle to make accessable from app.stack
this.app.use(path, handler) middleware.handle._middleware = middleware
return middleware
}
useMiddleware (middleware) {
const { route, handle } = this.resolveMiddleware(middleware)
this.app.use(route.includes('#error') ? '/' : route, handle)
}
replaceMiddleware (query, middleware) {
let serverStackItem
if (typeof query === 'string') {
// Search by entry
serverStackItem = this.app.stack.find(({ handle }) => handle._middleware && handle._middleware.entry === query)
} else {
// Search by reference
serverStackItem = this.app.stack.find(({ handle }) => handle === query)
}
// Stop if item not found
if (!serverStackItem) {
return
}
// Resolve middleware
const { route, handle } = this.resolveMiddleware(middleware)
// Update serverStackItem
serverStackItem.handle = handle
// Error State
if (route.includes('#error')) {
serverStackItem.route = serverStackItem.route || '/'
} else {
serverStackItem.route = route
}
// Return updated item
return serverStackItem
}
serverMiddlewarePaths () {
return this.app.stack.map(({ handle }) => handle._middleware && handle._middleware.entry).filter(Boolean)
} }
renderRoute () { renderRoute () {

View File

@ -61,7 +61,8 @@ describe('server: server', () => {
ready: jest.fn(), ready: jest.fn(),
callHook: jest.fn(), callHook: jest.fn(),
resolver: { resolver: {
requireModule: jest.fn() requireModule: jest.fn(),
resolvePath: jest.fn().mockImplementation(p => p)
} }
}) })
@ -379,7 +380,7 @@ describe('server: server', () => {
expect(server.app.use).toBeCalledWith('/middleware', handler) expect(server.app.use).toBeCalledWith('/middleware', handler)
}) })
test('should throw error when module resolves failed', () => { test('should show error when module require failed', () => {
const nuxt = createNuxt() const nuxt = createNuxt()
nuxt.options.router = { base: '/' } nuxt.options.router = { base: '/' }
const server = new Server(nuxt) const server = new Server(nuxt)
@ -388,9 +389,10 @@ describe('server: server', () => {
throw error throw error
}) })
expect(() => server.useMiddleware('test-middleware')).toThrow(error) server.useMiddleware('test-middleware')
expect(consola.error).toBeCalledTimes(1) expect(consola.error).toBeCalledTimes(1)
expect(consola.error).toBeCalledWith(error) expect(consola.error).toBeCalledWith('ServerMiddleware Error:', error)
}) })
test('should only log error when module resolves failed in dev mode', () => { test('should only log error when module resolves failed in dev mode', () => {
@ -406,7 +408,7 @@ describe('server: server', () => {
server.useMiddleware('test-middleware') server.useMiddleware('test-middleware')
expect(consola.error).toBeCalledTimes(1) expect(consola.error).toBeCalledTimes(1)
expect(consola.error).toBeCalledWith(error) expect(consola.error).toBeCalledWith('ServerMiddleware Error:', error)
}) })
test('should render route via renderer', () => { test('should render route via renderer', () => {

View File

@ -3,7 +3,14 @@ export function isExternalDependency (id) {
} }
export function clearRequireCache (id) { export function clearRequireCache (id) {
const entry = require.cache[id] let entry
try {
entry = require.cache[id]
} catch (e) {
delete require.cache[id]
return
}
if (!entry || isExternalDependency(id)) { if (!entry || isExternalDependency(id)) {
return return
} }
@ -20,7 +27,14 @@ export function clearRequireCache (id) {
} }
export function scanRequireTree (id, files = new Set()) { export function scanRequireTree (id, files = new Set()) {
const entry = require.cache[id] let entry
try {
entry = require.cache[id]
} catch (e) {
files.add(id)
return files
}
if (!entry || isExternalDependency(id) || files.has(id)) { if (!entry || isExternalDependency(id) || files.has(id)) {
return files return files
} }

View File

@ -1,6 +1,6 @@
export default { export default {
serverMiddleware: [ serverMiddleware: [
'~/middleware.js', { route: '/empty', handle: '~/middleware.js' },
(req, res, next) => next() (req, res, next) => next()
], ],
watch: ['~/custom.file'], watch: ['~/custom.file'],