serviceWorker.js 14 KB

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