serviceWorker.js 8.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330
  1. import { v4 as uuid } from 'uuid'
  2. import { fetchRange } from './features/pdf-preview/util/pdf-caching'
  3. const OError = require('@overleaf/o-error')
  4. // VERSION should get incremented when making changes to caching behavior or
  5. // adjusting metrics collection.
  6. // Keep in sync with PdfJsMetrics.
  7. const VERSION = 3
  8. const CLEAR_CACHE_REQUEST_MATCHER = /^\/project\/[0-9a-f]{24}\/output$/
  9. const COMPILE_REQUEST_MATCHER = /^\/project\/[0-9a-f]{24}\/compile$/
  10. const PDF_REQUEST_MATCHER =
  11. /^(\/zone\/.)?(\/project\/[0-9a-f]{24}\/.*\/output.pdf)$/
  12. const PDF_JS_CHUNK_SIZE = 128 * 1024
  13. // Each compile request defines a context (essentially the specific pdf file for
  14. // that compile), requests for that pdf file can use the hashes in the compile
  15. // response, which are stored in the context.
  16. const CLIENT_CONTEXT = new Map()
  17. /**
  18. * @param {string} clientId
  19. */
  20. function getClientContext(clientId) {
  21. let clientContext = CLIENT_CONTEXT.get(clientId)
  22. if (!clientContext) {
  23. const cached = new Set()
  24. const pdfs = new Map()
  25. const metrics = {
  26. version: VERSION,
  27. id: uuid(),
  28. epoch: Date.now(),
  29. failedCount: 0,
  30. tooLargeOverheadCount: 0,
  31. tooManyRequestsCount: 0,
  32. cachedCount: 0,
  33. cachedBytes: 0,
  34. fetchedCount: 0,
  35. fetchedBytes: 0,
  36. requestedCount: 0,
  37. requestedBytes: 0,
  38. compileCount: 0,
  39. }
  40. clientContext = { pdfs, metrics, cached }
  41. CLIENT_CONTEXT.set(clientId, clientContext)
  42. // clean up old client maps
  43. expirePdfContexts()
  44. }
  45. return clientContext
  46. }
  47. /**
  48. * @param {string} clientId
  49. * @param {string} path
  50. * @param {Object} pdfContext
  51. */
  52. function registerPdfContext(clientId, path, pdfContext) {
  53. const clientContext = getClientContext(clientId)
  54. const { pdfs, metrics, cached, clsiServerId } = clientContext
  55. pdfContext.metrics = metrics
  56. pdfContext.cached = cached
  57. if (pdfContext.clsiServerId !== clsiServerId) {
  58. // VM changed, this invalidates all browser caches.
  59. clientContext.clsiServerId = pdfContext.clsiServerId
  60. cached.clear()
  61. }
  62. // we only need to keep the last 3 contexts
  63. for (const key of pdfs.keys()) {
  64. if (pdfs.size < 3) {
  65. break
  66. }
  67. pdfs.delete(key) // the map keys are returned in insertion order, so we are deleting the oldest entry here
  68. }
  69. pdfs.set(path, pdfContext)
  70. }
  71. /**
  72. * @param {string} clientId
  73. * @param {string} path
  74. */
  75. function getPdfContext(clientId, path) {
  76. const { pdfs } = getClientContext(clientId)
  77. return pdfs.get(path)
  78. }
  79. function expirePdfContexts() {
  80. // discard client maps for clients that are no longer connected
  81. const currentClientSet = new Set()
  82. self.clients.matchAll().then(function (clientList) {
  83. clientList.forEach(client => {
  84. currentClientSet.add(client.id)
  85. })
  86. CLIENT_CONTEXT.forEach((map, clientId) => {
  87. if (!currentClientSet.has(clientId)) {
  88. CLIENT_CONTEXT.delete(clientId)
  89. }
  90. })
  91. })
  92. }
  93. /**
  94. * @param {FetchEvent} event
  95. */
  96. function onFetch(event) {
  97. const url = new URL(event.request.url)
  98. const path = url.pathname
  99. if (path.match(COMPILE_REQUEST_MATCHER)) {
  100. return processCompileRequest(event)
  101. }
  102. const match = path.match(PDF_REQUEST_MATCHER)
  103. if (match) {
  104. const ctx = getPdfContext(event.clientId, match[2])
  105. if (ctx) {
  106. return processPdfRequest(event, ctx)
  107. }
  108. }
  109. if (
  110. event.request.method === 'DELETE' &&
  111. path.match(CLEAR_CACHE_REQUEST_MATCHER)
  112. ) {
  113. return processClearCacheRequest(event)
  114. }
  115. // other request, ignore
  116. }
  117. /**
  118. * @param {FetchEvent} event
  119. */
  120. function processClearCacheRequest(event) {
  121. CLIENT_CONTEXT.delete(event.clientId)
  122. // use default request proxy.
  123. }
  124. /**
  125. * @param {FetchEvent} event
  126. */
  127. function processCompileRequest(event) {
  128. event.respondWith(
  129. fetch(event.request).then(response => {
  130. if (response.status !== 200) return response
  131. return response.json().then(body => {
  132. handleCompileResponse(event, response, body)
  133. // Send the service workers metrics to the frontend.
  134. const { metrics } = getClientContext(event.clientId)
  135. metrics.compileCount++
  136. body.serviceWorkerMetrics = metrics
  137. return new Response(JSON.stringify(body), response)
  138. })
  139. })
  140. )
  141. }
  142. /**
  143. * @param {Request} request
  144. * @param {Object} file
  145. * @return {Response}
  146. */
  147. function handleProbeRequest(request, file) {
  148. // PDF.js starts the pdf download with a probe request that has no
  149. // range headers on it.
  150. // Upon seeing the response headers, it decides whether to upgrade the
  151. // transport to chunked requests or keep reading the response body.
  152. // For small PDFs (2*chunkSize = 2*128kB) it just sends one request.
  153. // We will fetch all the ranges in bulk and emit them.
  154. // For large PDFs it sends this probe request, aborts that request before
  155. // reading any data and then sends multiple range requests.
  156. // It would be wasteful to action this probe request with all the ranges
  157. // that are available in the PDF and serve the full PDF content to
  158. // PDF.js for the probe request.
  159. // We are emitting a dummy response to the probe request instead.
  160. // It triggers the chunked transfer and subsequent fewer ranges need to be
  161. // requested -- only those of visible pages in the pdf viewer.
  162. // https://github.com/mozilla/pdf.js/blob/6fd899dc443425747098935207096328e7b55eb2/src/display/network_utils.js#L43-L47
  163. const pdfJSWillUseChunkedTransfer = file.size > 2 * PDF_JS_CHUNK_SIZE
  164. const isRangeRequest = request.headers.has('Range')
  165. if (!isRangeRequest && pdfJSWillUseChunkedTransfer) {
  166. const headers = new Headers()
  167. headers.set('Accept-Ranges', 'bytes')
  168. headers.set('Content-Length', file.size)
  169. headers.set('Content-Type', 'application/pdf')
  170. return new Response('', {
  171. headers,
  172. status: 200,
  173. statusText: 'OK',
  174. })
  175. }
  176. }
  177. /**
  178. *
  179. * @param {FetchEvent} event
  180. * @param {Object} file
  181. * @param {string} clsiServerId
  182. * @param {string} compileGroup
  183. * @param {Date} pdfCreatedAt
  184. * @param {Object} metrics
  185. * @param {Set} cached
  186. */
  187. function processPdfRequest(
  188. event,
  189. { file, clsiServerId, compileGroup, pdfCreatedAt, metrics, cached }
  190. ) {
  191. const response = handleProbeRequest(event.request, file)
  192. if (response) {
  193. return event.respondWith(response)
  194. }
  195. const rangeHeader =
  196. event.request.headers.get('Range') || `bytes=0-${file.size - 1}`
  197. const [start, last] = rangeHeader
  198. .slice('bytes='.length)
  199. .split('-')
  200. .map(i => parseInt(i, 10))
  201. const end = last + 1
  202. return event.respondWith(
  203. fetchRange({
  204. url: event.request.url,
  205. start,
  206. end,
  207. file,
  208. pdfCreatedAt,
  209. metrics,
  210. cached,
  211. })
  212. .then(blob => {
  213. return new Response(blob, {
  214. status: 206,
  215. headers: {
  216. 'Accept-Ranges': 'bytes',
  217. 'Content-Length': end - start,
  218. 'Content-Range': `bytes ${start}-${last}/${file.size}`,
  219. 'Content-Type': 'application/pdf',
  220. },
  221. })
  222. })
  223. .catch(error => {
  224. metrics.failedCount++
  225. reportError(event, OError.tag(error, 'failed to compose pdf response'))
  226. return fetch(event.request)
  227. })
  228. )
  229. }
  230. /**
  231. * @param {FetchEvent} event
  232. * @param {Response} response
  233. * @param {Object} body
  234. */
  235. function handleCompileResponse(event, response, body) {
  236. if (!body || body.status !== 'success') return
  237. for (const file of body.outputFiles) {
  238. if (file.path !== 'output.pdf') continue // not the pdf used for rendering
  239. if (file.ranges?.length) {
  240. const { clsiServerId, compileGroup } = body
  241. registerPdfContext(event.clientId, file.url, {
  242. file,
  243. clsiServerId,
  244. compileGroup,
  245. })
  246. }
  247. break
  248. }
  249. }
  250. /**
  251. * @param {FetchEvent} event
  252. */
  253. function onFetchWithErrorHandling(event) {
  254. try {
  255. onFetch(event)
  256. } catch (error) {
  257. reportError(event, OError.tag(error, 'low level error in onFetch'))
  258. }
  259. }
  260. // allow fetch event listener to be removed if necessary
  261. const controller = new AbortController()
  262. // listen to all network requests
  263. self.addEventListener('fetch', onFetchWithErrorHandling, {
  264. signal: controller.signal,
  265. })
  266. // complete setup ASAP
  267. self.addEventListener('install', event => {
  268. event.waitUntil(self.skipWaiting())
  269. })
  270. self.addEventListener('activate', event => {
  271. event.waitUntil(self.clients.claim())
  272. })
  273. self.addEventListener('message', event => {
  274. if (event.data && event.data.type === 'disable') {
  275. controller.abort() // removes the fetch event listener
  276. }
  277. })
  278. /**
  279. *
  280. * @param {FetchEvent} event
  281. * @param {Error} error
  282. */
  283. function reportError(event, error) {
  284. self.clients
  285. .get(event.clientId)
  286. .then(client => {
  287. if (!client) {
  288. // The client disconnected.
  289. return
  290. }
  291. client.postMessage(
  292. JSON.stringify({
  293. extra: { url: event.request.url, info: OError.getFullInfo(error) },
  294. error: {
  295. name: error.name,
  296. message: error.message,
  297. stack: OError.getFullStack(error),
  298. },
  299. })
  300. )
  301. })
  302. .catch(() => {})
  303. }