serviceWorker.js 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230
  1. const COMPILE_REQUEST_MATCHER = /^\/project\/[0-9a-f]{24}\/compile$/
  2. const MIN_CHUNK_SIZE = 128 * 1024
  3. const PDF_FILES = new Map()
  4. /**
  5. * @param {FetchEvent} event
  6. */
  7. function onFetch(event) {
  8. const url = new URL(event.request.url)
  9. const path = url.pathname
  10. if (path.match(COMPILE_REQUEST_MATCHER)) {
  11. return processCompileRequest(event)
  12. }
  13. const ctx = PDF_FILES.get(path)
  14. if (ctx) {
  15. return processPdfRequest(event, ctx)
  16. }
  17. // other request, ignore
  18. }
  19. function processCompileRequest(event) {
  20. event.respondWith(
  21. fetch(event.request).then(response => {
  22. if (response.status !== 200) return response
  23. return response.json().then(body => {
  24. handleCompileResponse(body)
  25. // The response body is consumed, serialize it again.
  26. return new Response(JSON.stringify(body), response)
  27. })
  28. })
  29. )
  30. }
  31. /**
  32. *
  33. * @param {FetchEvent} event
  34. * @param {Object} file
  35. * @param {string} clsiServerId
  36. * @param {string} compileGroup
  37. */
  38. function processPdfRequest(event, { file, clsiServerId, compileGroup }) {
  39. if (!event.request.headers.has('Range') && file.size > MIN_CHUNK_SIZE) {
  40. // skip probe request
  41. const headers = new Headers()
  42. headers.set('Accept-Ranges', 'bytes')
  43. headers.set('Content-Length', file.size)
  44. headers.set('Content-Type', 'application/pdf')
  45. return event.respondWith(
  46. new Response('', {
  47. headers,
  48. status: 200,
  49. statusText: 'OK',
  50. })
  51. )
  52. }
  53. const rangeHeader =
  54. event.request.headers.get('Range') || `bytes=0-${file.size}`
  55. const [start, last] = rangeHeader
  56. .slice('bytes='.length)
  57. .split('-')
  58. .map(i => parseInt(i, 10))
  59. const end = last + 1
  60. const chunks = getMatchingChunks(file.ranges, start, end)
  61. const dynamicChunks = getInterleavingDynamicChunks(chunks, start, end)
  62. // URL prefix is /project/:id/user/:id/build/... or /project/:id/build/...
  63. // for authenticated and unauthenticated users respectively.
  64. const perUserPrefix = file.url.slice(0, file.url.indexOf('/build/'))
  65. const requests = chunks
  66. .map(chunk => {
  67. const path = `${perUserPrefix}/content/${file.contentId}/${chunk.hash}`
  68. const url = new URL(path, event.request.url)
  69. if (clsiServerId) {
  70. url.searchParams.set('clsiserverid', clsiServerId)
  71. }
  72. if (compileGroup) {
  73. url.searchParams.set('compileGroup', compileGroup)
  74. }
  75. return { chunk, url: url.toString() }
  76. })
  77. .concat(
  78. dynamicChunks.map(chunk => {
  79. const { start, end } = chunk
  80. return {
  81. chunk,
  82. url: event.request.url,
  83. init: { headers: { Range: `bytes=${start}-${end - 1}` } },
  84. }
  85. })
  86. )
  87. const size = end - start
  88. const reAssembledBlob = new Uint8Array(size)
  89. event.respondWith(
  90. Promise.all(
  91. requests.map(({ chunk, url, init }) =>
  92. fetch(url, init)
  93. .then(response => {
  94. if (!(response.status === 206 || response.status === 200)) {
  95. throw new Error(
  96. `could not fetch ${url} ${JSON.stringify(init)}: ${
  97. response.status
  98. }`
  99. )
  100. }
  101. return response.arrayBuffer()
  102. })
  103. .then(arrayBuffer => {
  104. return { chunk, arrayBuffer }
  105. })
  106. )
  107. )
  108. .then(responses => {
  109. responses.forEach(({ chunk, arrayBuffer }) => {
  110. // overlap:
  111. // | REQUESTED_RANGE |
  112. // | CHUNK |
  113. const offsetStart = Math.max(start - chunk.start, 0)
  114. // overlap:
  115. // | REQUESTED_RANGE |
  116. // | CHUNK |
  117. const offsetEnd = Math.max(chunk.end - end, 0)
  118. if (offsetStart > 0 || offsetEnd > 0) {
  119. // compute index positions for slice to handle case where offsetEnd=0
  120. const chunkSize = chunk.end - chunk.start
  121. arrayBuffer = arrayBuffer.slice(offsetStart, chunkSize - offsetEnd)
  122. }
  123. const insertPosition = Math.max(chunk.start - start, 0)
  124. reAssembledBlob.set(new Uint8Array(arrayBuffer), insertPosition)
  125. })
  126. return new Response(reAssembledBlob, {
  127. status: 206,
  128. headers: {
  129. 'Accept-Ranges': 'bytes',
  130. 'Content-Length': size,
  131. 'Content-Range': `bytes ${start}-${last}/${file.size}`,
  132. 'Content-Type': 'application/pdf',
  133. },
  134. })
  135. })
  136. .catch(error => {
  137. console.error('Could not fetch partial pdf chunks', error)
  138. return fetch(event.request)
  139. })
  140. )
  141. }
  142. /**
  143. * @param {Object} body
  144. */
  145. function handleCompileResponse(body) {
  146. if (!body || body.status !== 'success') return
  147. for (const file of body.outputFiles) {
  148. if (file.path !== 'output.pdf') continue // not the pdf used for rendering
  149. if (file.ranges) {
  150. const { clsiServerId, compileGroup } = body
  151. PDF_FILES.set(file.url, {
  152. file,
  153. clsiServerId,
  154. compileGroup,
  155. })
  156. }
  157. break
  158. }
  159. }
  160. /**
  161. * @param {Array} chunks
  162. * @param {number} start
  163. * @param {number} end
  164. * @returns {Array}
  165. */
  166. function getMatchingChunks(chunks, start, end) {
  167. const matchingChunks = []
  168. for (const chunk of chunks) {
  169. if (chunk.end <= start) {
  170. // no overlap:
  171. // | REQUESTED_RANGE |
  172. // | CHUNK |
  173. continue
  174. }
  175. if (chunk.start >= end) {
  176. // no overlap:
  177. // | REQUESTED_RANGE |
  178. // | CHUNK |
  179. break
  180. }
  181. matchingChunks.push(chunk)
  182. }
  183. return matchingChunks
  184. }
  185. /**
  186. * @param {Array} chunks
  187. * @param {number} start
  188. * @param {number} end
  189. * @returns {Array}
  190. */
  191. function getInterleavingDynamicChunks(chunks, start, end) {
  192. const dynamicChunks = []
  193. for (const chunk of chunks) {
  194. if (start < chunk.start) {
  195. dynamicChunks.push({ start, end: chunk.start })
  196. }
  197. start = chunk.end
  198. }
  199. if (start < end) {
  200. dynamicChunks.push({ start, end })
  201. }
  202. return dynamicChunks
  203. }
  204. // listen to all network requests
  205. self.addEventListener('fetch', onFetch)
  206. // complete setup ASAP
  207. self.addEventListener('install', event => {
  208. event.waitUntil(self.skipWaiting())
  209. })
  210. self.addEventListener('activate', event => {
  211. event.waitUntil(self.clients.claim())
  212. })