serviceWorker.js 11 KB

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