UNPKG

next

Version:

The React Framework

400 lines (399 loc) 17.8 kB
import { NEXT_REQUEST_ID_HEADER } from '../components/app-router-headers'; import { InvariantError } from '../../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); } } export 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; } export function createDebugChannel(requestHeaders) { let requestId; if (requestHeaders) { requestId = requestHeaders[NEXT_REQUEST_ID_HEADER] ?? undefined; if (!requestId) { throw Object.defineProperty(new InvariantError(`Expected a ${JSON.stringify(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(`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(); } } }); } //# sourceMappingURL=debug-channel.js.map