serviceWorker.js 21 KB

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