next
Version:
The React Framework
428 lines (426 loc) • 18.7 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", {
value: true
});
0 && (module.exports = {
createDebugChannel: null,
getOrCreateDebugChannelReadableWriterPair: null
});
function _export(target, all) {
for(var name in all)Object.defineProperty(target, name, {
enumerable: true,
get: all[name]
});
}
_export(exports, {
createDebugChannel: function() {
return createDebugChannel;
},
getOrCreateDebugChannelReadableWriterPair: function() {
return getOrCreateDebugChannelReadableWriterPair;
}
});
const _approuterheaders = require("../components/app-router-headers");
const _invarianterror = require("../../shared/lib/invariant-error");
const pairs = new Map();
/**
* Upper bound on the number of in-memory debug-channel pairs we retain, evicted
* least-recently-used, bounding the live per-request map.
*
* A pair must outlive its stream's close so a late decode of the same response
* (the primary decode plus stage extractions via `decodeStageUntilBoundary`,
* which can run after the channel closed over the WebSocket) still finds the
* buffered data. The cap only needs to exceed the pairs live or recently closed
* at once (bounded by how many prefetch/navigation requests are in flight
* together), so a few dozen leaves ample headroom even for the segment-heavy
* bursts the Instant Navs DevTools capture can produce.
*/ const MAX_DEBUG_CHANNEL_PAIRS = 64;
const DB_NAME = '__next_debug_channel';
const STORE_NAME = 'channels';
const CREATED_AT_INDEX = 'createdAt';
/**
* Upper bound on persisted document debug channels in IndexedDB (one per
* document, kept for HTTP-cache restore), evicted oldest-first.
*/ const MAX_PERSISTED_DOCUMENT_CHANNELS = 10;
function openDebugChannelDB() {
return new Promise((resolve, reject)=>{
const openRequest = indexedDB.open(DB_NAME, 1);
openRequest.onupgradeneeded = ()=>{
const store = openRequest.result.createObjectStore(STORE_NAME, {
keyPath: 'requestId'
});
store.createIndex(CREATED_AT_INDEX, 'createdAt');
};
openRequest.onsuccess = ()=>resolve(openRequest.result);
openRequest.onerror = ()=>reject(openRequest.error);
openRequest.onblocked = ()=>reject(openRequest.error);
});
}
/**
* Resolves on the next idle period via `requestIdleCallback`, falling back to a
* `setTimeout` where `requestIdleCallback` is unavailable.
*/ function whenIdle() {
return new Promise((resolve)=>{
if (typeof requestIdleCallback === 'function') {
requestIdleCallback(()=>resolve());
} else {
setTimeout(resolve, 0);
}
});
}
async function persistDebugChannelToIndexedDB(requestId, chunks) {
let db;
try {
db = await openDebugChannelDB();
} catch (error) {
console.debug('Failed to open debug channel IndexedDB for write', error);
return;
}
try {
await new Promise((resolve, reject)=>{
const transaction = db.transaction(STORE_NAME, 'readwrite');
const store = transaction.objectStore(STORE_NAME);
store.put({
requestId,
createdAt: Date.now(),
chunks
});
// Prune oldest entries beyond the cap to bound storage growth across tabs
// and/or page loads. The createdAt index gives ordered traversal without
// scanning, and the cursor deletes commit atomically with the put above.
const countReq = store.count();
countReq.onsuccess = ()=>{
let entriesToDelete = countReq.result - MAX_PERSISTED_DOCUMENT_CHANNELS;
if (entriesToDelete <= 0) {
return;
}
const cursorReq = store.index(CREATED_AT_INDEX).openCursor();
cursorReq.onsuccess = ()=>{
const cursor = cursorReq.result;
if (!cursor || entriesToDelete === 0) {
return;
}
cursor.delete();
entriesToDelete--;
cursor.continue();
};
};
transaction.oncomplete = ()=>{
if (process.env.__NEXT_TEST_MODE) {
// Test-only flag, set once this document's debug channel entry is
// durably committed. Persistence is deferred to an idle callback and
// the IndexedDB write is async, so this flag lets e2e tests await
// persistence deterministically — coupling only to "an entry was
// persisted" and not to how or where it is stored. It resets
// naturally on each navigation since every document gets a fresh
// window. The local cast keeps the augmentation out of the shipped
// declaration files.
;
self.__NEXT_DEBUG_CHANNEL_PERSISTED = true;
}
resolve();
};
transaction.onerror = ()=>reject(transaction.error);
transaction.onabort = ()=>reject(transaction.error);
});
} catch (error) {
// Best-effort: if persistence fails (quota, transaction abort, etc.), an
// HTTP cache restore will fall back to location.reload() since no entry
// will be found.
console.debug('Failed to write debug channel entry to IndexedDB', error);
} finally{
db.close();
}
}
function restoreDebugChannelFromIndexedDB(requestId) {
return new ReadableStream({
async start (controller) {
let entry;
try {
const db = await openDebugChannelDB();
try {
entry = await new Promise((resolve, reject)=>{
const tx = db.transaction(STORE_NAME, 'readonly');
const store = tx.objectStore(STORE_NAME);
const getReq = store.get(requestId);
getReq.onsuccess = ()=>resolve(getReq.result);
getReq.onerror = ()=>reject(getReq.error);
});
} finally{
db.close();
}
} catch (error) {
// Treat any IDB failure as "no entry" and fall through to reload.
console.debug('Failed to read debug channel entry from IndexedDB', error);
}
if (!entry) {
// Debug channel can't be restored — missing debug chunks would block
// hydration. Force a fresh page load from the server. Leave the stream
// parked (no enqueue, no close) so the Flight client stays put until
// the reload tears the document down, instead of synchronously erroring
// with "Connection closed.".
location.reload();
return;
}
for (const chunk of entry.chunks){
controller.enqueue(chunk);
}
controller.close();
}
});
}
/**
* Decide at script-execution time whether the document was served from the
* browser's cache or freshly fetched from the server. `type === 'back_forward'`
* alone isn't enough: a back/forward navigation can also be a fresh server
* re-fetch when the HTTP cache entry was evicted (long-lived tab, storage
* pressure, manual cache clear), and treating that as a cache restore would
* trigger an unnecessary `location.reload()` when no persisted chunks are
* found.
*/ function wasServedFromCacheKnownAtExec(entry) {
if (!entry) {
return 1;
}
// Safari tab-duplication cache restore: type='navigate' paired with
// responseStart=0 (no first-body-byte over the network) and a non-zero
// responseEnd. Fresh navigations always have responseStart > 0.
if (entry.type === 'navigate' && entry.responseStart === 0 && entry.responseEnd > 0) {
return 0;
}
// Every remaining cache-restore signal requires a back/forward navigation.
// (bfcache restores don't re-execute scripts and never reach this code.)
if (entry.type !== 'back_forward') {
return 1;
}
// Chrome ≥109 and Safari ≥17 populate `deliveryType` at exec time even when
// the size fields aren't filled in yet. This is the only exec-time fast path
// for real Safari ≥17 cache restores (Safari leaves encodedBodySize at 0 at
// exec).
if (entry.deliveryType === 'cache') {
return 0;
}
// Chrome and Firefox publish an HTTP cache restore as transferSize=0 (no
// bytes over the wire) plus a non-zero cached body size at exec time.
if (entry.transferSize === 0 && entry.encodedBodySize > 0) {
return 0;
}
// No body bytes measured yet. Either the response is still streaming, or
// WebKit is reporting transferSize=0 and encodedBodySize=0 at exec time
// regardless of whether the document was cached or re-fetched. Defer to
// `pageshow` where the two cases become distinguishable.
if (entry.encodedBodySize === 0) {
return 2;
}
// Body bytes already measured at exec time with no other cache signal: a
// re-fetched back-nav whose response happened to complete before our script
// ran. The deferred branch above would have caught the same case if the
// response had still been streaming.
return 1;
}
/**
* Re-check the cache-restore decision at `pageshow`, when every browser has
* populated the navigation-entry size fields. Only called when
* `wasServedFromCacheKnownAtExec` returned `ExecTimeCacheDecision.Undecided`.
*/ function wasServedFromCacheAtPageshow(entry) {
if (!entry) {
return false;
}
// Safari tab-duplication signature; see the matching branch in
// `wasServedFromCacheKnownAtExec`.
if (entry.type === 'navigate' && entry.responseStart === 0 && entry.responseEnd > 0) {
return true;
}
// A back/forward navigation where at least one of the size fields is zero
// means the body didn't come over the wire. Browsers signal a cache restore
// differently — Chrome/Firefox zero `transferSize` and keep a non-zero cached
// `encodedBodySize`; Safari does the inverse with a small `transferSize`
// (header overhead) and `encodedBodySize=0`; WebKit under Playwright zeros
// both. A fresh re-fetch populates both with the response size.
return entry.type === 'back_forward' && (entry.transferSize === 0 || entry.encodedBodySize === 0);
}
function getNavigationEntry() {
try {
return performance.getEntriesByType('navigation')[0];
} catch {
return undefined;
}
}
/**
* Reclaim the least-recently-used debug-channel pairs once the map exceeds
* `MAX_DEBUG_CHANNEL_PAIRS`. The map is iterated in insertion order and we
* re-insert entries on access (see
* `getOrCreateDebugChannelReadableWriterPair`), so the least-recently-used
* pairs sit at the front. Evicting only ever affects future lookups for that
* request id; consumers that already hold a tee branch keep reading
* independently of the map.
*/ function evictExcessDebugChannelPairs() {
while(pairs.size > MAX_DEBUG_CHANNEL_PAIRS){
const oldestRequestId = pairs.keys().next().value;
if (oldestRequestId === undefined) {
break;
}
pairs.delete(oldestRequestId);
}
}
function getOrCreateDebugChannelReadableWriterPair(requestId) {
const existingPair = pairs.get(requestId);
if (existingPair) {
// Refresh the LRU recency of an already-known channel by re-inserting it at
// the most-recent position, so a channel that's still being written to or
// read from isn't evicted while a late consumer (e.g. a stage re-decode of
// the same response) still needs it.
pairs.delete(requestId);
pairs.set(requestId, existingPair);
return existingPair;
}
// Buffer chunks only for the initial document's debug channel, not for
// client-side navigation requests. Persisted to IndexedDB once complete so it
// can be restored when the browser serves the page from HTTP cache
// (back-forward navigation, tab duplication, etc.).
const chunks = requestId === self.__next_r ? [] : null;
const { readable, writable } = new TransformStream({
transform (chunk, controller) {
if (chunks) {
chunks.push(chunk.slice());
}
controller.enqueue(chunk);
}
});
const pair = {
readable,
writer: writable.getWriter()
};
pairs.set(requestId, pair);
// Retain the pair past its stream's close (see MAX_DEBUG_CHANNEL_PAIRS) and
// bound the map by reclaiming the least-recently-used.
evictExcessDebugChannelPairs();
pair.writer.closed.then(async ()=>{
if (!chunks) {
return;
}
// The initial document's debug stream closes while hydration is still
// running, so persisting here would steal main-thread time from it. Wait
// for genuine idle (no timeout): persistence is best-effort, so if the
// page never idles before navigation we skip it and a later restore falls
// back to a reload, rather than forcing a blocking write.
await whenIdle();
await persistDebugChannelToIndexedDB(requestId, chunks);
}).catch((error)=>{
// writer.closed rejected (e.g., stream aborted), nothing to persist.
console.debug('Debug channel writer closed with error', error);
}).finally(()=>{
// Keep the now-closed pair in the map so late decodes of this request
// still resolve against its buffered stream; it's reclaimed later by LRU
// eviction. Release the IndexedDB staging buffer now that it's persisted.
if (chunks) {
chunks.length = 0;
}
});
return pair;
}
function createDebugChannel(requestHeaders) {
let requestId;
if (requestHeaders) {
requestId = requestHeaders[_approuterheaders.NEXT_REQUEST_ID_HEADER] ?? undefined;
if (!requestId) {
throw Object.defineProperty(new _invarianterror.InvariantError(`Expected a ${JSON.stringify(_approuterheaders.NEXT_REQUEST_ID_HEADER)} request header.`), "__NEXT_ERROR_CODE", {
value: "E854",
enumerable: false,
configurable: true
});
}
} else {
requestId = self.__next_r;
if (!requestId) {
throw Object.defineProperty(new _invarianterror.InvariantError(`Expected a request ID to be defined for the document via self.__next_r.`), "__NEXT_ERROR_CODE", {
value: "E806",
enumerable: false,
configurable: true
});
}
}
// Only attempt to restore the IndexedDB debug channel entry for the
// initial document load (no request headers). Client-side navigations pass
// request headers and should always use the WebSocket-backed debug channel.
if (!requestHeaders) {
switch(wasServedFromCacheKnownAtExec(getNavigationEntry())){
case 0:
return {
readable: restoreDebugChannelOrReload(requestId)
};
case 2:
// Body bytes haven't been measured on the navigation entry yet. Suspend
// the stream until pageshow, re-check there, then source from the
// persisted chunks or the WebSocket-backed pair accordingly.
return {
readable: createDeferredDebugChannelReadable(requestId)
};
case 1:
break;
}
}
const pair = getOrCreateDebugChannelReadableWriterPair(requestId);
// Hand out a fresh tee branch per consumer and keep the remainder for the
// next one (see the `readable` field doc above).
const [branch, rest] = pair.readable.tee();
pair.readable = rest;
return {
readable: branch
};
}
/**
* Try to restore the debug channel from the persisted chunks. If none are
* found, force a fresh page load.
*/ function restoreDebugChannelOrReload(requestId) {
const readable = restoreDebugChannelFromIndexedDB(requestId);
if (readable) {
return readable;
}
// No persisted entry. Typically this happens when the HTTP cache held the
// HTML but the persisted entry was never written, or was overwritten by a
// newer document in this tab.
location.reload();
// Never-closing stream. Keeps the Flight client suspended until the reload
// tears the document down, instead of letting it synchronously error with
// "Connection closed.".
return new ReadableStream();
}
/**
* Used when `wasServedFromCacheKnownAtExec` returns
* `ExecTimeCacheDecision.Undecided`. Waits for `pageshow`, re-runs the check,
* and forwards data from either the persisted chunks or the WebSocket.
*/ function createDeferredDebugChannelReadable(requestId) {
return new ReadableStream({
async start (controller) {
// By `pageshow` every browser has populated the navigation-entry size
// fields, so the re-check below is unambiguous.
await new Promise((resolve)=>{
window.addEventListener('pageshow', ()=>resolve(), {
once: true
});
});
const source = wasServedFromCacheAtPageshow(getNavigationEntry()) ? restoreDebugChannelOrReload(requestId) : getOrCreateDebugChannelReadableWriterPair(requestId).readable;
const reader = source.getReader();
try {
while(true){
const { done, value } = await reader.read();
if (done) {
controller.close();
return;
}
controller.enqueue(value);
}
} catch (error) {
controller.error(error);
} finally{
reader.releaseLock();
}
}
});
}
if ((typeof exports.default === 'function' || (typeof exports.default === 'object' && exports.default !== null)) && typeof exports.default.__esModule === 'undefined') {
Object.defineProperty(exports.default, '__esModule', { value: true });
Object.assign(exports.default, exports);
module.exports = exports.default;
}
//# sourceMappingURL=debug-channel.js.map