Migrate server-sent events HMR connection to WebSocket (#29903)

This replaces the server-sent events HMR connection with a WebSocket connection to prevent hitting browser connection limits, allow sending events back from the browser, and overall better performance. 

This approach sets up the the `upgrade` event listener on the server immediately when created via `next dev` and on the first request using `req.socket.server` when created via a custom server. In a follow-up PR we can push the files changed via the WebSocket as well. 

x-ref: https://github.com/vercel/next.js/issues/10061
x-ref: https://github.com/vercel/next.js/issues/8064
x-ref: https://github.com/vercel/next.js/issues/4495

## Bug

- [ ] Related issues linked using `fixes #number`
- [ ] 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
This commit is contained in:
JJ Kasper 2021-10-15 02:09:54 -05:00 committed by GitHub
parent ea0cdc5a36
commit 75748caf7f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
27 changed files with 289 additions and 1247 deletions

View file

@ -1,12 +1,7 @@
/* globals __webpack_hash__ */
import EventSourcePolyfill from './event-source-polyfill'
import { addMessageListener } from './error-overlay/eventsource'
import { setupPing } from './on-demand-entries-utils'
import { displayContent } from './fouc'
if (!window.EventSource) {
window.EventSource = EventSourcePolyfill
}
import initOnDemandEntries from './on-demand-entries-client'
import { addMessageListener, connectHMR } from './error-overlay/websocket'
const data = JSON.parse(document.getElementById('__NEXT_DATA__').textContent)
let { assetPrefix, page } = data
@ -94,5 +89,6 @@ addMessageListener((event) => {
}
})
setupPing(assetPrefix, () => page)
connectHMR({ path: `${assetPrefix}/_next/webpack-hmr` })
displayContent()
initOnDemandEntries()

View file

@ -1,4 +1,4 @@
import { addMessageListener } from './error-overlay/eventsource'
import { addMessageListener } from './error-overlay/websocket'
export default function initializeBuildWatcher(toggleCallback) {
const shadowHost = document.createElement('div')

View file

@ -1,66 +0,0 @@
const eventCallbacks = []
function EventSourceWrapper(options) {
var source
var lastActivity = new Date()
var listeners = []
if (!options.timeout) {
options.timeout = 20 * 1000
}
init()
var timer = setInterval(function () {
if (new Date() - lastActivity > options.timeout) {
handleDisconnect()
}
}, options.timeout / 2)
function init() {
source = new window.EventSource(options.path)
source.onopen = handleOnline
source.onerror = handleDisconnect
source.onmessage = handleMessage
}
function handleOnline() {
if (options.log) console.log('[HMR] connected')
lastActivity = new Date()
}
function handleMessage(event) {
lastActivity = new Date()
for (var i = 0; i < listeners.length; i++) {
listeners[i](event)
}
eventCallbacks.forEach((cb) => {
if (!cb.unfiltered && event.data.indexOf('action') === -1) return
cb(event)
})
}
function handleDisconnect() {
clearInterval(timer)
source.close()
setTimeout(init, options.timeout)
}
return {
close: () => {
clearInterval(timer)
source.close()
},
addMessageListener: function (fn) {
listeners.push(fn)
},
}
}
export function addMessageListener(cb) {
eventCallbacks.push(cb)
}
export function getEventSourceWrapper(options) {
return EventSourceWrapper(options)
}

View file

@ -33,7 +33,7 @@ import {
onRefresh,
} from '@next/react-dev-overlay/lib/client'
import stripAnsi from 'next/dist/compiled/strip-ansi'
import { addMessageListener } from './eventsource'
import { addMessageListener } from './websocket'
import formatWebpackMessages from './format-webpack-messages'
// This alternative WebpackDevServer combines the functionality of:
@ -51,10 +51,8 @@ export default function connect() {
register()
addMessageListener((event) => {
// This is the heartbeat event
if (event.data === '\uD83D\uDC93') {
return
}
if (event.data.indexOf('action') === -1) return
try {
processMessage(event)
} catch (ex) {

View file

@ -0,0 +1,59 @@
let source: WebSocket
const eventCallbacks: ((event: any) => void)[] = []
let lastActivity = Date.now()
export function addMessageListener(cb: (event: any) => void) {
eventCallbacks.push(cb)
}
export function sendMessage(data: any) {
if (!source || source.readyState !== source.OPEN) return
return source.send(data)
}
export function connectHMR(options: {
path: string
timeout: number
log?: boolean
}) {
if (!options.timeout) {
options.timeout = 5 * 1000
}
init()
let timer = setInterval(function () {
if (Date.now() - lastActivity > options.timeout) {
handleDisconnect()
}
}, options.timeout / 2)
function init() {
if (source) source.close()
const { protocol, hostname, port } = location
const url = `${protocol === 'http:' ? 'ws' : 'wss'}://${hostname}:${port}`
source = new window.WebSocket(`${url}${options.path}`)
source.onopen = handleOnline
source.onerror = handleDisconnect
source.onmessage = handleMessage
}
function handleOnline() {
if (options.log) console.log('[HMR] connected')
lastActivity = Date.now()
}
function handleMessage(event: any) {
lastActivity = Date.now()
eventCallbacks.forEach((cb) => {
cb(event)
})
}
function handleDisconnect() {
clearInterval(timer)
source.close()
setTimeout(init, options.timeout)
}
}

View file

@ -1,964 +0,0 @@
/* eslint-disable */
// Improved version of https://github.com/Yaffle/EventSource/
// Available under MIT License (MIT)
// Only tries to support IE11 and nothing below
var document = window.document
var Response = window.Response
var TextDecoder = window.TextDecoder
var TextEncoder = window.TextEncoder
var AbortController = window.AbortController
if (AbortController == undefined) {
AbortController = function () {
this.signal = null
this.abort = function () {}
}
}
function TextDecoderPolyfill() {
this.bitsNeeded = 0
this.codePoint = 0
}
TextDecoderPolyfill.prototype.decode = function (octets) {
function valid(codePoint, shift, octetsCount) {
if (octetsCount === 1) {
return codePoint >= 0x0080 >> shift && codePoint << shift <= 0x07ff
}
if (octetsCount === 2) {
return (
(codePoint >= 0x0800 >> shift && codePoint << shift <= 0xd7ff) ||
(codePoint >= 0xe000 >> shift && codePoint << shift <= 0xffff)
)
}
if (octetsCount === 3) {
return codePoint >= 0x010000 >> shift && codePoint << shift <= 0x10ffff
}
throw new Error()
}
function octetsCount(bitsNeeded, codePoint) {
if (bitsNeeded === 6 * 1) {
return codePoint >> 6 > 15 ? 3 : codePoint > 31 ? 2 : 1
}
if (bitsNeeded === 6 * 2) {
return codePoint > 15 ? 3 : 2
}
if (bitsNeeded === 6 * 3) {
return 3
}
throw new Error()
}
var REPLACER = 0xfffd
var string = ''
var bitsNeeded = this.bitsNeeded
var codePoint = this.codePoint
for (var i = 0; i < octets.length; i += 1) {
var octet = octets[i]
if (bitsNeeded !== 0) {
if (
octet < 128 ||
octet > 191 ||
!valid(
(codePoint << 6) | (octet & 63),
bitsNeeded - 6,
octetsCount(bitsNeeded, codePoint)
)
) {
bitsNeeded = 0
codePoint = REPLACER
string += String.fromCharCode(codePoint)
}
}
if (bitsNeeded === 0) {
if (octet >= 0 && octet <= 127) {
bitsNeeded = 0
codePoint = octet
} else if (octet >= 192 && octet <= 223) {
bitsNeeded = 6 * 1
codePoint = octet & 31
} else if (octet >= 224 && octet <= 239) {
bitsNeeded = 6 * 2
codePoint = octet & 15
} else if (octet >= 240 && octet <= 247) {
bitsNeeded = 6 * 3
codePoint = octet & 7
} else {
bitsNeeded = 0
codePoint = REPLACER
}
if (
bitsNeeded !== 0 &&
!valid(codePoint, bitsNeeded, octetsCount(bitsNeeded, codePoint))
) {
bitsNeeded = 0
codePoint = REPLACER
}
} else {
bitsNeeded -= 6
codePoint = (codePoint << 6) | (octet & 63)
}
if (bitsNeeded === 0) {
if (codePoint <= 0xffff) {
string += String.fromCharCode(codePoint)
} else {
string += String.fromCharCode(0xd800 + ((codePoint - 0xffff - 1) >> 10))
string += String.fromCharCode(
0xdc00 + ((codePoint - 0xffff - 1) & 0x3ff)
)
}
}
}
this.bitsNeeded = bitsNeeded
this.codePoint = codePoint
return string
}
// Firefox < 38 throws an error with stream option
var supportsStreamOption = function () {
try {
return (
new TextDecoder().decode(new TextEncoder().encode('test'), {
stream: true,
}) === 'test'
)
} catch (error) {
console.log(error)
}
return false
}
// IE, Edge
if (
TextDecoder == undefined ||
TextEncoder == undefined ||
!supportsStreamOption()
) {
TextDecoder = TextDecoderPolyfill
}
var k = function () {}
function XHRWrapper(xhr) {
this.withCredentials = false
this.responseType = ''
this.readyState = 0
this.status = 0
this.statusText = ''
this.responseText = ''
this.onprogress = k
this.onreadystatechange = k
this._contentType = ''
this._xhr = xhr
this._sendTimeout = 0
this._abort = k
}
XHRWrapper.prototype.open = function (method, url) {
this._abort(true)
var that = this
var xhr = this._xhr
var state = 1
var timeout = 0
this._abort = function (silent) {
if (that._sendTimeout !== 0) {
clearTimeout(that._sendTimeout)
that._sendTimeout = 0
}
if (state === 1 || state === 2 || state === 3) {
state = 4
xhr.onload = k
xhr.onerror = k
xhr.onabort = k
xhr.onprogress = k
xhr.onreadystatechange = k
// IE 8 - 9: XDomainRequest#abort() does not fire any event
// Opera < 10: XMLHttpRequest#abort() does not fire any event
xhr.abort()
if (timeout !== 0) {
clearTimeout(timeout)
timeout = 0
}
if (!silent) {
that.readyState = 4
that.onreadystatechange()
}
}
state = 0
}
var onStart = function () {
if (state === 1) {
// state = 2;
var status = 0
var statusText = ''
var contentType = undefined
if (!('contentType' in xhr)) {
try {
status = xhr.status
statusText = xhr.statusText
contentType = xhr.getResponseHeader('Content-Type')
} catch (error) {
// IE < 10 throws exception for `xhr.status` when xhr.readyState === 2 || xhr.readyState === 3
// Opera < 11 throws exception for `xhr.status` when xhr.readyState === 2
// https://bugs.webkit.org/show_bug.cgi?id=29121
status = 0
statusText = ''
contentType = undefined
// Firefox < 14, Chrome ?, Safari ?
// https://bugs.webkit.org/show_bug.cgi?id=29658
// https://bugs.webkit.org/show_bug.cgi?id=77854
}
} else {
status = 200
statusText = 'OK'
contentType = xhr.contentType
}
if (status !== 0) {
state = 2
that.readyState = 2
that.status = status
that.statusText = statusText
that._contentType = contentType
that.onreadystatechange()
}
}
}
var onProgress = function () {
onStart()
if (state === 2 || state === 3) {
state = 3
var responseText = ''
try {
responseText = xhr.responseText
} catch (error) {
// IE 8 - 9 with XMLHttpRequest
}
that.readyState = 3
that.responseText = responseText
that.onprogress()
}
}
var onFinish = function () {
// Firefox 52 fires "readystatechange" (xhr.readyState === 4) without final "readystatechange" (xhr.readyState === 3)
// IE 8 fires "onload" without "onprogress"
onProgress()
if (state === 1 || state === 2 || state === 3) {
state = 4
if (timeout !== 0) {
clearTimeout(timeout)
timeout = 0
}
that.readyState = 4
that.onreadystatechange()
}
}
var onReadyStateChange = function () {
if (xhr != undefined) {
// Opera 12
if (xhr.readyState === 4) {
onFinish()
} else if (xhr.readyState === 3) {
onProgress()
} else if (xhr.readyState === 2) {
onStart()
}
}
}
var onTimeout = function () {
timeout = setTimeout(function () {
onTimeout()
}, 500)
if (xhr.readyState === 3) {
onProgress()
}
}
// XDomainRequest#abort removes onprogress, onerror, onload
xhr.onload = onFinish
xhr.onerror = onFinish
// improper fix to match Firefox behavior, but it is better than just ignore abort
// see https://bugzilla.mozilla.org/show_bug.cgi?id=768596
// https://bugzilla.mozilla.org/show_bug.cgi?id=880200
// https://code.google.com/p/chromium/issues/detail?id=153570
// IE 8 fires "onload" without "onprogress
xhr.onabort = onFinish
// https://bugzilla.mozilla.org/show_bug.cgi?id=736723
if (
!('sendAsBinary' in XMLHttpRequest.prototype) &&
!('mozAnon' in XMLHttpRequest.prototype)
) {
xhr.onprogress = onProgress
}
// IE 8 - 9 (XMLHTTPRequest)
// Opera < 12
// Firefox < 3.5
// Firefox 3.5 - 3.6 - ? < 9.0
// onprogress is not fired sometimes or delayed
// see also #64
xhr.onreadystatechange = onReadyStateChange
if ('contentType' in xhr) {
url += (url.indexOf('?') === -1 ? '?' : '&') + 'padding=true'
}
xhr.open(method, url, true)
if ('readyState' in xhr) {
// workaround for Opera 12 issue with "progress" events
// #91
timeout = setTimeout(function () {
onTimeout()
}, 0)
}
}
XHRWrapper.prototype.abort = function () {
this._abort(false)
}
XHRWrapper.prototype.getResponseHeader = function (name) {
return this._contentType
}
XHRWrapper.prototype.setRequestHeader = function (name, value) {
var xhr = this._xhr
if ('setRequestHeader' in xhr) {
xhr.setRequestHeader(name, value)
}
}
XHRWrapper.prototype.getAllResponseHeaders = function () {
return this._xhr.getAllResponseHeaders != undefined
? this._xhr.getAllResponseHeaders()
: ''
}
XHRWrapper.prototype.send = function () {
// loading indicator in Safari < ? (6), Chrome < 14, Firefox
if (
!('ontimeout' in XMLHttpRequest.prototype) &&
document != undefined &&
document.readyState != undefined &&
document.readyState !== 'complete'
) {
var that = this
that._sendTimeout = setTimeout(function () {
that._sendTimeout = 0
that.send()
}, 4)
return
}
var xhr = this._xhr
// withCredentials should be set after "open" for Safari and Chrome (< 19 ?)
xhr.withCredentials = this.withCredentials
xhr.responseType = this.responseType
try {
// xhr.send(); throws "Not enough arguments" in Firefox 3.0
xhr.send(undefined)
} catch (error1) {
// Safari 5.1.7, Opera 12
throw error1
}
}
function toLowerCase(name) {
return name.replace(/[A-Z]/g, function (c) {
return String.fromCharCode(c.charCodeAt(0) + 0x20)
})
}
function HeadersPolyfill(all) {
// Get headers: implemented according to mozilla's example code: https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest/getAllResponseHeaders#Example
var map = Object.create(null)
var array = all.split('\r\n')
for (var i = 0; i < array.length; i += 1) {
var line = array[i]
var parts = line.split(': ')
var name = parts.shift()
var value = parts.join(': ')
map[toLowerCase(name)] = value
}
this._map = map
}
HeadersPolyfill.prototype.get = function (name) {
return this._map[toLowerCase(name)]
}
function XHRTransport() {}
XHRTransport.prototype.open = function (
xhr,
onStartCallback,
onProgressCallback,
onFinishCallback,
url,
withCredentials,
headers
) {
xhr.open('GET', url)
var offset = 0
xhr.onprogress = function () {
var responseText = xhr.responseText
var chunk = responseText.slice(offset)
offset += chunk.length
onProgressCallback(chunk)
}
xhr.onreadystatechange = function () {
if (xhr.readyState === 2) {
var status = xhr.status
var statusText = xhr.statusText
var contentType = xhr.getResponseHeader('Content-Type')
var headers = xhr.getAllResponseHeaders()
onStartCallback(
status,
statusText,
contentType,
new HeadersPolyfill(headers),
function () {
xhr.abort()
}
)
} else if (xhr.readyState === 4) {
onFinishCallback()
}
}
xhr.withCredentials = withCredentials
xhr.responseType = 'text'
for (var name in headers) {
if (Object.prototype.hasOwnProperty.call(headers, name)) {
xhr.setRequestHeader(name, headers[name])
}
}
xhr.send()
}
function HeadersWrapper(headers) {
this._headers = headers
}
HeadersWrapper.prototype.get = function (name) {
return this._headers.get(name)
}
function FetchTransport() {}
FetchTransport.prototype.open = function (
xhr,
onStartCallback,
onProgressCallback,
onFinishCallback,
url,
withCredentials,
headers
) {
var controller = new AbortController()
var signal = controller.signal // see #120
var textDecoder = new TextDecoder()
fetch(url, {
headers: headers,
credentials: withCredentials ? 'include' : 'same-origin',
signal: signal,
cache: 'no-store',
})
.then(function (response) {
var reader = response.body.getReader()
onStartCallback(
response.status,
response.statusText,
response.headers.get('Content-Type'),
new HeadersWrapper(response.headers),
function () {
controller.abort()
reader.cancel()
}
)
return new Promise(function (resolve, reject) {
var readNextChunk = function () {
reader
.read()
.then(function (result) {
if (result.done) {
// Note: bytes in textDecoder are ignored
resolve(undefined)
} else {
var chunk = textDecoder.decode(result.value, { stream: true })
onProgressCallback(chunk)
readNextChunk()
}
})
['catch'](function (error) {
reject(error)
})
}
readNextChunk()
})
})
.then(
function (result) {
onFinishCallback()
return result
},
function (error) {
onFinishCallback()
return Promise.reject(error)
}
)
}
function EventTarget() {
this._listeners = Object.create(null)
}
function throwError(e) {
setTimeout(function () {
throw e
}, 0)
}
EventTarget.prototype.dispatchEvent = function (event) {
event.target = this
var typeListeners = this._listeners[event.type]
if (typeListeners != undefined) {
var length = typeListeners.length
for (var i = 0; i < length; i += 1) {
var listener = typeListeners[i]
try {
if (typeof listener.handleEvent === 'function') {
listener.handleEvent(event)
} else {
listener.call(this, event)
}
} catch (e) {
throwError(e)
}
}
}
}
EventTarget.prototype.addEventListener = function (type, listener) {
type = String(type)
var listeners = this._listeners
var typeListeners = listeners[type]
if (typeListeners == undefined) {
typeListeners = []
listeners[type] = typeListeners
}
var found = false
for (var i = 0; i < typeListeners.length; i += 1) {
if (typeListeners[i] === listener) {
found = true
}
}
if (!found) {
typeListeners.push(listener)
}
}
EventTarget.prototype.removeEventListener = function (type, listener) {
type = String(type)
var listeners = this._listeners
var typeListeners = listeners[type]
if (typeListeners != undefined) {
var filtered = []
for (var i = 0; i < typeListeners.length; i += 1) {
if (typeListeners[i] !== listener) {
filtered.push(typeListeners[i])
}
}
if (filtered.length === 0) {
delete listeners[type]
} else {
listeners[type] = filtered
}
}
}
function Event(type) {
this.type = type
this.target = undefined
}
function MessageEvent(type, options) {
Event.call(this, type)
this.data = options.data
this.lastEventId = options.lastEventId
}
MessageEvent.prototype = Object.create(Event.prototype)
function ConnectionEvent(type, options) {
Event.call(this, type)
this.status = options.status
this.statusText = options.statusText
this.headers = options.headers
}
ConnectionEvent.prototype = Object.create(Event.prototype)
var WAITING = -1
var CONNECTING = 0
var OPEN = 1
var CLOSED = 2
var AFTER_CR = -1
var FIELD_START = 0
var FIELD = 1
var VALUE_START = 2
var VALUE = 3
var contentTypeRegExp = /^text\/event\-stream;?(\s*charset\=utf\-8)?$/i
var MINIMUM_DURATION = 1000
var MAXIMUM_DURATION = 18000000
var parseDuration = function (value, def) {
var n = parseInt(value, 10)
if (n !== n) {
n = def
}
return clampDuration(n)
}
var clampDuration = function (n) {
return Math.min(Math.max(n, MINIMUM_DURATION), MAXIMUM_DURATION)
}
var fire = function (that, f, event) {
try {
if (typeof f === 'function') {
f.call(that, event)
}
} catch (e) {
throwError(e)
}
}
function EventSourcePolyfill(url, options) {
EventTarget.call(this)
this.onopen = undefined
this.onmessage = undefined
this.onerror = undefined
this.url = undefined
this.readyState = undefined
this.withCredentials = undefined
this._close = undefined
start(this, url, options)
}
var isFetchSupported =
fetch != undefined && Response != undefined && 'body' in Response.prototype
function start(es, url, options) {
url = String(url)
var withCredentials = options != undefined && Boolean(options.withCredentials)
var initialRetry = clampDuration(1000)
var heartbeatTimeout =
options != undefined && options.heartbeatTimeout != undefined
? parseDuration(options.heartbeatTimeout, 45000)
: clampDuration(45000)
var lastEventId = ''
var retry = initialRetry
var wasActivity = false
var headers =
options != undefined && options.headers != undefined
? JSON.parse(JSON.stringify(options.headers))
: undefined
var CurrentTransport =
options != undefined && options.Transport != undefined
? options.Transport
: XMLHttpRequest
var xhr =
isFetchSupported &&
!(options != undefined && options.Transport != undefined)
? undefined
: new XHRWrapper(new CurrentTransport())
var transport = xhr == undefined ? new FetchTransport() : new XHRTransport()
var cancelFunction = undefined
var timeout = 0
var currentState = WAITING
var dataBuffer = ''
var lastEventIdBuffer = ''
var eventTypeBuffer = ''
var textBuffer = ''
var state = FIELD_START
var fieldStart = 0
var valueStart = 0
var onStart = function (status, statusText, contentType, headers, cancel) {
if (currentState === CONNECTING) {
cancelFunction = cancel
if (
status === 200 &&
contentType != undefined &&
contentTypeRegExp.test(contentType)
) {
currentState = OPEN
wasActivity = true
retry = initialRetry
es.readyState = OPEN
var event = new ConnectionEvent('open', {
status: status,
statusText: statusText,
headers: headers,
})
es.dispatchEvent(event)
fire(es, es.onopen, event)
} else {
var message = ''
if (status !== 200) {
if (statusText) {
statusText = statusText.replace(/\s+/g, ' ')
}
message =
"EventSource's response has a status " +
status +
' ' +
statusText +
' that is not 200. Aborting the connection.'
} else {
message =
"EventSource's response has a Content-Type specifying an unsupported type: " +
(contentType == undefined
? '-'
: contentType.replace(/\s+/g, ' ')) +
'. Aborting the connection.'
}
throwError(new Error(message))
close()
var event = new ConnectionEvent('error', {
status: status,
statusText: statusText,
headers: headers,
})
es.dispatchEvent(event)
fire(es, es.onerror, event)
}
}
}
var onProgress = function (textChunk) {
if (currentState === OPEN) {
var n = -1
for (var i = 0; i < textChunk.length; i += 1) {
var c = textChunk.charCodeAt(i)
if (c === '\n'.charCodeAt(0) || c === '\r'.charCodeAt(0)) {
n = i
}
}
var chunk = (n !== -1 ? textBuffer : '') + textChunk.slice(0, n + 1)
textBuffer = (n === -1 ? textBuffer : '') + textChunk.slice(n + 1)
if (chunk !== '') {
wasActivity = true
}
for (var position = 0; position < chunk.length; position += 1) {
var c = chunk.charCodeAt(position)
if (state === AFTER_CR && c === '\n'.charCodeAt(0)) {
state = FIELD_START
} else {
if (state === AFTER_CR) {
state = FIELD_START
}
if (c === '\r'.charCodeAt(0) || c === '\n'.charCodeAt(0)) {
if (state !== FIELD_START) {
if (state === FIELD) {
valueStart = position + 1
}
var field = chunk.slice(fieldStart, valueStart - 1)
var value = chunk.slice(
valueStart +
(valueStart < position &&
chunk.charCodeAt(valueStart) === ' '.charCodeAt(0)
? 1
: 0),
position
)
if (field === 'data') {
dataBuffer += '\n'
dataBuffer += value
} else if (field === 'id') {
lastEventIdBuffer = value
} else if (field === 'event') {
eventTypeBuffer = value
} else if (field === 'retry') {
initialRetry = parseDuration(value, initialRetry)
retry = initialRetry
} else if (field === 'heartbeatTimeout') {
heartbeatTimeout = parseDuration(value, heartbeatTimeout)
if (timeout !== 0) {
clearTimeout(timeout)
timeout = setTimeout(function () {
onTimeout()
}, heartbeatTimeout)
}
}
}
if (state === FIELD_START) {
if (dataBuffer !== '') {
lastEventId = lastEventIdBuffer
if (eventTypeBuffer === '') {
eventTypeBuffer = 'message'
}
var event = new MessageEvent(eventTypeBuffer, {
data: dataBuffer.slice(1),
lastEventId: lastEventIdBuffer,
})
es.dispatchEvent(event)
if (eventTypeBuffer === 'message') {
fire(es, es.onmessage, event)
}
if (currentState === CLOSED) {
return
}
}
dataBuffer = ''
eventTypeBuffer = ''
}
state = c === '\r'.charCodeAt(0) ? AFTER_CR : FIELD_START
} else {
if (state === FIELD_START) {
fieldStart = position
state = FIELD
}
if (state === FIELD) {
if (c === ':'.charCodeAt(0)) {
valueStart = position + 1
state = VALUE_START
}
} else if (state === VALUE_START) {
state = VALUE
}
}
}
}
}
}
var onFinish = function () {
if (currentState === OPEN || currentState === CONNECTING) {
currentState = WAITING
if (timeout !== 0) {
clearTimeout(timeout)
timeout = 0
}
timeout = setTimeout(function () {
onTimeout()
}, retry)
retry = clampDuration(Math.min(initialRetry * 16, retry * 2))
es.readyState = CONNECTING
var event = new Event('error')
es.dispatchEvent(event)
fire(es, es.onerror, event)
}
}
var close = function () {
currentState = CLOSED
if (cancelFunction != undefined) {
cancelFunction()
cancelFunction = undefined
}
if (timeout !== 0) {
clearTimeout(timeout)
timeout = 0
}
es.readyState = CLOSED
}
var onTimeout = function () {
timeout = 0
if (currentState !== WAITING) {
if (!wasActivity && cancelFunction != undefined) {
throwError(
new Error(
'No activity within ' +
heartbeatTimeout +
' milliseconds. Reconnecting.'
)
)
cancelFunction()
cancelFunction = undefined
} else {
wasActivity = false
timeout = setTimeout(function () {
onTimeout()
}, heartbeatTimeout)
}
return
}
wasActivity = false
timeout = setTimeout(function () {
onTimeout()
}, heartbeatTimeout)
currentState = CONNECTING
dataBuffer = ''
eventTypeBuffer = ''
lastEventIdBuffer = lastEventId
textBuffer = ''
fieldStart = 0
valueStart = 0
state = FIELD_START
// https://bugzilla.mozilla.org/show_bug.cgi?id=428916
// Request header field Last-Event-ID is not allowed by Access-Control-Allow-Headers.
var requestURL = url
if (url.slice(0, 5) !== 'data:' && url.slice(0, 5) !== 'blob:') {
if (lastEventId !== '') {
requestURL +=
(url.indexOf('?') === -1 ? '?' : '&') +
'lastEventId=' +
encodeURIComponent(lastEventId)
}
}
var requestHeaders = {}
requestHeaders['Accept'] = 'text/event-stream'
if (headers != undefined) {
for (var name in headers) {
if (Object.prototype.hasOwnProperty.call(headers, name)) {
requestHeaders[name] = headers[name]
}
}
}
try {
transport.open(
xhr,
onStart,
onProgress,
onFinish,
requestURL,
withCredentials,
requestHeaders
)
} catch (error) {
close()
throw error
}
}
es.url = url
es.readyState = CONNECTING
es.withCredentials = withCredentials
es._close = close
onTimeout()
}
EventSourcePolyfill.prototype = Object.create(EventTarget.prototype)
EventSourcePolyfill.prototype.CONNECTING = CONNECTING
EventSourcePolyfill.prototype.OPEN = OPEN
EventSourcePolyfill.prototype.CLOSED = CLOSED
EventSourcePolyfill.prototype.close = function () {
this._close()
}
EventSourcePolyfill.CONNECTING = CONNECTING
EventSourcePolyfill.OPEN = OPEN
EventSourcePolyfill.CLOSED = CLOSED
EventSourcePolyfill.prototype.withCredentials = undefined
export default EventSourcePolyfill

View file

@ -1,33 +1,46 @@
import Router from 'next/router'
import { setupPing, currentPage, closePing } from './on-demand-entries-utils'
import { addMessageListener, sendMessage } from './error-overlay/websocket'
export default async ({ assetPrefix }) => {
Router.ready(() => {
Router.events.on(
'routeChangeComplete',
setupPing.bind(this, assetPrefix, () => Router.pathname)
)
})
export default async () => {
setInterval(() => {
sendMessage(JSON.stringify({ event: 'ping', page: Router.pathname }))
}, 2500)
setupPing(
assetPrefix,
() => Router.query.__NEXT_PAGE || Router.pathname,
currentPage
)
// prevent HMR connection from being closed when running tests
if (!process.env.__NEXT_TEST_MODE) {
document.addEventListener('visibilitychange', (_event) => {
const state = document.visibilityState
if (state === 'visible') {
setupPing(assetPrefix, () => Router.pathname, true)
} else {
closePing()
addMessageListener((event) => {
if (event.data.indexOf('{') === -1) return
try {
const payload = JSON.parse(event.data)
// don't attempt fetching the page if we're already showing
// the dev overlay as this can cause the error to be triggered
// repeatedly
if (
payload.event === 'pong' &&
payload.invalid &&
!self.__NEXT_DATA__.err
) {
// Payload can be invalid even if the page does exist.
// So, we check if it can be created.
fetch(location.href, {
credentials: 'same-origin',
}).then((pageRes) => {
if (pageRes.status === 200) {
// Page exists now, reload
location.reload()
} else {
// Page doesn't exist
if (
self.__NEXT_DATA__.page === Router.pathname &&
Router.pathname !== '/_error'
) {
// We are still on the page,
// reload to show 404 error page
location.reload()
}
}
})
}
})
window.addEventListener('beforeunload', () => {
closePing()
})
}
} catch (err) {
console.error('on-demand-entries failed to parse response', err)
}
})
}

View file

@ -1,61 +0,0 @@
/* global location */
import { getEventSourceWrapper } from './error-overlay/eventsource'
let evtSource
export let currentPage
export function closePing() {
if (evtSource) evtSource.close()
evtSource = null
}
export function setupPing(assetPrefix, pathnameFn, retry) {
const pathname = pathnameFn()
// Make sure to only create new EventSource request if page has changed
if (pathname === currentPage && !retry) return
currentPage = pathname
// close current EventSource connection
closePing()
evtSource = getEventSourceWrapper({
path: `${assetPrefix}/_next/webpack-hmr?page=${encodeURIComponent(
currentPage
)}`,
timeout: 5000,
})
evtSource.addMessageListener((event) => {
if (event.data.indexOf('{') === -1) return
try {
const payload = JSON.parse(event.data)
// don't attempt fetching the page if we're already showing
// the dev overlay as this can cause the error to be triggered
// repeatedly
if (payload.invalid && !self.__NEXT_DATA__.err) {
// Payload can be invalid even if the page does exist.
// So, we check if it can be created.
fetch(location.href, {
credentials: 'same-origin',
}).then((pageRes) => {
if (pageRes.status === 200) {
// Page exists now, reload
location.reload()
} else {
// Page doesn't exist
if (
self.__NEXT_DATA__.page === currentPage &&
currentPage !== '/_error'
) {
// We are still on the page,
// reload to show 404 error page
location.reload()
}
}
})
}
} catch (err) {
console.error('on-demand-entries failed to parse response', err)
}
})
}

View file

@ -1,11 +1,10 @@
/* globals __REPLACE_NOOP_IMPORT__ */
import { initNext, version, router, emitter, render, renderError } from './'
import EventSourcePolyfill from './dev/event-source-polyfill'
import initOnDemandEntries from './dev/on-demand-entries-client'
import initWebpackHMR from './dev/webpack-hot-middleware-client'
import initializeBuildWatcher from './dev/dev-build-watcher'
import { displayContent } from './dev/fouc'
import { addMessageListener } from './dev/error-overlay/eventsource'
import { connectHMR, addMessageListener } from './dev/error-overlay/websocket'
import {
assign,
urlQueryToSearchParams,
@ -18,11 +17,6 @@ import {
// eslint-disable-next-line no-unused-expressions
__REPLACE_NOOP_IMPORT__
// Support EventSource on Internet Explorer 11
if (!window.EventSource) {
window.EventSource = EventSourcePolyfill
}
const {
__NEXT_DATA__: { assetPrefix },
} = window
@ -30,6 +24,8 @@ const {
const prefix = assetPrefix || ''
const webpackHMR = initWebpackHMR()
connectHMR({ path: `${prefix}/_next/webpack-hmr` })
window.next = {
version,
// router is initialized later so it has to be live-binded
@ -42,7 +38,7 @@ window.next = {
}
initNext({ webpackHMR })
.then(({ renderCtx }) => {
initOnDemandEntries({ assetPrefix: prefix })
initOnDemandEntries()
let buildIndicatorHandler = () => {}
@ -88,7 +84,6 @@ initNext({ webpackHMR })
}
}
}
devPagesManifestListener.unfiltered = true
addMessageListener(devPagesManifestListener)
if (process.env.__NEXT_BUILD_INDICATOR) {

View file

@ -0,0 +1,19 @@
Copyright (c) 2011 Einar Otto Stangvik <einaros@gmail.com>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1 @@
{"name":"ws","main":"index.js","author":"Einar Otto Stangvik <einaros@gmail.com> (http://2x.io)","license":"MIT"}

View file

@ -183,6 +183,7 @@
"@types/styled-jsx": "2.2.8",
"@types/text-table": "0.2.1",
"@types/webpack-sources1": "npm:@types/webpack-sources@0.1.5",
"@types/ws": "8.2.0",
"@vercel/ncc": "0.27.0",
"@vercel/nft": "0.16.1",
"amphtml-validator": "1.0.33",
@ -249,7 +250,8 @@
"webpack-sources1": "npm:webpack-sources@1.4.3",
"webpack-sources3": "npm:webpack-sources@3.2.0",
"webpack4": "npm:webpack@4.44.1",
"webpack5": "npm:webpack@5.58.2"
"webpack5": "npm:webpack@5.58.2",
"ws": "8.2.3"
},
"resolutions": {
"browserslist": "4.16.6",

View file

@ -22,7 +22,7 @@
// TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
// SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
import { webpack } from 'next/dist/compiled/webpack/webpack'
import http from 'http'
import type ws from 'ws'
export class WebpackHotMiddleware {
eventStream: EventStream
@ -86,14 +86,10 @@ export class WebpackHotMiddleware {
this.latestStats = statsResult
this.publishStats('built', this.latestStats)
}
middleware = (
req: http.IncomingMessage,
res: http.ServerResponse,
next: () => void
) => {
if (this.closed) return next()
if (!req.url?.startsWith('/_next/webpack-hmr')) return next()
this.eventStream.handler(req, res)
onHMR = (client: ws) => {
if (this.closed) return
this.eventStream.handler(client)
if (this.latestStats) {
// Explicitly not passing in `log` fn as we don't want to log again on
// the server
@ -131,64 +127,34 @@ export class WebpackHotMiddleware {
}
class EventStream {
clients: Set<http.ServerResponse>
interval: NodeJS.Timeout
clients: Set<ws>
constructor() {
this.clients = new Set()
this.interval = setInterval(this.heartbeatTick, 2500).unref()
}
heartbeatTick = () => {
this.everyClient((client) => {
client.write('data: \uD83D\uDC93\n\n')
})
}
everyClient(fn: (client: http.ServerResponse) => void) {
everyClient(fn: (client: ws) => void) {
for (const client of this.clients) {
fn(client)
}
}
close() {
clearInterval(this.interval)
this.everyClient((client) => {
if (!client.finished) client.end()
client.close()
})
this.clients.clear()
}
handler(req: http.IncomingMessage, res: http.ServerResponse) {
const headers = {
'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',
}
const isHttp1 = !(parseInt(req.httpVersion) >= 2)
if (isHttp1) {
req.socket.setKeepAlive(true)
Object.assign(headers, {
Connection: 'keep-alive',
})
}
res.writeHead(200, headers)
res.write('\n')
this.clients.add(res)
req.on('close', () => {
if (!res.finished) res.end()
this.clients.delete(res)
handler(client: ws) {
this.clients.add(client)
client.addEventListener('close', () => {
this.clients.delete(client)
})
}
publish(payload: any) {
this.everyClient((client) => {
client.write('data: ' + JSON.stringify(payload) + '\n\n')
client.send(JSON.stringify(payload))
})
}
}

View file

@ -1,5 +1,4 @@
import { getOverlayMiddleware } from '@next/react-dev-overlay/lib/middleware'
import { NextHandleFunction } from 'connect'
import { IncomingMessage, ServerResponse } from 'http'
import { WebpackHotMiddleware } from './hot-middleware'
import { join, relative, isAbsolute } from 'path'
@ -33,6 +32,9 @@ import { CustomRoutes } from '../../lib/load-custom-routes'
import { DecodeError } from '../../shared/lib/utils'
import { Span, trace } from '../../trace'
import isError from '../../lib/is-error'
import ws from 'next/dist/compiled/ws'
const wsServer = new ws.Server({ noServer: true })
export async function renderScriptError(
res: ServerResponse,
@ -136,7 +138,7 @@ export default class HotReloader {
private buildId: string
private middlewares: any[]
private pagesDir: string
private webpackHotMiddleware: (NextHandleFunction & any) | null
private webpackHotMiddleware?: WebpackHotMiddleware
private config: NextConfigComplete
private stats: webpack.Stats | null
public serverStats: webpack.Stats | null
@ -144,7 +146,7 @@ export default class HotReloader {
private serverError: Error | null = null
private serverPrevDocumentHash: string | null
private prevChunkNames?: Set<any>
private onDemandEntries: any
private onDemandEntries?: ReturnType<typeof onDemandEntryHandler>
private previewProps: __ApiPreviewProps
private watcher: any
private rewrites: CustomRoutes['rewrites']
@ -171,7 +173,6 @@ export default class HotReloader {
this.dir = dir
this.middlewares = []
this.pagesDir = pagesDir
this.webpackHotMiddleware = null
this.stats = null
this.serverStats = null
this.serverPrevDocumentHash = null
@ -261,6 +262,13 @@ export default class HotReloader {
return { finished }
}
public onHMR(req: IncomingMessage, _res: ServerResponse, head: Buffer) {
wsServer.handleUpgrade(req, req.socket, head, (client) => {
this.webpackHotMiddleware?.onHMR(client)
this.onDemandEntries?.onHMR(client)
})
}
private async clean(span: Span): Promise<void> {
return span
.traceChild('clean')
@ -623,9 +631,6 @@ export default class HotReloader {
})
this.middlewares = [
// must come before hotMiddleware
this.onDemandEntries.middleware,
this.webpackHotMiddleware.middleware,
getOverlayMiddleware({
rootDirectory: this.dir,
stats: () => this.stats,
@ -703,7 +708,7 @@ export default class HotReloader {
if (error) {
return Promise.reject(error)
}
return this.onDemandEntries.ensurePage(page, clientOnly)
return this.onDemandEntries?.ensurePage(page, clientOnly) as any
}
}

View file

@ -1,7 +1,7 @@
import crypto from 'crypto'
import fs from 'fs'
import chalk from 'chalk'
import { IncomingMessage, ServerResponse } from 'http'
import { IncomingMessage, ServerResponse, Server as HTTPServer } from 'http'
import { Worker } from 'jest-worker'
import AmpHtmlValidator from 'next/dist/compiled/amphtml-validator'
import findUp from 'next/dist/compiled/find-up'
@ -73,6 +73,7 @@ export default class DevServer extends Server {
private hotReloader?: HotReloader
private isCustomServer: boolean
protected sortedRoutes?: string[]
private addedUpgradeListener = false
protected staticPathsWorker: import('jest-worker').Worker & {
loadStaticPaths: typeof import('./static-paths-worker').loadStaticPaths
@ -82,6 +83,7 @@ export default class DevServer extends Server {
options: ServerConstructor & {
conf: NextConfig
isNextDevCommand?: boolean
httpServer?: HTTPServer
}
) {
super({ ...options, dev: true })
@ -116,6 +118,13 @@ export default class DevServer extends Server {
`The static directory has been deprecated in favor of the public directory. https://nextjs.org/docs/messages/static-dir-deprecated`
)
}
// setup upgrade listener eagerly when we can otherwise
// it will be done on the first request via req.socket.server
if (options.httpServer) {
this.setupWebSocketHandler(options.httpServer)
}
this.isCustomServer = !options.isNextDevCommand
this.pagesDir = findPagesDir(this.dir)
this.staticPathsWorker = new Worker(
@ -410,12 +419,38 @@ export default class DevServer extends Server {
return false
}
private setupWebSocketHandler(server?: HTTPServer, _req?: IncomingMessage) {
if (!this.addedUpgradeListener) {
this.addedUpgradeListener = true
server = server || (_req?.socket as any)?.server
if (!server) {
// this is very unlikely to happen but show an error in case
// it does somehow
Log.error(
`Invalid IncomingMessage received, make sure http.createServer is being used to handle requests.`
)
} else {
server.on('upgrade', (req, socket, head) => {
if (
req.url?.startsWith(
`${this.nextConfig.basePath || ''}/_next/webpack-hmr`
)
) {
this.hotReloader?.onHMR(req, socket, head)
}
})
}
}
}
async run(
req: IncomingMessage,
res: ServerResponse,
parsedUrl: UrlWithParsedQuery
): Promise<void> {
await this.devReady
this.setupWebSocketHandler(undefined, req)
const { basePath } = this.nextConfig
let originalPathname: string | null = null

View file

@ -1,7 +1,5 @@
import { EventEmitter } from 'events'
import { IncomingMessage, ServerResponse } from 'http'
import { join, posix } from 'path'
import { parse } from 'url'
import { webpack } from 'next/dist/compiled/webpack/webpack'
import { normalizePagePath, normalizePathSep } from '../normalize-page-path'
import { pageNotFoundError } from '../require'
@ -9,6 +7,7 @@ import { findPageFile } from '../lib/find-page-file'
import getRouteFromEntrypoint from '../get-route-from-entrypoint'
import { API_ROUTE } from '../../lib/constants'
import { reportTrigger } from '../../build/output'
import type ws from 'ws'
export const ADDED = Symbol('added')
export const BUILDING = Symbol('building')
@ -247,27 +246,23 @@ export default function onDemandEntryHandler(
return promise
},
middleware(req: IncomingMessage, res: ServerResponse, next: Function) {
if (!req.url?.startsWith('/_next/webpack-hmr')) return next()
onHMR(client: ws) {
client.addEventListener('message', ({ data }) => {
data = typeof data !== 'string' ? data.toString() : data
try {
const parsedData = JSON.parse(data)
const { query } = parse(req.url!, true)
const page = query.page
if (!page) return next()
const runPing = () => {
const data = handlePing(query.page as string)
if (!data) return
res.write('data: ' + JSON.stringify(data) + '\n\n')
}
const pingInterval = setInterval(() => runPing(), pingIntervalTime)
// Run a ping now to make sure page is instantly flagged as active
setTimeout(() => runPing(), 0)
req.on('close', () => {
clearInterval(pingInterval)
if (parsedData.event === 'ping') {
const result = handlePing(parsedData.page)
client.send(
JSON.stringify({
...result,
event: 'pong',
})
)
}
} catch (_) {}
})
next()
},
}
}

View file

@ -6,11 +6,17 @@ export default async function start(
port?: number,
hostname?: string
) {
let requestHandler: ReturnType<typeof app.getRequestHandler>
const srv = http.createServer((req, res) => {
return requestHandler(req, res)
})
const app = next({
...serverOptions,
customServer: false,
httpServer: srv,
})
const srv = http.createServer(app.getRequestHandler())
requestHandler = app.getRequestHandler()
await new Promise<void>((resolve, reject) => {
// This code catches EADDRINUSE error if the port is already in use
srv.on('error', reject)

View file

@ -1,4 +1,6 @@
// eslint-disable-next-line import/no-extraneous-dependencies
const findUp = require('find-up')
// eslint-disable-next-line import/no-extraneous-dependencies
const ncc = require('@vercel/ncc')
const { existsSync, readFileSync } = require('fs')
const { basename, dirname, extname, join, resolve } = require('path')
@ -56,7 +58,17 @@ module.exports = function (task) {
// It defines `name`, `main`, `author`, and `license`. It also defines `types`.
// n.b. types intended for development usage only.
function writePackageManifest(packageName, main, bundleName, precompiled) {
const packagePath = bundleRequire.resolve(packageName + '/package.json')
// some newer packages fail to include package.json in the exports
// so we can't reliably use require.resolve here
let packagePath
try {
packagePath = bundleRequire.resolve(packageName + '/package.json')
} catch (_) {
packagePath = findUp.sync('package.json', {
cwd: dirname(bundleRequire.resolve(packageName)),
})
}
let { name, author, license } = require(packagePath)
const compiledPackagePath = join(

View file

@ -782,6 +782,15 @@ export async function ncc_webpack_bundle_packages(task, opts) {
.target('compiled/webpack/')
}
// eslint-disable-next-line camelcase
externals['ws'] = 'next/dist/compiled/ws'
export async function ncc_ws(task, opts) {
await task
.source(opts.src || relative(__dirname, require.resolve('ws')))
.ncc({ packageName: 'ws', externals })
.target('compiled/ws')
}
externals['path-to-regexp'] = 'next/dist/compiled/path-to-regexp'
export async function path_to_regexp(task, opts) {
await task
@ -871,6 +880,7 @@ export async function ncc(task, opts) {
'ncc_webpack_bundle_packages',
'ncc_webpack_sources1',
'ncc_webpack_sources3',
'ncc_ws',
'ncc_mini_css_extract_plugin',
],
opts

View file

@ -218,6 +218,10 @@ declare module 'next/dist/compiled/web-vitals' {
import m from 'web-vitals'
export = m
}
declare module 'next/dist/compiled/ws' {
import m from 'ws'
export = m
}
declare module 'next/dist/compiled/comment-json' {
import m from 'comment-json'

View file

@ -1 +1,3 @@
export default () => <div>API - conflict</div>
export default function Page() {
return <div>API - conflict</div>
}

View file

@ -1 +1,3 @@
export default () => <div>API - support</div>
export default function Page() {
return <div>API - support</div>
}

View file

@ -1 +1,3 @@
export default () => <div>User</div>
export default function Page() {
return <div>User</div>
}

View file

@ -14,6 +14,7 @@ import {
nextExport,
getPageFileFromBuildManifest,
getPageFileFromPagesManifest,
check,
} from 'next-test-utils'
import json from '../big.json'
@ -479,8 +480,10 @@ function runTests(dev = false) {
await fetchViaHTTP(appPort, '/api/test-no-end', undefined, {
signal: controller.signal,
}).catch(() => {})
expect(stderr).toContain(
`API resolved without sending a response for /api/test-no-end, this may result in stalled requests.`
await check(
() => stderr,
/API resolved without sending a response for \/api\/test-no-end, this may result in stalled requests/
)
})

View file

@ -1,11 +1,9 @@
/* eslint-env jest */
import webdriver from 'next-webdriver'
import WebSocket from 'ws'
import { join } from 'path'
import AbortController from 'abort-controller'
import webdriver from 'next-webdriver'
import {
renderViaHTTP,
fetchViaHTTP,
findPort,
launchApp,
killApp,
@ -20,22 +18,9 @@ const appDir = join(__dirname, '../')
const context = {}
const doPing = (page) => {
const controller = new AbortController()
const signal = controller.signal
return fetchViaHTTP(
context.appPort,
'/_next/webpack-hmr',
{ page },
{ signal }
).then((res) => {
res.body.on('data', (chunk) => {
try {
const payload = JSON.parse(chunk.toString().split('data:')[1])
if (payload.success || payload.invalid) {
controller.abort()
}
} catch (_) {}
})
return new Promise((resolve) => {
context.ws.onmessage = () => resolve()
context.ws.send(JSON.stringify({ event: 'ping', page }))
})
}
@ -44,8 +29,17 @@ describe('On Demand Entries', () => {
beforeAll(async () => {
context.appPort = await findPort()
context.server = await launchApp(appDir, context.appPort)
await new Promise((resolve) => {
context.ws = new WebSocket(
`ws://localhost:${context.appPort}/_next/webpack-hmr`
)
context.ws.on('open', () => resolve())
context.ws.on('error', console.error)
})
})
afterAll(() => {
context.ws.close()
killApp(context.server)
})

View file

@ -4867,6 +4867,13 @@
"@types/source-list-map" "*"
source-map "^0.6.1"
"@types/ws@8.2.0":
version "8.2.0"
resolved "https://registry.yarnpkg.com/@types/ws/-/ws-8.2.0.tgz#75faefbe2328f3b833cb8dc640658328990d04f3"
integrity sha512-cyeefcUCgJlEk+hk2h3N+MqKKsPViQgF5boi9TTHSK+PoR9KWBb/C5ccPcDyAqgsbAYHTwulch725DV84+pSpg==
dependencies:
"@types/node" "*"
"@types/yargs-parser@*":
version "13.1.0"
resolved "https://registry.yarnpkg.com/@types/yargs-parser/-/yargs-parser-13.1.0.tgz#c563aa192f39350a1d18da36c5a8da382bbd8228"
@ -17277,6 +17284,7 @@ scheduler@^0.20.2:
ajv-keywords "^3.5.2"
"schema-utils3@npm:schema-utils@3.0.0", schema-utils@^3.0.0:
name schema-utils3
version "3.0.0"
resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-3.0.0.tgz#67502f6aa2b66a2d4032b4279a2944978a0913ef"
integrity sha512-6D82/xSzO094ajanoOSbe4YvXWMfn2A//8Y1+MUqFAJul5Bs+yn36xbK9OtNDcRVSBJ9jjeoXftM6CfztsjOAA==
@ -19989,6 +19997,11 @@ write-pkg@^4.0.0:
type-fest "^0.4.1"
write-json-file "^3.2.0"
ws@8.2.3:
version "8.2.3"
resolved "https://registry.yarnpkg.com/ws/-/ws-8.2.3.tgz#63a56456db1b04367d0b721a0b80cae6d8becbba"
integrity sha512-wBuoj1BDpC6ZQ1B7DWQBYVLphPWkm8i9Y0/3YdHjHKHiohOJ1ws+3OccDWtH+PoC9DZD5WOTrJvNbWvjS6JWaA==
ws@>=7.4.6:
version "8.2.0"
resolved "https://registry.yarnpkg.com/ws/-/ws-8.2.0.tgz#0b738cd484bfc9303421914b11bb4011e07615bb"