serviceWorker.js 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349
  1. import { v4 as uuid } from 'uuid'
  2. const COMPILE_REQUEST_MATCHER = /^\/project\/[0-9a-f]{24}\/compile$/
  3. const MIN_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. *
  72. * @param {FetchEvent} event
  73. * @param {Object} file
  74. * @param {string} clsiServerId
  75. * @param {string} compileGroup
  76. * @param {Date} pdfCreatedAt
  77. */
  78. function processPdfRequest(
  79. event,
  80. { file, clsiServerId, compileGroup, pdfCreatedAt }
  81. ) {
  82. if (!event.request.headers.has('Range') && file.size > MIN_CHUNK_SIZE) {
  83. // skip probe request
  84. const headers = new Headers()
  85. headers.set('Accept-Ranges', 'bytes')
  86. headers.set('Content-Length', file.size)
  87. headers.set('Content-Type', 'application/pdf')
  88. return event.respondWith(
  89. new Response('', {
  90. headers,
  91. status: 200,
  92. statusText: 'OK',
  93. })
  94. )
  95. }
  96. const verifyChunks = event.request.url.includes('verify_chunks=true')
  97. const rangeHeader =
  98. event.request.headers.get('Range') || `bytes=0-${file.size}`
  99. const [start, last] = rangeHeader
  100. .slice('bytes='.length)
  101. .split('-')
  102. .map(i => parseInt(i, 10))
  103. const end = last + 1
  104. const chunks = getMatchingChunks(file.ranges, start, end)
  105. const dynamicChunks = getInterleavingDynamicChunks(chunks, start, end)
  106. // URL prefix is /project/:id/user/:id/build/... or /project/:id/build/...
  107. // for authenticated and unauthenticated users respectively.
  108. const perUserPrefix = file.url.slice(0, file.url.indexOf('/build/'))
  109. const requests = chunks
  110. .map(chunk => {
  111. const path = `${perUserPrefix}/content/${file.contentId}/${chunk.hash}`
  112. const url = new URL(path, event.request.url)
  113. if (clsiServerId) {
  114. url.searchParams.set('clsiserverid', clsiServerId)
  115. }
  116. if (compileGroup) {
  117. url.searchParams.set('compileGroup', compileGroup)
  118. }
  119. return { chunk, url: url.toString() }
  120. })
  121. .concat(
  122. dynamicChunks.map(chunk => {
  123. const { start, end } = chunk
  124. return {
  125. chunk,
  126. url: event.request.url,
  127. init: { headers: { Range: `bytes=${start}-${end - 1}` } },
  128. }
  129. })
  130. )
  131. const size = end - start
  132. let cachedBytes = 0
  133. let fetchedBytes = 0
  134. const reAssembledBlob = new Uint8Array(size)
  135. event.respondWith(
  136. Promise.all(
  137. requests.map(({ chunk, url, init }) =>
  138. fetch(url, init)
  139. .then(response => {
  140. if (!(response.status === 206 || response.status === 200)) {
  141. throw new Error(
  142. `could not fetch ${url} ${JSON.stringify(init)}: ${
  143. response.status
  144. }`
  145. )
  146. }
  147. const blobFetchDate = getServerTime(response)
  148. const blobSize = getResponseSize(response)
  149. if (blobFetchDate && blobSize) {
  150. const chunkSize =
  151. Math.min(end, chunk.end) - Math.max(start, chunk.start)
  152. // Example: 2MB PDF, 1MB image, 128KB PDF.js chunk.
  153. // | pdf.js chunk |
  154. // | A BIG IMAGE BLOB |
  155. // | THE FULL PDF |
  156. if (blobFetchDate < pdfCreatedAt) {
  157. cachedBytes += chunkSize
  158. } else {
  159. // Blobs are fetched in bulk.
  160. fetchedBytes += blobSize
  161. }
  162. }
  163. return response.arrayBuffer()
  164. })
  165. .then(arrayBuffer => {
  166. return { chunk, arrayBuffer }
  167. })
  168. )
  169. )
  170. .then(responses => {
  171. responses.forEach(({ chunk, arrayBuffer }) => {
  172. // overlap:
  173. // | REQUESTED_RANGE |
  174. // | CHUNK |
  175. const offsetStart = Math.max(start - chunk.start, 0)
  176. // overlap:
  177. // | REQUESTED_RANGE |
  178. // | CHUNK |
  179. const offsetEnd = Math.max(chunk.end - end, 0)
  180. if (offsetStart > 0 || offsetEnd > 0) {
  181. // compute index positions for slice to handle case where offsetEnd=0
  182. const chunkSize = chunk.end - chunk.start
  183. arrayBuffer = arrayBuffer.slice(offsetStart, chunkSize - offsetEnd)
  184. }
  185. const insertPosition = Math.max(chunk.start - start, 0)
  186. reAssembledBlob.set(new Uint8Array(arrayBuffer), insertPosition)
  187. })
  188. let verifyProcess = Promise.resolve(reAssembledBlob)
  189. if (verifyChunks) {
  190. verifyProcess = fetch(event.request)
  191. .then(response => response.arrayBuffer())
  192. .then(arrayBuffer => {
  193. const fullBlob = new Uint8Array(arrayBuffer)
  194. const metrics = {}
  195. if (reAssembledBlob.byteLength !== fullBlob.byteLength) {
  196. metrics.sizeDiffers = true
  197. } else if (
  198. !reAssembledBlob.every((v, idx) => v === fullBlob[idx])
  199. ) {
  200. metrics.mismatch = true
  201. } else {
  202. metrics.success = true
  203. }
  204. trackChunkVerify(metrics)
  205. if (metrics.success === true) {
  206. return reAssembledBlob
  207. } else {
  208. return fullBlob
  209. }
  210. })
  211. }
  212. return verifyProcess.then(blob => {
  213. trackStats({ size, cachedBytes, fetchedBytes })
  214. return new Response(blob, {
  215. status: 206,
  216. headers: {
  217. 'Accept-Ranges': 'bytes',
  218. 'Content-Length': size,
  219. 'Content-Range': `bytes ${start}-${last}/${file.size}`,
  220. 'Content-Type': 'application/pdf',
  221. },
  222. })
  223. })
  224. })
  225. .catch(error => {
  226. console.error('Could not fetch partial pdf chunks', error)
  227. return fetch(event.request)
  228. })
  229. )
  230. }
  231. /**
  232. *
  233. * @param {Response} response
  234. */
  235. function getServerTime(response) {
  236. const raw = response.headers.get('Date')
  237. if (!raw) return undefined
  238. return new Date(raw)
  239. }
  240. /**
  241. *
  242. * @param {Response} response
  243. */
  244. function getResponseSize(response) {
  245. const raw = response.headers.get('Content-Length')
  246. if (!raw) return 0
  247. return parseInt(raw, 10)
  248. }
  249. /**
  250. * @param {Response} response
  251. * @param {Object} body
  252. */
  253. function handleCompileResponse(response, body) {
  254. if (!body || body.status !== 'success') return
  255. const pdfCreatedAt = getServerTime(response)
  256. for (const file of body.outputFiles) {
  257. if (file.path !== 'output.pdf') continue // not the pdf used for rendering
  258. if (file.ranges) {
  259. const { clsiServerId, compileGroup } = body
  260. PDF_FILES.set(file.url, {
  261. pdfCreatedAt,
  262. file,
  263. clsiServerId,
  264. compileGroup,
  265. })
  266. }
  267. break
  268. }
  269. }
  270. /**
  271. * @param {Array} chunks
  272. * @param {number} start
  273. * @param {number} end
  274. * @returns {Array}
  275. */
  276. function getMatchingChunks(chunks, start, end) {
  277. const matchingChunks = []
  278. for (const chunk of chunks) {
  279. if (chunk.end <= start) {
  280. // no overlap:
  281. // | REQUESTED_RANGE |
  282. // | CHUNK |
  283. continue
  284. }
  285. if (chunk.start >= end) {
  286. // no overlap:
  287. // | REQUESTED_RANGE |
  288. // | CHUNK |
  289. break
  290. }
  291. matchingChunks.push(chunk)
  292. }
  293. return matchingChunks
  294. }
  295. /**
  296. * @param {Array} chunks
  297. * @param {number} start
  298. * @param {number} end
  299. * @returns {Array}
  300. */
  301. function getInterleavingDynamicChunks(chunks, start, end) {
  302. const dynamicChunks = []
  303. for (const chunk of chunks) {
  304. if (start < chunk.start) {
  305. dynamicChunks.push({ start, end: chunk.start })
  306. }
  307. start = chunk.end
  308. }
  309. if (start < end) {
  310. dynamicChunks.push({ start, end })
  311. }
  312. return dynamicChunks
  313. }
  314. // listen to all network requests
  315. self.addEventListener('fetch', onFetch)
  316. // complete setup ASAP
  317. self.addEventListener('install', event => {
  318. event.waitUntil(self.skipWaiting())
  319. })
  320. self.addEventListener('activate', event => {
  321. event.waitUntil(self.clients.claim())
  322. })