serviceWorker.js 12 KB

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