serviceWorker.js 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769
  1. import { v4 as uuid } from 'uuid'
  2. const OError = require('@overleaf/o-error')
  3. // VERSION should get incremented when making changes to caching behavior or
  4. // adjusting metrics collection.
  5. // Keep in sync with PdfJsMetrics.
  6. const VERSION = 2
  7. const CLEAR_CACHE_REQUEST_MATCHER = /^\/project\/[0-9a-f]{24}\/output$/
  8. const COMPILE_REQUEST_MATCHER = /^\/project\/[0-9a-f]{24}\/compile$/
  9. const PDF_REQUEST_MATCHER = /^\/project\/[0-9a-f]{24}\/.*\/output.pdf$/
  10. const PDF_JS_CHUNK_SIZE = 128 * 1024
  11. const MAX_SUBREQUEST_COUNT = 4
  12. const MAX_SUBREQUEST_BYTES = 4 * PDF_JS_CHUNK_SIZE
  13. const INCREMENTAL_CACHE_SIZE = 1000
  14. // Each compile request defines a context (essentially the specific pdf file for
  15. // that compile), requests for that pdf file can use the hashes in the compile
  16. // response, which are stored in the context.
  17. const CLIENT_CONTEXT = new Map()
  18. /**
  19. * @param {string} clientId
  20. */
  21. function getClientContext(clientId) {
  22. let clientContext = CLIENT_CONTEXT.get(clientId)
  23. if (!clientContext) {
  24. const cached = new Set()
  25. const pdfs = new Map()
  26. const metrics = {
  27. version: VERSION,
  28. id: uuid(),
  29. epoch: Date.now(),
  30. failedCount: 0,
  31. tooLargeOverheadCount: 0,
  32. tooManyRequestsCount: 0,
  33. cachedCount: 0,
  34. cachedBytes: 0,
  35. fetchedCount: 0,
  36. fetchedBytes: 0,
  37. requestedCount: 0,
  38. requestedBytes: 0,
  39. compileCount: 0,
  40. }
  41. clientContext = { pdfs, metrics, cached }
  42. CLIENT_CONTEXT.set(clientId, clientContext)
  43. // clean up old client maps
  44. expirePdfContexts()
  45. }
  46. return clientContext
  47. }
  48. /**
  49. * @param {string} clientId
  50. * @param {string} path
  51. * @param {Object} pdfContext
  52. */
  53. function registerPdfContext(clientId, path, pdfContext) {
  54. const clientContext = getClientContext(clientId)
  55. const { pdfs, metrics, cached, clsiServerId } = clientContext
  56. pdfContext.metrics = metrics
  57. pdfContext.cached = cached
  58. if (pdfContext.clsiServerId !== clsiServerId) {
  59. // VM changed, this invalidates all browser caches.
  60. clientContext.clsiServerId = pdfContext.clsiServerId
  61. cached.clear()
  62. }
  63. // we only need to keep the last 3 contexts
  64. for (const key of pdfs.keys()) {
  65. if (pdfs.size < 3) {
  66. break
  67. }
  68. pdfs.delete(key) // the map keys are returned in insertion order, so we are deleting the oldest entry here
  69. }
  70. pdfs.set(path, pdfContext)
  71. }
  72. /**
  73. * @param {string} clientId
  74. * @param {string} path
  75. */
  76. function getPdfContext(clientId, path) {
  77. const { pdfs } = getClientContext(clientId)
  78. return pdfs.get(path)
  79. }
  80. function expirePdfContexts() {
  81. // discard client maps for clients that are no longer connected
  82. const currentClientSet = new Set()
  83. self.clients.matchAll().then(function (clientList) {
  84. clientList.forEach(client => {
  85. currentClientSet.add(client.id)
  86. })
  87. CLIENT_CONTEXT.forEach((map, clientId) => {
  88. if (!currentClientSet.has(clientId)) {
  89. CLIENT_CONTEXT.delete(clientId)
  90. }
  91. })
  92. })
  93. }
  94. /**
  95. *
  96. * @param {Object} metrics
  97. * @param {number} size
  98. * @param {number} cachedCount
  99. * @param {number} cachedBytes
  100. * @param {number} fetchedCount
  101. * @param {number} fetchedBytes
  102. */
  103. function trackDownloadStats(
  104. metrics,
  105. { size, cachedCount, cachedBytes, fetchedCount, fetchedBytes }
  106. ) {
  107. metrics.cachedCount += cachedCount
  108. metrics.cachedBytes += cachedBytes
  109. metrics.fetchedCount += fetchedCount
  110. metrics.fetchedBytes += fetchedBytes
  111. metrics.requestedCount++
  112. metrics.requestedBytes += size
  113. }
  114. /**
  115. * @param {Object} metrics
  116. * @param {boolean} sizeDiffers
  117. * @param {boolean} mismatch
  118. * @param {boolean} success
  119. */
  120. function trackChunkVerify(metrics, { sizeDiffers, mismatch, success }) {
  121. if (sizeDiffers) {
  122. metrics.chunkVerifySizeDiffers |= 0
  123. metrics.chunkVerifySizeDiffers += 1
  124. }
  125. if (mismatch) {
  126. metrics.chunkVerifyMismatch |= 0
  127. metrics.chunkVerifyMismatch += 1
  128. }
  129. if (success) {
  130. metrics.chunkVerifySuccess |= 0
  131. metrics.chunkVerifySuccess += 1
  132. }
  133. }
  134. /**
  135. * @param {Array} chunks
  136. */
  137. function countBytes(chunks) {
  138. return chunks.reduce((totalBytes, chunk) => {
  139. return totalBytes + (chunk.end - chunk.start)
  140. }, 0)
  141. }
  142. /**
  143. * @param {FetchEvent} event
  144. */
  145. function onFetch(event) {
  146. const url = new URL(event.request.url)
  147. const path = url.pathname
  148. if (path.match(COMPILE_REQUEST_MATCHER)) {
  149. return processCompileRequest(event)
  150. }
  151. if (path.match(PDF_REQUEST_MATCHER)) {
  152. const ctx = getPdfContext(event.clientId, path)
  153. if (ctx) {
  154. return processPdfRequest(event, ctx)
  155. }
  156. }
  157. if (
  158. event.request.method === 'DELETE' &&
  159. path.match(CLEAR_CACHE_REQUEST_MATCHER)
  160. ) {
  161. return processClearCacheRequest(event)
  162. }
  163. // other request, ignore
  164. }
  165. /**
  166. * @param {FetchEvent} event
  167. */
  168. function processClearCacheRequest(event) {
  169. CLIENT_CONTEXT.delete(event.clientId)
  170. // use default request proxy.
  171. }
  172. /**
  173. * @param {FetchEvent} event
  174. */
  175. function processCompileRequest(event) {
  176. event.respondWith(
  177. fetch(event.request).then(response => {
  178. if (response.status !== 200) return response
  179. return response.json().then(body => {
  180. handleCompileResponse(event, response, body)
  181. // Send the service workers metrics to the frontend.
  182. const { metrics } = getClientContext(event.clientId)
  183. metrics.compileCount++
  184. body.serviceWorkerMetrics = metrics
  185. return new Response(JSON.stringify(body), response)
  186. })
  187. })
  188. )
  189. }
  190. /**
  191. * @param {Request} request
  192. * @param {Object} file
  193. * @return {Response}
  194. */
  195. function handleProbeRequest(request, file) {
  196. // PDF.js starts the pdf download with a probe request that has no
  197. // range headers on it.
  198. // Upon seeing the response headers, it decides whether to upgrade the
  199. // transport to chunked requests or keep reading the response body.
  200. // For small PDFs (2*chunkSize = 2*128kB) it just sends one request.
  201. // We will fetch all the ranges in bulk and emit them.
  202. // For large PDFs it sends this probe request, aborts that request before
  203. // reading any data and then sends multiple range requests.
  204. // It would be wasteful to action this probe request with all the ranges
  205. // that are available in the PDF and serve the full PDF content to
  206. // PDF.js for the probe request.
  207. // We are emitting a dummy response to the probe request instead.
  208. // It triggers the chunked transfer and subsequent fewer ranges need to be
  209. // requested -- only those of visible pages in the pdf viewer.
  210. // https://github.com/mozilla/pdf.js/blob/6fd899dc443425747098935207096328e7b55eb2/src/display/network_utils.js#L43-L47
  211. const pdfJSWillUseChunkedTransfer = file.size > 2 * PDF_JS_CHUNK_SIZE
  212. const isRangeRequest = request.headers.has('Range')
  213. if (!isRangeRequest && pdfJSWillUseChunkedTransfer) {
  214. const headers = new Headers()
  215. headers.set('Accept-Ranges', 'bytes')
  216. headers.set('Content-Length', file.size)
  217. headers.set('Content-Type', 'application/pdf')
  218. return new Response('', {
  219. headers,
  220. status: 200,
  221. statusText: 'OK',
  222. })
  223. }
  224. }
  225. /**
  226. *
  227. * @param {FetchEvent} event
  228. * @param {Object} file
  229. * @param {string} clsiServerId
  230. * @param {string} compileGroup
  231. * @param {Date} pdfCreatedAt
  232. * @param {Object} metrics
  233. * @param {Set} cached
  234. */
  235. function processPdfRequest(
  236. event,
  237. { file, clsiServerId, compileGroup, pdfCreatedAt, metrics, cached }
  238. ) {
  239. const response = handleProbeRequest(event.request, file)
  240. if (response) {
  241. return event.respondWith(response)
  242. }
  243. const verifyChunks = event.request.url.includes('verify_chunks=true')
  244. const rangeHeader =
  245. event.request.headers.get('Range') || `bytes=0-${file.size - 1}`
  246. const [start, last] = rangeHeader
  247. .slice('bytes='.length)
  248. .split('-')
  249. .map(i => parseInt(i, 10))
  250. const end = last + 1
  251. // Check that handling the range request won't trigger excessive subrequests,
  252. // (to avoid unwanted latency compared to the original request).
  253. const { chunks, newChunks } = cutRequestAmplification(
  254. getMatchingChunks(file.ranges, start, end),
  255. cached,
  256. metrics
  257. )
  258. const dynamicChunks = getInterleavingDynamicChunks(chunks, start, end)
  259. const chunksSize = countBytes(newChunks)
  260. const size = end - start
  261. if (chunks.length === 0 && dynamicChunks.length === 1) {
  262. // fall back to the original range request when no chunks are cached.
  263. trackDownloadStats(metrics, {
  264. size,
  265. cachedCount: 0,
  266. cachedBytes: 0,
  267. fetchedCount: 1,
  268. fetchedBytes: size,
  269. })
  270. return
  271. }
  272. if (
  273. chunksSize > MAX_SUBREQUEST_BYTES &&
  274. !(dynamicChunks.length === 0 && newChunks.length <= 1)
  275. ) {
  276. // fall back to the original range request when a very large amount of
  277. // object data would be requested, unless it is the only object in the
  278. // request or everything is already cached.
  279. metrics.tooLargeOverheadCount++
  280. trackDownloadStats(metrics, {
  281. size,
  282. cachedCount: 0,
  283. cachedBytes: 0,
  284. fetchedCount: 1,
  285. fetchedBytes: size,
  286. })
  287. return
  288. }
  289. // URL prefix is /project/:id/user/:id/build/... or /project/:id/build/...
  290. // for authenticated and unauthenticated users respectively.
  291. const perUserPrefix = file.url.slice(0, file.url.indexOf('/build/'))
  292. const byteRanges = dynamicChunks
  293. .map(chunk => `${chunk.start}-${chunk.end - 1}`)
  294. .join(',')
  295. const coalescedDynamicChunks = []
  296. switch (dynamicChunks.length) {
  297. case 0:
  298. break
  299. case 1:
  300. coalescedDynamicChunks.push({
  301. chunk: dynamicChunks[0],
  302. url: event.request.url,
  303. init: { headers: { Range: `bytes=${byteRanges}` } },
  304. })
  305. break
  306. default:
  307. coalescedDynamicChunks.push({
  308. chunk: dynamicChunks,
  309. url: event.request.url,
  310. init: { headers: { Range: `bytes=${byteRanges}` } },
  311. })
  312. }
  313. const requests = chunks
  314. .map(chunk => {
  315. const path = `${perUserPrefix}/content/${file.contentId}/${chunk.hash}`
  316. const url = new URL(path, event.request.url)
  317. if (clsiServerId) {
  318. url.searchParams.set('clsiserverid', clsiServerId)
  319. }
  320. if (compileGroup) {
  321. url.searchParams.set('compileGroup', compileGroup)
  322. }
  323. return { chunk, url: url.toString() }
  324. })
  325. .concat(coalescedDynamicChunks)
  326. let cachedCount = 0
  327. let cachedBytes = 0
  328. let fetchedCount = 0
  329. let fetchedBytes = 0
  330. const reAssembledBlob = new Uint8Array(size)
  331. event.respondWith(
  332. Promise.all(
  333. requests.map(({ chunk, url, init }) =>
  334. fetch(url, init)
  335. .then(response => {
  336. if (!(response.status === 206 || response.status === 200)) {
  337. throw new OError(
  338. 'non successful response status: ' + response.status
  339. )
  340. }
  341. const boundary = getMultipartBoundary(response)
  342. if (Array.isArray(chunk) && !boundary) {
  343. throw new OError('missing boundary on multipart request', {
  344. headers: Object.fromEntries(response.headers.entries()),
  345. chunk,
  346. })
  347. }
  348. const blobFetchDate = getServerTime(response)
  349. const blobSize = getResponseSize(response)
  350. if (blobFetchDate && blobSize) {
  351. const chunkSize =
  352. Math.min(end, chunk.end) - Math.max(start, chunk.start)
  353. // Example: 2MB PDF, 1MB image, 128KB PDF.js chunk.
  354. // | pdf.js chunk |
  355. // | A BIG IMAGE BLOB |
  356. // | THE FULL PDF |
  357. if (blobFetchDate < pdfCreatedAt) {
  358. cachedCount++
  359. cachedBytes += chunkSize
  360. // Roll the position of the hash in the Map.
  361. cached.delete(chunk.hash)
  362. cached.add(chunk.hash)
  363. } else {
  364. // Blobs are fetched in bulk.
  365. fetchedCount++
  366. fetchedBytes += blobSize
  367. }
  368. }
  369. return response
  370. .blob()
  371. .then(blob => blob.arrayBuffer())
  372. .then(arraybuffer => {
  373. return {
  374. boundary,
  375. chunk,
  376. data: backFillObjectContext(chunk, arraybuffer),
  377. }
  378. })
  379. })
  380. .catch(error => {
  381. throw OError.tag(error, 'cannot fetch chunk', { url })
  382. })
  383. )
  384. )
  385. .then(rawResponses => {
  386. const responses = []
  387. for (const response of rawResponses) {
  388. if (response.boundary) {
  389. responses.push(
  390. ...getMultiPartResponses(response, file, metrics, verifyChunks)
  391. )
  392. } else {
  393. responses.push(response)
  394. }
  395. }
  396. responses.forEach(({ chunk, data }) => {
  397. // overlap:
  398. // | REQUESTED_RANGE |
  399. // | CHUNK |
  400. const offsetStart = Math.max(start - chunk.start, 0)
  401. // overlap:
  402. // | REQUESTED_RANGE |
  403. // | CHUNK |
  404. const offsetEnd = Math.max(chunk.end - end, 0)
  405. if (offsetStart > 0 || offsetEnd > 0) {
  406. // compute index positions for slice to handle case where offsetEnd=0
  407. const chunkSize = chunk.end - chunk.start
  408. data = data.subarray(offsetStart, chunkSize - offsetEnd)
  409. }
  410. const insertPosition = Math.max(chunk.start - start, 0)
  411. reAssembledBlob.set(data, insertPosition)
  412. })
  413. let verifyProcess = Promise.resolve(reAssembledBlob)
  414. if (verifyChunks) {
  415. verifyProcess = fetch(event.request)
  416. .then(response => response.arrayBuffer())
  417. .then(arrayBuffer => {
  418. const fullBlob = new Uint8Array(arrayBuffer)
  419. const stats = {}
  420. if (reAssembledBlob.byteLength !== fullBlob.byteLength) {
  421. stats.sizeDiffers = true
  422. } else if (
  423. !reAssembledBlob.every((v, idx) => v === fullBlob[idx])
  424. ) {
  425. stats.mismatch = true
  426. } else {
  427. stats.success = true
  428. }
  429. trackChunkVerify(metrics, stats)
  430. if (stats.success === true) {
  431. return reAssembledBlob
  432. } else {
  433. return fullBlob
  434. }
  435. })
  436. }
  437. return verifyProcess.then(blob => {
  438. trackDownloadStats(metrics, {
  439. size,
  440. cachedCount,
  441. cachedBytes,
  442. fetchedCount,
  443. fetchedBytes,
  444. })
  445. return new Response(blob, {
  446. status: 206,
  447. headers: {
  448. 'Accept-Ranges': 'bytes',
  449. 'Content-Length': size,
  450. 'Content-Range': `bytes ${start}-${last}/${file.size}`,
  451. 'Content-Type': 'application/pdf',
  452. },
  453. })
  454. })
  455. })
  456. .catch(error => {
  457. fetchedBytes += size
  458. metrics.failedCount++
  459. trackDownloadStats(metrics, {
  460. size,
  461. cachedCount: 0,
  462. cachedBytes: 0,
  463. fetchedCount,
  464. fetchedBytes,
  465. })
  466. reportError(event, OError.tag(error, 'failed to compose pdf response'))
  467. return fetch(event.request)
  468. })
  469. )
  470. }
  471. /**
  472. *
  473. * @param {Response} response
  474. */
  475. function getServerTime(response) {
  476. const raw = response.headers.get('Date')
  477. if (!raw) return new Date()
  478. return new Date(raw)
  479. }
  480. /**
  481. *
  482. * @param {Response} response
  483. */
  484. function getResponseSize(response) {
  485. const raw = response.headers.get('Content-Length')
  486. if (!raw) return 0
  487. return parseInt(raw, 10)
  488. }
  489. /**
  490. *
  491. * @param {Response} response
  492. */
  493. function getMultipartBoundary(response) {
  494. const raw = response.headers.get('Content-Type')
  495. if (!raw.includes('multipart/byteranges')) return ''
  496. const idx = raw.indexOf('boundary=')
  497. if (idx === -1) return ''
  498. return raw.slice(idx + 'boundary='.length)
  499. }
  500. /**
  501. * @param {Object} response
  502. * @param {Object} file
  503. * @param {Object} metrics
  504. * @param {boolean} verifyChunks
  505. */
  506. function getMultiPartResponses(response, file, metrics, verifyChunks) {
  507. const { chunk: chunks, data, boundary } = response
  508. const responses = []
  509. let offsetStart = 0
  510. for (const chunk of chunks) {
  511. const header = `\r\n--${boundary}\r\nContent-Type: application/pdf\r\nContent-Range: bytes ${
  512. chunk.start
  513. }-${chunk.end - 1}/${file.size}\r\n\r\n`
  514. const headerSize = header.length
  515. // Verify header content. A proxy might have tampered with it.
  516. const headerRaw = ENCODER.encode(header)
  517. if (
  518. !data
  519. .subarray(offsetStart, offsetStart + headerSize)
  520. .every((v, idx) => v === headerRaw[idx])
  521. ) {
  522. metrics.headerVerifyFailure |= 0
  523. metrics.headerVerifyFailure++
  524. throw new OError('multipart response header does not match', {
  525. actual: new TextDecoder().decode(
  526. data.subarray(offsetStart, offsetStart + headerSize)
  527. ),
  528. expected: header,
  529. })
  530. }
  531. offsetStart += headerSize
  532. const chunkSize = chunk.end - chunk.start
  533. responses.push({
  534. chunk,
  535. data: data.subarray(offsetStart, offsetStart + chunkSize),
  536. })
  537. offsetStart += chunkSize
  538. }
  539. return responses
  540. }
  541. /**
  542. * @param {FetchEvent} event
  543. * @param {Response} response
  544. * @param {Object} body
  545. */
  546. function handleCompileResponse(event, response, body) {
  547. if (!body || body.status !== 'success') return
  548. const pdfCreatedAt = getServerTime(response)
  549. for (const file of body.outputFiles) {
  550. if (file.path !== 'output.pdf') continue // not the pdf used for rendering
  551. if (file.ranges) {
  552. file.ranges.forEach(backFillEdgeBounds)
  553. const { clsiServerId, compileGroup } = body
  554. registerPdfContext(event.clientId, file.url, {
  555. pdfCreatedAt,
  556. file,
  557. clsiServerId,
  558. compileGroup,
  559. })
  560. }
  561. break
  562. }
  563. }
  564. const ENCODER = new TextEncoder()
  565. function backFillEdgeBounds(chunk) {
  566. if (chunk.objectId) {
  567. chunk.objectId = ENCODER.encode(chunk.objectId)
  568. chunk.start -= chunk.objectId.byteLength
  569. }
  570. return chunk
  571. }
  572. /**
  573. * @param chunk
  574. * @param {ArrayBuffer} arrayBuffer
  575. * @return {Uint8Array}
  576. */
  577. function backFillObjectContext(chunk, arrayBuffer) {
  578. if (!chunk.objectId) {
  579. // This is a dynamic chunk
  580. return new Uint8Array(arrayBuffer)
  581. }
  582. const { start, end, objectId } = chunk
  583. const header = Uint8Array.from(objectId)
  584. const fullBuffer = new Uint8Array(end - start)
  585. fullBuffer.set(header, 0)
  586. fullBuffer.set(new Uint8Array(arrayBuffer), objectId.length)
  587. return fullBuffer
  588. }
  589. /**
  590. * @param {Array} chunks
  591. * @param {number} start
  592. * @param {number} end
  593. * @returns {Array}
  594. */
  595. function getMatchingChunks(chunks, start, end) {
  596. const matchingChunks = []
  597. for (const chunk of chunks) {
  598. if (chunk.end <= start) {
  599. // no overlap:
  600. // | REQUESTED_RANGE |
  601. // | CHUNK |
  602. continue
  603. }
  604. if (chunk.start >= end) {
  605. // no overlap:
  606. // | REQUESTED_RANGE |
  607. // | CHUNK |
  608. break
  609. }
  610. matchingChunks.push(chunk)
  611. }
  612. return matchingChunks
  613. }
  614. /**
  615. * @param {Array} potentialChunks
  616. * @param {Set} cached
  617. * @param {Object} metrics
  618. */
  619. function cutRequestAmplification(potentialChunks, cached, metrics) {
  620. const chunks = []
  621. const newChunks = []
  622. let tooManyRequests = false
  623. for (const chunk of potentialChunks) {
  624. if (cached.has(chunk.hash)) {
  625. chunks.push(chunk)
  626. continue
  627. }
  628. if (newChunks.length < MAX_SUBREQUEST_COUNT) {
  629. chunks.push(chunk)
  630. newChunks.push(chunk)
  631. } else {
  632. tooManyRequests = true
  633. }
  634. }
  635. if (tooManyRequests) {
  636. metrics.tooManyRequestsCount++
  637. }
  638. if (cached.size > INCREMENTAL_CACHE_SIZE) {
  639. for (const key of cached) {
  640. if (cached.size < INCREMENTAL_CACHE_SIZE) {
  641. break
  642. }
  643. // Map keys are stored in insertion order.
  644. // We re-insert keys on cache hit, 'cached' is a cheap LRU.
  645. cached.delete(key)
  646. }
  647. }
  648. return { chunks, newChunks }
  649. }
  650. /**
  651. * @param {Array} chunks
  652. * @param {number} start
  653. * @param {number} end
  654. * @returns {Array}
  655. */
  656. function getInterleavingDynamicChunks(chunks, start, end) {
  657. const dynamicChunks = []
  658. for (const chunk of chunks) {
  659. if (start < chunk.start) {
  660. dynamicChunks.push({ start, end: chunk.start })
  661. }
  662. start = chunk.end
  663. }
  664. if (start < end) {
  665. dynamicChunks.push({ start, end })
  666. }
  667. return dynamicChunks
  668. }
  669. /**
  670. * @param {FetchEvent} event
  671. */
  672. function onFetchWithErrorHandling(event) {
  673. try {
  674. onFetch(event)
  675. } catch (error) {
  676. reportError(event, OError.tag(error, 'low level error in onFetch'))
  677. }
  678. }
  679. // allow fetch event listener to be removed if necessary
  680. const controller = new AbortController()
  681. // listen to all network requests
  682. self.addEventListener('fetch', onFetchWithErrorHandling, {
  683. signal: controller.signal,
  684. })
  685. // complete setup ASAP
  686. self.addEventListener('install', event => {
  687. event.waitUntil(self.skipWaiting())
  688. })
  689. self.addEventListener('activate', event => {
  690. event.waitUntil(self.clients.claim())
  691. })
  692. self.addEventListener('message', event => {
  693. if (event.data && event.data.type === 'disable') {
  694. controller.abort() // removes the fetch event listener
  695. }
  696. })
  697. /**
  698. *
  699. * @param {FetchEvent} event
  700. * @param {Error} error
  701. */
  702. function reportError(event, error) {
  703. self.clients
  704. .get(event.clientId)
  705. .then(client => {
  706. if (!client) {
  707. // The client disconnected.
  708. return
  709. }
  710. client.postMessage(
  711. JSON.stringify({
  712. extra: { url: event.request.url, info: OError.getFullInfo(error) },
  713. error: {
  714. name: error.name,
  715. message: error.message,
  716. stack: OError.getFullStack(error),
  717. },
  718. })
  719. )
  720. })
  721. .catch(() => {})
  722. }