serviceWorker.js 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558
  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 = 1
  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. // Each compile request defines a context (essentially the specific pdf file for
  12. // that compile), requests for that pdf file can use the hashes in the compile
  13. // response, which are stored in the context.
  14. const CLIENT_CONTEXT = new Map()
  15. /**
  16. * @param {string} clientId
  17. */
  18. function getClientContext(clientId) {
  19. let clientContext = CLIENT_CONTEXT.get(clientId)
  20. if (!clientContext) {
  21. const pdfs = new Map()
  22. const metrics = {
  23. version: VERSION,
  24. id: uuid(),
  25. epoch: Date.now(),
  26. cachedBytes: 0,
  27. fetchedBytes: 0,
  28. requestedBytes: 0,
  29. }
  30. clientContext = { pdfs, metrics }
  31. CLIENT_CONTEXT.set(clientId, clientContext)
  32. // clean up old client maps
  33. expirePdfContexts()
  34. }
  35. return clientContext
  36. }
  37. /**
  38. * @param {string} clientId
  39. * @param {string} path
  40. * @param {Object} pdfContext
  41. */
  42. function registerPdfContext(clientId, path, pdfContext) {
  43. const clientContext = getClientContext(clientId)
  44. const { pdfs, metrics } = clientContext
  45. pdfContext.metrics = metrics
  46. // we only need to keep the last 3 contexts
  47. for (const key of pdfs.keys()) {
  48. if (pdfs.size < 3) {
  49. break
  50. }
  51. pdfs.delete(key) // the map keys are returned in insertion order, so we are deleting the oldest entry here
  52. }
  53. pdfs.set(path, pdfContext)
  54. }
  55. /**
  56. * @param {string} clientId
  57. * @param {string} path
  58. */
  59. function getPdfContext(clientId, path) {
  60. const { pdfs } = getClientContext(clientId)
  61. return pdfs.get(path)
  62. }
  63. function expirePdfContexts() {
  64. // discard client maps for clients that are no longer connected
  65. const currentClientSet = new Set()
  66. self.clients.matchAll().then(function (clientList) {
  67. clientList.forEach(client => {
  68. currentClientSet.add(client.id)
  69. })
  70. CLIENT_CONTEXT.forEach((map, clientId) => {
  71. if (!currentClientSet.has(clientId)) {
  72. CLIENT_CONTEXT.delete(clientId)
  73. }
  74. })
  75. })
  76. }
  77. /**
  78. *
  79. * @param {Object} metrics
  80. * @param {number} size
  81. * @param {number} cachedBytes
  82. * @param {number} fetchedBytes
  83. */
  84. function trackDownloadStats(metrics, { size, cachedBytes, fetchedBytes }) {
  85. metrics.cachedBytes += cachedBytes
  86. metrics.fetchedBytes += fetchedBytes
  87. metrics.requestedBytes += size
  88. }
  89. /**
  90. * @param {Object} metrics
  91. * @param {boolean} sizeDiffers
  92. * @param {boolean} mismatch
  93. * @param {boolean} success
  94. */
  95. function trackChunkVerify(metrics, { sizeDiffers, mismatch, success }) {
  96. if (sizeDiffers) {
  97. metrics.chunkVerifySizeDiffers |= 0
  98. metrics.chunkVerifySizeDiffers += 1
  99. }
  100. if (mismatch) {
  101. metrics.chunkVerifyMismatch |= 0
  102. metrics.chunkVerifyMismatch += 1
  103. }
  104. if (success) {
  105. metrics.chunkVerifySuccess |= 0
  106. metrics.chunkVerifySuccess += 1
  107. }
  108. }
  109. /**
  110. * @param {Array} chunks
  111. */
  112. function countBytes(chunks) {
  113. return chunks.reduce((totalBytes, chunk) => {
  114. return totalBytes + (chunk.end - chunk.start)
  115. }, 0)
  116. }
  117. /**
  118. * @param {FetchEvent} event
  119. */
  120. function onFetch(event) {
  121. const url = new URL(event.request.url)
  122. const path = url.pathname
  123. if (path.match(COMPILE_REQUEST_MATCHER)) {
  124. return processCompileRequest(event)
  125. }
  126. if (path.match(PDF_REQUEST_MATCHER)) {
  127. const ctx = getPdfContext(event.clientId, path)
  128. if (ctx) {
  129. return processPdfRequest(event, ctx)
  130. }
  131. }
  132. // other request, ignore
  133. }
  134. /**
  135. * @param {FetchEvent} event
  136. */
  137. function processCompileRequest(event) {
  138. event.respondWith(
  139. fetch(event.request).then(response => {
  140. if (response.status !== 200) return response
  141. return response.json().then(body => {
  142. handleCompileResponse(event, response, body)
  143. // Send the service workers metrics to the frontend.
  144. const { metrics } = getClientContext(event.clientId)
  145. body.serviceWorkerMetrics = metrics
  146. return new Response(JSON.stringify(body), response)
  147. })
  148. })
  149. )
  150. }
  151. /**
  152. * @param {Request} request
  153. * @param {Object} file
  154. * @return {Response}
  155. */
  156. function handleProbeRequest(request, file) {
  157. // PDF.js starts the pdf download with a probe request that has no
  158. // range headers on it.
  159. // Upon seeing the response headers, it decides whether to upgrade the
  160. // transport to chunked requests or keep reading the response body.
  161. // For small PDFs (2*chunkSize = 2*128kB) it just sends one request.
  162. // We will fetch all the ranges in bulk and emit them.
  163. // For large PDFs it sends this probe request, aborts that request before
  164. // reading any data and then sends multiple range requests.
  165. // It would be wasteful to action this probe request with all the ranges
  166. // that are available in the PDF and serve the full PDF content to
  167. // PDF.js for the probe request.
  168. // We are emitting a dummy response to the probe request instead.
  169. // It triggers the chunked transfer and subsequent fewer ranges need to be
  170. // requested -- only those of visible pages in the pdf viewer.
  171. // https://github.com/mozilla/pdf.js/blob/6fd899dc443425747098935207096328e7b55eb2/src/display/network_utils.js#L43-L47
  172. const pdfJSWillUseChunkedTransfer = file.size > 2 * PDF_JS_CHUNK_SIZE
  173. const isRangeRequest = request.headers.has('Range')
  174. if (!isRangeRequest && pdfJSWillUseChunkedTransfer) {
  175. const headers = new Headers()
  176. headers.set('Accept-Ranges', 'bytes')
  177. headers.set('Content-Length', file.size)
  178. headers.set('Content-Type', 'application/pdf')
  179. return new Response('', {
  180. headers,
  181. status: 200,
  182. statusText: 'OK',
  183. })
  184. }
  185. }
  186. /**
  187. *
  188. * @param {FetchEvent} event
  189. * @param {Object} file
  190. * @param {string} clsiServerId
  191. * @param {string} compileGroup
  192. * @param {Date} pdfCreatedAt
  193. * @param {Object} metrics
  194. */
  195. function processPdfRequest(
  196. event,
  197. { file, clsiServerId, compileGroup, pdfCreatedAt, metrics }
  198. ) {
  199. const response = handleProbeRequest(event.request, file)
  200. if (response) {
  201. return event.respondWith(response)
  202. }
  203. const verifyChunks = event.request.url.includes('verify_chunks=true')
  204. const rangeHeader =
  205. event.request.headers.get('Range') || `bytes=0-${file.size - 1}`
  206. const [start, last] = rangeHeader
  207. .slice('bytes='.length)
  208. .split('-')
  209. .map(i => parseInt(i, 10))
  210. const end = last + 1
  211. // Check that handling the range request won't trigger excessive subrequests,
  212. // (to avoid unwanted latency compared to the original request).
  213. const chunks = getMatchingChunks(file.ranges, start, end)
  214. const dynamicChunks = getInterleavingDynamicChunks(chunks, start, end)
  215. const chunksSize = countBytes(chunks)
  216. const size = end - start
  217. if (chunks.length + dynamicChunks.length > MAX_SUBREQUEST_COUNT) {
  218. // fall back to the original range request when splitting the range creates
  219. // too many subrequests.
  220. trackDownloadStats(metrics, { size, cachedBytes: 0, fetchedBytes: size })
  221. return
  222. }
  223. if (
  224. chunksSize > MAX_SUBREQUEST_BYTES &&
  225. !(dynamicChunks.length === 0 && chunks.length === 1)
  226. ) {
  227. // fall back to the original range request when a very large amount of
  228. // object data would be requested, unless it is the only object in the
  229. // request.
  230. trackDownloadStats(metrics, { size, cachedBytes: 0, fetchedBytes: size })
  231. return
  232. }
  233. // URL prefix is /project/:id/user/:id/build/... or /project/:id/build/...
  234. // for authenticated and unauthenticated users respectively.
  235. const perUserPrefix = file.url.slice(0, file.url.indexOf('/build/'))
  236. const requests = chunks
  237. .map(chunk => {
  238. const path = `${perUserPrefix}/content/${file.contentId}/${chunk.hash}`
  239. const url = new URL(path, event.request.url)
  240. if (clsiServerId) {
  241. url.searchParams.set('clsiserverid', clsiServerId)
  242. }
  243. if (compileGroup) {
  244. url.searchParams.set('compileGroup', compileGroup)
  245. }
  246. return { chunk, url: url.toString() }
  247. })
  248. .concat(
  249. dynamicChunks.map(chunk => {
  250. const { start, end } = chunk
  251. return {
  252. chunk,
  253. url: event.request.url,
  254. init: { headers: { Range: `bytes=${start}-${end - 1}` } },
  255. }
  256. })
  257. )
  258. let cachedBytes = 0
  259. let fetchedBytes = 0
  260. const reAssembledBlob = new Uint8Array(size)
  261. event.respondWith(
  262. Promise.all(
  263. requests.map(({ chunk, url, init }) =>
  264. fetch(url, init)
  265. .then(response => {
  266. if (!(response.status === 206 || response.status === 200)) {
  267. throw new OError(
  268. 'non successful response status: ' + response.status
  269. )
  270. }
  271. const blobFetchDate = getServerTime(response)
  272. const blobSize = getResponseSize(response)
  273. if (blobFetchDate && blobSize) {
  274. const chunkSize =
  275. Math.min(end, chunk.end) - Math.max(start, chunk.start)
  276. // Example: 2MB PDF, 1MB image, 128KB PDF.js chunk.
  277. // | pdf.js chunk |
  278. // | A BIG IMAGE BLOB |
  279. // | THE FULL PDF |
  280. if (blobFetchDate < pdfCreatedAt) {
  281. cachedBytes += chunkSize
  282. } else {
  283. // Blobs are fetched in bulk.
  284. fetchedBytes += blobSize
  285. }
  286. }
  287. return response.arrayBuffer()
  288. })
  289. .then(arrayBuffer => {
  290. return {
  291. chunk,
  292. data: backFillObjectContext(chunk, arrayBuffer),
  293. }
  294. })
  295. .catch(error => {
  296. throw OError.tag(error, 'cannot fetch chunk', { url })
  297. })
  298. )
  299. )
  300. .then(responses => {
  301. responses.forEach(({ chunk, data }) => {
  302. // overlap:
  303. // | REQUESTED_RANGE |
  304. // | CHUNK |
  305. const offsetStart = Math.max(start - chunk.start, 0)
  306. // overlap:
  307. // | REQUESTED_RANGE |
  308. // | CHUNK |
  309. const offsetEnd = Math.max(chunk.end - end, 0)
  310. if (offsetStart > 0 || offsetEnd > 0) {
  311. // compute index positions for slice to handle case where offsetEnd=0
  312. const chunkSize = chunk.end - chunk.start
  313. data = data.slice(offsetStart, chunkSize - offsetEnd)
  314. }
  315. const insertPosition = Math.max(chunk.start - start, 0)
  316. reAssembledBlob.set(data, insertPosition)
  317. })
  318. let verifyProcess = Promise.resolve(reAssembledBlob)
  319. if (verifyChunks) {
  320. verifyProcess = fetch(event.request)
  321. .then(response => response.arrayBuffer())
  322. .then(arrayBuffer => {
  323. const fullBlob = new Uint8Array(arrayBuffer)
  324. const stats = {}
  325. if (reAssembledBlob.byteLength !== fullBlob.byteLength) {
  326. stats.sizeDiffers = true
  327. } else if (
  328. !reAssembledBlob.every((v, idx) => v === fullBlob[idx])
  329. ) {
  330. stats.mismatch = true
  331. } else {
  332. stats.success = true
  333. }
  334. trackChunkVerify(metrics, stats)
  335. if (stats.success === true) {
  336. return reAssembledBlob
  337. } else {
  338. return fullBlob
  339. }
  340. })
  341. }
  342. return verifyProcess.then(blob => {
  343. trackDownloadStats(metrics, { size, cachedBytes, fetchedBytes })
  344. return new Response(blob, {
  345. status: 206,
  346. headers: {
  347. 'Accept-Ranges': 'bytes',
  348. 'Content-Length': size,
  349. 'Content-Range': `bytes ${start}-${last}/${file.size}`,
  350. 'Content-Type': 'application/pdf',
  351. },
  352. })
  353. })
  354. })
  355. .catch(error => {
  356. fetchedBytes += size
  357. trackDownloadStats(metrics, { size, cachedBytes: 0, fetchedBytes })
  358. reportError(event, OError.tag(error, 'failed to compose pdf response'))
  359. return fetch(event.request)
  360. })
  361. )
  362. }
  363. /**
  364. *
  365. * @param {Response} response
  366. */
  367. function getServerTime(response) {
  368. const raw = response.headers.get('Date')
  369. if (!raw) return new Date()
  370. return new Date(raw)
  371. }
  372. /**
  373. *
  374. * @param {Response} response
  375. */
  376. function getResponseSize(response) {
  377. const raw = response.headers.get('Content-Length')
  378. if (!raw) return 0
  379. return parseInt(raw, 10)
  380. }
  381. /**
  382. * @param {FetchEvent} event
  383. * @param {Response} response
  384. * @param {Object} body
  385. */
  386. function handleCompileResponse(event, response, body) {
  387. if (!body || body.status !== 'success') return
  388. const pdfCreatedAt = getServerTime(response)
  389. for (const file of body.outputFiles) {
  390. if (file.path !== 'output.pdf') continue // not the pdf used for rendering
  391. if (file.ranges) {
  392. file.ranges.forEach(backFillEdgeBounds)
  393. const { clsiServerId, compileGroup } = body
  394. registerPdfContext(event.clientId, file.url, {
  395. pdfCreatedAt,
  396. file,
  397. clsiServerId,
  398. compileGroup,
  399. })
  400. }
  401. break
  402. }
  403. }
  404. const ENCODER = new TextEncoder()
  405. function backFillEdgeBounds(chunk) {
  406. if (chunk.objectId) {
  407. chunk.objectId = ENCODER.encode(chunk.objectId)
  408. chunk.start -= chunk.objectId.byteLength
  409. }
  410. return chunk
  411. }
  412. /**
  413. * @param chunk
  414. * @param {ArrayBuffer} arrayBuffer
  415. * @return {Uint8Array}
  416. */
  417. function backFillObjectContext(chunk, arrayBuffer) {
  418. if (!chunk.objectId) {
  419. // This is a dynamic chunk
  420. return new Uint8Array(arrayBuffer)
  421. }
  422. const { start, end, objectId } = chunk
  423. const header = Uint8Array.from(objectId)
  424. const fullBuffer = new Uint8Array(end - start)
  425. fullBuffer.set(header, 0)
  426. fullBuffer.set(new Uint8Array(arrayBuffer), objectId.length)
  427. return fullBuffer
  428. }
  429. /**
  430. * @param {Array} chunks
  431. * @param {number} start
  432. * @param {number} end
  433. * @returns {Array}
  434. */
  435. function getMatchingChunks(chunks, start, end) {
  436. const matchingChunks = []
  437. for (const chunk of chunks) {
  438. if (chunk.end <= start) {
  439. // no overlap:
  440. // | REQUESTED_RANGE |
  441. // | CHUNK |
  442. continue
  443. }
  444. if (chunk.start >= end) {
  445. // no overlap:
  446. // | REQUESTED_RANGE |
  447. // | CHUNK |
  448. break
  449. }
  450. matchingChunks.push(chunk)
  451. }
  452. return matchingChunks
  453. }
  454. /**
  455. * @param {Array} chunks
  456. * @param {number} start
  457. * @param {number} end
  458. * @returns {Array}
  459. */
  460. function getInterleavingDynamicChunks(chunks, start, end) {
  461. const dynamicChunks = []
  462. for (const chunk of chunks) {
  463. if (start < chunk.start) {
  464. dynamicChunks.push({ start, end: chunk.start })
  465. }
  466. start = chunk.end
  467. }
  468. if (start < end) {
  469. dynamicChunks.push({ start, end })
  470. }
  471. return dynamicChunks
  472. }
  473. /**
  474. * @param {FetchEvent} event
  475. */
  476. function onFetchWithErrorHandling(event) {
  477. try {
  478. onFetch(event)
  479. } catch (error) {
  480. reportError(event, OError.tag(error, 'low level error in onFetch'))
  481. }
  482. }
  483. // listen to all network requests
  484. self.addEventListener('fetch', onFetchWithErrorHandling)
  485. // complete setup ASAP
  486. self.addEventListener('install', event => {
  487. event.waitUntil(self.skipWaiting())
  488. })
  489. self.addEventListener('activate', event => {
  490. event.waitUntil(self.clients.claim())
  491. })
  492. /**
  493. *
  494. * @param {FetchEvent} event
  495. * @param {Error} error
  496. */
  497. function reportError(event, error) {
  498. self.clients
  499. .get(event.clientId)
  500. .then(client => {
  501. if (!client) {
  502. // The client disconnected.
  503. return
  504. }
  505. client.postMessage(
  506. JSON.stringify({
  507. extra: { url: event.request.url, info: OError.getFullInfo(error) },
  508. error: {
  509. name: error.name,
  510. message: error.message,
  511. stack: OError.getFullStack(error),
  512. },
  513. })
  514. )
  515. })
  516. .catch(() => {})
  517. }