serviceWorker.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375
  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}`
  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 { chunk, arrayBuffer }
  192. })
  193. )
  194. )
  195. .then(responses => {
  196. responses.forEach(({ chunk, arrayBuffer }) => {
  197. // overlap:
  198. // | REQUESTED_RANGE |
  199. // | CHUNK |
  200. const offsetStart = Math.max(start - chunk.start, 0)
  201. // overlap:
  202. // | REQUESTED_RANGE |
  203. // | CHUNK |
  204. const offsetEnd = Math.max(chunk.end - end, 0)
  205. if (offsetStart > 0 || offsetEnd > 0) {
  206. // compute index positions for slice to handle case where offsetEnd=0
  207. const chunkSize = chunk.end - chunk.start
  208. arrayBuffer = arrayBuffer.slice(offsetStart, chunkSize - offsetEnd)
  209. }
  210. const insertPosition = Math.max(chunk.start - start, 0)
  211. reAssembledBlob.set(new Uint8Array(arrayBuffer), insertPosition)
  212. })
  213. let verifyProcess = Promise.resolve(reAssembledBlob)
  214. if (verifyChunks) {
  215. verifyProcess = fetch(event.request)
  216. .then(response => response.arrayBuffer())
  217. .then(arrayBuffer => {
  218. const fullBlob = new Uint8Array(arrayBuffer)
  219. const metrics = {}
  220. if (reAssembledBlob.byteLength !== fullBlob.byteLength) {
  221. metrics.sizeDiffers = true
  222. } else if (
  223. !reAssembledBlob.every((v, idx) => v === fullBlob[idx])
  224. ) {
  225. metrics.mismatch = true
  226. } else {
  227. metrics.success = true
  228. }
  229. trackChunkVerify(metrics)
  230. if (metrics.success === true) {
  231. return reAssembledBlob
  232. } else {
  233. return fullBlob
  234. }
  235. })
  236. }
  237. return verifyProcess.then(blob => {
  238. trackStats({ size, cachedBytes, fetchedBytes })
  239. return new Response(blob, {
  240. status: 206,
  241. headers: {
  242. 'Accept-Ranges': 'bytes',
  243. 'Content-Length': size,
  244. 'Content-Range': `bytes ${start}-${last}/${file.size}`,
  245. 'Content-Type': 'application/pdf',
  246. },
  247. })
  248. })
  249. })
  250. .catch(error => {
  251. console.error('Could not fetch partial pdf chunks', error)
  252. return fetch(event.request)
  253. })
  254. )
  255. }
  256. /**
  257. *
  258. * @param {Response} response
  259. */
  260. function getServerTime(response) {
  261. const raw = response.headers.get('Date')
  262. if (!raw) return undefined
  263. return new Date(raw)
  264. }
  265. /**
  266. *
  267. * @param {Response} response
  268. */
  269. function getResponseSize(response) {
  270. const raw = response.headers.get('Content-Length')
  271. if (!raw) return 0
  272. return parseInt(raw, 10)
  273. }
  274. /**
  275. * @param {Response} response
  276. * @param {Object} body
  277. */
  278. function handleCompileResponse(response, body) {
  279. if (!body || body.status !== 'success') return
  280. const pdfCreatedAt = getServerTime(response)
  281. for (const file of body.outputFiles) {
  282. if (file.path !== 'output.pdf') continue // not the pdf used for rendering
  283. if (file.ranges) {
  284. const { clsiServerId, compileGroup } = body
  285. PDF_FILES.set(file.url, {
  286. pdfCreatedAt,
  287. file,
  288. clsiServerId,
  289. compileGroup,
  290. })
  291. }
  292. break
  293. }
  294. }
  295. /**
  296. * @param {Array} chunks
  297. * @param {number} start
  298. * @param {number} end
  299. * @returns {Array}
  300. */
  301. function getMatchingChunks(chunks, start, end) {
  302. const matchingChunks = []
  303. for (const chunk of chunks) {
  304. if (chunk.end <= start) {
  305. // no overlap:
  306. // | REQUESTED_RANGE |
  307. // | CHUNK |
  308. continue
  309. }
  310. if (chunk.start >= end) {
  311. // no overlap:
  312. // | REQUESTED_RANGE |
  313. // | CHUNK |
  314. break
  315. }
  316. matchingChunks.push(chunk)
  317. }
  318. return matchingChunks
  319. }
  320. /**
  321. * @param {Array} chunks
  322. * @param {number} start
  323. * @param {number} end
  324. * @returns {Array}
  325. */
  326. function getInterleavingDynamicChunks(chunks, start, end) {
  327. const dynamicChunks = []
  328. for (const chunk of chunks) {
  329. if (start < chunk.start) {
  330. dynamicChunks.push({ start, end: chunk.start })
  331. }
  332. start = chunk.end
  333. }
  334. if (start < end) {
  335. dynamicChunks.push({ start, end })
  336. }
  337. return dynamicChunks
  338. }
  339. // listen to all network requests
  340. self.addEventListener('fetch', onFetch)
  341. // complete setup ASAP
  342. self.addEventListener('install', event => {
  343. event.waitUntil(self.skipWaiting())
  344. })
  345. self.addEventListener('activate', event => {
  346. event.waitUntil(self.clients.claim())
  347. })