serviceWorker.js 20 KB

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