pdf-caching.js 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827
  1. import OError from '@overleaf/o-error'
  2. const PDF_JS_CHUNK_SIZE = 128 * 1024
  3. const MAX_SUB_REQUEST_COUNT = 4
  4. const MAX_SUB_REQUEST_BYTES = 4 * PDF_JS_CHUNK_SIZE
  5. const HEADER_OVERHEAD_PER_MULTI_PART_CHUNK = 100
  6. const MULTI_PART_THRESHOLD = 4
  7. const INCREMENTAL_CACHE_SIZE = 1000
  8. // Download large chunks once the shard bandwidth exceeds 50% of their size.
  9. const CHUNK_USAGE_THRESHOLD_PREFETCH_LARGE = 0.5
  10. // Preferred caching once we downloaded a chunk (in multiple shards) in full.
  11. const CHUNK_USAGE_THRESHOLD_TRIGGER_PREFERRED = 1
  12. const CHUNK_USAGE_THRESHOLD_CACHED = 42
  13. // 42 * 0.7^11 < 1, aka we keep stale entries around for 11 compiles.
  14. const CHUNK_USAGE_STALE_DECAY_RATE = 0.7
  15. /**
  16. * @param {Object} file
  17. */
  18. function backfillEdgeBounds(file) {
  19. const encoder = new TextEncoder()
  20. for (const chunk of file.ranges) {
  21. if (chunk.objectId) {
  22. chunk.objectId = encoder.encode(chunk.objectId)
  23. chunk.start -= chunk.objectId.byteLength
  24. chunk.size = chunk.end - chunk.start
  25. }
  26. }
  27. }
  28. /**
  29. * @param {Map} usageScore
  30. * @param {Map} cachedUrls
  31. */
  32. function trimState({ usageScore, cachedUrls }) {
  33. for (const hash of usageScore) {
  34. if (usageScore.size < INCREMENTAL_CACHE_SIZE) {
  35. break
  36. }
  37. const score = usageScore.get(hash)
  38. if (score >= CHUNK_USAGE_THRESHOLD_TRIGGER_PREFERRED) {
  39. // Keep entries that are worth caching around for longer.
  40. usageScore.set(hash, score * CHUNK_USAGE_STALE_DECAY_RATE)
  41. continue
  42. }
  43. cachedUrls.delete(hash)
  44. usageScore.delete(hash)
  45. }
  46. }
  47. /**
  48. * @param {Object} file
  49. * @param {Map} usageScore
  50. * @param {Map} cachedUrls
  51. */
  52. function preprocessFileOnce({ file, usageScore, cachedUrls }) {
  53. if (file.preprocessed) return
  54. file.preprocessed = true
  55. file.createdAt = new Date(file.createdAt)
  56. file.prefetched = file.prefetched || []
  57. trimState({ usageScore, cachedUrls })
  58. backfillEdgeBounds(file)
  59. }
  60. /**
  61. * @param {Array} chunks
  62. */
  63. function countBytes(chunks) {
  64. return chunks.reduce((totalBytes, chunk) => {
  65. return totalBytes + (chunk.end - chunk.start)
  66. }, 0)
  67. }
  68. /**
  69. *
  70. * @param {Object} metrics
  71. * @param {number} size
  72. * @param {number} cachedCount
  73. * @param {number} cachedBytes
  74. * @param {number} fetchedCount
  75. * @param {number} fetchedBytes
  76. */
  77. function trackDownloadStats(
  78. metrics,
  79. { size, cachedCount, cachedBytes, fetchedCount, fetchedBytes }
  80. ) {
  81. metrics.cachedCount += cachedCount
  82. metrics.cachedBytes += cachedBytes
  83. metrics.fetchedCount += fetchedCount
  84. metrics.fetchedBytes += fetchedBytes
  85. metrics.requestedCount++
  86. metrics.requestedBytes += size
  87. }
  88. /**
  89. * @param {Object} metrics
  90. * @param {boolean} sizeDiffers
  91. * @param {boolean} mismatch
  92. * @param {boolean} success
  93. */
  94. function trackChunkVerify(metrics, { sizeDiffers, mismatch, success }) {
  95. if (sizeDiffers) {
  96. metrics.chunkVerifySizeDiffers |= 0
  97. metrics.chunkVerifySizeDiffers += 1
  98. }
  99. if (mismatch) {
  100. metrics.chunkVerifyMismatch |= 0
  101. metrics.chunkVerifyMismatch += 1
  102. }
  103. if (success) {
  104. metrics.chunkVerifySuccess |= 0
  105. metrics.chunkVerifySuccess += 1
  106. }
  107. }
  108. /**
  109. * @param chunk
  110. * @param {ArrayBuffer} arrayBuffer
  111. * @return {Uint8Array}
  112. */
  113. function backFillObjectContext(chunk, arrayBuffer) {
  114. if (!chunk.objectId) {
  115. // This is a dynamic chunk
  116. return new Uint8Array(arrayBuffer)
  117. }
  118. const { size, objectId } = chunk
  119. const header = Uint8Array.from(objectId)
  120. const fullBuffer = new Uint8Array(size)
  121. fullBuffer.set(header, 0)
  122. fullBuffer.set(new Uint8Array(arrayBuffer), objectId.length)
  123. return fullBuffer
  124. }
  125. /**
  126. * @param {Array} chunks
  127. * @param {number} start
  128. * @param {number} end
  129. * @returns {Array}
  130. */
  131. function getMatchingChunks(chunks, start, end) {
  132. const matchingChunks = []
  133. for (const chunk of chunks) {
  134. if (chunk.end <= start) {
  135. // no overlap:
  136. // | REQUESTED_RANGE |
  137. // | CHUNK |
  138. continue
  139. }
  140. if (chunk.start >= end) {
  141. // no overlap:
  142. // | REQUESTED_RANGE |
  143. // | CHUNK |
  144. break
  145. }
  146. matchingChunks.push(chunk)
  147. }
  148. return matchingChunks
  149. }
  150. /**
  151. * @param {Object} a
  152. * @param {Object} b
  153. */
  154. function sortBySizeDESC(a, b) {
  155. return a.size > b.size ? -1 : 1
  156. }
  157. /**
  158. * @param {Object} a
  159. * @param {Object} b
  160. */
  161. function sortByStartASC(a, b) {
  162. return a.start > b.start ? 1 : -1
  163. }
  164. /**
  165. * @param {Object} chunk
  166. */
  167. function usageAboveThreshold(chunk) {
  168. // We fetched enough shards of this chunk. Cache it in full now.
  169. return chunk.totalUsage > CHUNK_USAGE_THRESHOLD_TRIGGER_PREFERRED
  170. }
  171. /**
  172. * @param {Array} potentialChunks
  173. * @param {Map} usageScore
  174. * @param {Map} cachedUrls
  175. * @param {Object} metrics
  176. * @param {number} start
  177. * @param {number} end
  178. * @param {boolean} prefetchLargeEnabled
  179. */
  180. function cutRequestAmplification({
  181. potentialChunks,
  182. usageScore,
  183. cachedUrls,
  184. metrics,
  185. start,
  186. end,
  187. prefetchLargeEnabled,
  188. }) {
  189. // NOTE: Map keys are stored in insertion order.
  190. // We re-insert keys on cache hit and turn 'usageScore' into a cheap LRU.
  191. const chunks = []
  192. const skipAlreadyAdded = chunk => !chunks.includes(chunk)
  193. let tooManyRequests = false
  194. let tooMuchBandwidth = false
  195. let newChunks = 0
  196. let newCacheBandwidth = 0
  197. for (const chunk of potentialChunks) {
  198. const newUsage =
  199. (Math.min(end, chunk.end) - Math.max(start, chunk.start)) / chunk.size
  200. const totalUsage = (usageScore.get(chunk.hash) || 0) + newUsage
  201. usageScore.delete(chunk.hash)
  202. usageScore.set(chunk.hash, totalUsage)
  203. chunk.totalUsage = totalUsage
  204. }
  205. // Always download already cached entries
  206. for (const chunk of potentialChunks) {
  207. if (chunk.totalUsage >= CHUNK_USAGE_THRESHOLD_CACHED) {
  208. chunks.push(chunk)
  209. }
  210. }
  211. // Prefer large blobs over small ones.
  212. potentialChunks.sort(sortBySizeDESC)
  213. // Prefer chunks with high (previous) usage over brand-new chunks.
  214. const firstComeFirstCache = () => true
  215. for (const trigger of [usageAboveThreshold, firstComeFirstCache]) {
  216. for (const chunk of potentialChunks.filter(skipAlreadyAdded)) {
  217. if (newCacheBandwidth + chunk.size > MAX_SUB_REQUEST_BYTES) {
  218. // We would breach the bandwidth amplification limit.
  219. tooMuchBandwidth = true
  220. continue
  221. }
  222. if (newChunks + 1 > MAX_SUB_REQUEST_COUNT) {
  223. // We would breach the request rate amplification limit.
  224. tooManyRequests = true
  225. continue
  226. }
  227. if (trigger(chunk)) {
  228. newCacheBandwidth += chunk.size
  229. newChunks += 1
  230. chunks.push(chunk)
  231. }
  232. }
  233. }
  234. const largeChunk = potentialChunks.filter(skipAlreadyAdded)[0]
  235. if (largeChunk?.size >= PDF_JS_CHUNK_SIZE) {
  236. // This is a large chunk that exceeds the bandwidth amplification limit.
  237. if (largeChunk.start <= start && largeChunk.end >= end) {
  238. // This is a large chunk spanning the entire range. pdf.js will only
  239. // request these in case it needs the underlying stream, so it is OK to
  240. // download as much data as the stream is large in one go.
  241. chunks.push(largeChunk)
  242. } else if (
  243. prefetchLargeEnabled &&
  244. largeChunk.totalUsage > CHUNK_USAGE_THRESHOLD_PREFETCH_LARGE
  245. ) {
  246. // pdf.js actually wants the smaller (dynamic) chunk in the range that
  247. // happens to sit right next to this large chunk.
  248. // pdf.js has requested a lot of the large chunk via shards by now, and it
  249. // is time to download it in full to stop "wasting" more bandwidth and
  250. // more importantly cut down latency as we can prefetch the small chunk.
  251. chunks.push(largeChunk)
  252. }
  253. }
  254. if (tooManyRequests) {
  255. metrics.tooManyRequestsCount++
  256. }
  257. if (tooMuchBandwidth) {
  258. metrics.tooMuchBandwidthCount++
  259. }
  260. chunks.sort(sortByStartASC)
  261. return chunks
  262. }
  263. /**
  264. * @param {Array} chunks
  265. * @param {number} start
  266. * @param {number} end
  267. * @returns {Array}
  268. */
  269. function getInterleavingDynamicChunks(chunks, start, end) {
  270. const dynamicChunks = []
  271. for (const chunk of chunks) {
  272. if (start < chunk.start) {
  273. dynamicChunks.push({ start, end: chunk.start })
  274. }
  275. start = chunk.end
  276. }
  277. if (start < end) {
  278. dynamicChunks.push({ start, end })
  279. }
  280. return dynamicChunks
  281. }
  282. /**
  283. *
  284. * @param {Response} response
  285. */
  286. function getServerTime(response) {
  287. const raw = response.headers.get('Date')
  288. if (!raw) return new Date()
  289. return new Date(raw)
  290. }
  291. /**
  292. *
  293. * @param {Response} response
  294. */
  295. function getResponseSize(response) {
  296. const raw = response.headers.get('Content-Length')
  297. if (!raw) return 0
  298. return parseInt(raw, 10)
  299. }
  300. /**
  301. *
  302. * @param {Response} response
  303. * @param chunk
  304. */
  305. function getMultipartBoundary(response, chunk) {
  306. if (!Array.isArray(chunk)) return ''
  307. const raw = response.headers.get('Content-Type')
  308. if (raw.includes('multipart/byteranges')) {
  309. const idx = raw.indexOf('boundary=')
  310. if (idx !== -1) return raw.slice(idx + 'boundary='.length)
  311. }
  312. throw new OError('missing boundary on multipart request', {
  313. headers: Object.fromEntries(response.headers.entries()),
  314. chunk,
  315. })
  316. }
  317. /**
  318. * @param {Object} file
  319. * @param {Array} chunks
  320. * @param {Uint8Array} data
  321. * @param {string} boundary
  322. * @param {Object} metrics
  323. */
  324. function resolveMultiPartResponses({ file, chunks, data, boundary, metrics }) {
  325. const responses = []
  326. let offsetStart = 0
  327. const encoder = new TextEncoder()
  328. for (const chunk of chunks) {
  329. const header = `\r\n--${boundary}\r\nContent-Type: application/pdf\r\nContent-Range: bytes ${
  330. chunk.start
  331. }-${chunk.end - 1}/${file.size}\r\n\r\n`
  332. const headerSize = header.length
  333. // Verify header content. A proxy might have tampered with it.
  334. const headerRaw = encoder.encode(header)
  335. if (
  336. !data
  337. .subarray(offsetStart, offsetStart + headerSize)
  338. .every((v, idx) => v === headerRaw[idx])
  339. ) {
  340. metrics.headerVerifyFailure |= 0
  341. metrics.headerVerifyFailure++
  342. throw new OError('multipart response header does not match', {
  343. actual: new TextDecoder().decode(
  344. data.subarray(offsetStart, offsetStart + headerSize)
  345. ),
  346. expected: header,
  347. })
  348. }
  349. offsetStart += headerSize
  350. const chunkSize = chunk.end - chunk.start
  351. responses.push({
  352. chunk,
  353. data: data.subarray(offsetStart, offsetStart + chunkSize),
  354. })
  355. offsetStart += chunkSize
  356. }
  357. return responses
  358. }
  359. /**
  360. *
  361. * @param {Response} response
  362. */
  363. function checkChunkResponse(response) {
  364. if (!(response.status === 206 || response.status === 200)) {
  365. throw new OError('non successful response status: ' + response.status)
  366. }
  367. }
  368. /**
  369. *
  370. * @param {string} url
  371. * @param {number} start
  372. * @param {number} end
  373. * @param {AbortSignal} abortSignal
  374. */
  375. export async function fallbackRequest({ url, start, end, abortSignal }) {
  376. try {
  377. const response = await fetch(url, {
  378. headers: { Range: `bytes=${start}-${end - 1}` },
  379. signal: abortSignal,
  380. })
  381. checkChunkResponse(response)
  382. return await response.arrayBuffer()
  383. } catch (e) {
  384. throw OError.tag(e, 'fallback request failed', { url, start, end })
  385. }
  386. }
  387. /**
  388. *
  389. * @param {string} url
  390. * @param {number} start
  391. * @param {number} end
  392. * @param {Object} metrics
  393. * @param {Uint8Array} actual
  394. * @param {AbortSignal} abortSignal
  395. */
  396. async function verifyRange({ url, start, end, metrics, actual, abortSignal }) {
  397. let expectedRaw
  398. try {
  399. expectedRaw = await fallbackRequest({ url, start, end, abortSignal })
  400. } catch (error) {
  401. throw OError.tag(error, 'cannot verify range', { url, start, end })
  402. }
  403. const expected = new Uint8Array(expectedRaw)
  404. const stats = {}
  405. if (actual.byteLength !== expected.byteLength) {
  406. stats.sizeDiffers = true
  407. } else if (!expected.every((v, idx) => v === actual[idx])) {
  408. stats.mismatch = true
  409. } else {
  410. stats.success = true
  411. }
  412. trackChunkVerify(metrics, stats)
  413. return expected
  414. }
  415. /**
  416. * @param {Array} chunks
  417. * @param {Array} prefetched
  418. * @param {number} start
  419. * @param {number} end
  420. */
  421. function skipPrefetched(chunks, prefetched, start, end) {
  422. return chunks.filter(chunk => {
  423. return !prefetched.find(
  424. c =>
  425. c.start <= Math.max(chunk.start, start) &&
  426. c.end >= Math.min(chunk.end, end)
  427. )
  428. })
  429. }
  430. /**
  431. * @param {Object} chunk
  432. * @param {string} url
  433. * @param {Object} init
  434. * @param {Map<string, string>} cachedUrls
  435. * @param {Object} metrics
  436. * @param {boolean} cachedUrlLookupEnabled
  437. */
  438. async function fetchChunk({
  439. chunk,
  440. url,
  441. init,
  442. cachedUrls,
  443. metrics,
  444. cachedUrlLookupEnabled,
  445. }) {
  446. const oldUrl = cachedUrls.get(chunk.hash)
  447. if (cachedUrlLookupEnabled && chunk.hash && oldUrl && oldUrl !== url) {
  448. // When the clsi server id changes, the content id changes too and as a
  449. // result all the browser cache keys (aka urls) get invalidated.
  450. // We memorize the previous browser cache keys in `cachedUrls`.
  451. try {
  452. const response = await fetch(oldUrl, init)
  453. if (response.status === 200) {
  454. metrics.oldUrlHitCount += 1
  455. return response
  456. }
  457. if (response.status === 404) {
  458. // The old browser cache entry is gone and the old file is gone too.
  459. metrics.oldUrlMissCount += 1
  460. }
  461. // Fallback to the latest url.
  462. } catch (e) {
  463. // Fallback to the latest url.
  464. }
  465. }
  466. const response = await fetch(url, init)
  467. checkChunkResponse(response)
  468. if (chunk.hash) cachedUrls.set(chunk.hash, url)
  469. return response
  470. }
  471. /**
  472. * @param {Object} file
  473. * @param {number} start
  474. * @param {number} end
  475. * @param {Array} dynamicChunks
  476. * @param {boolean} prefetchXRefTable
  477. * @param {number} startXRefTableRange
  478. */
  479. function addPrefetchingChunks({
  480. file,
  481. start,
  482. end,
  483. dynamicChunks,
  484. prefetchXRefTable,
  485. startXRefTableRange,
  486. }) {
  487. // Prefetch in case this is the first range, or we are fetching dynamic
  488. // chunks anyway (so we can ride-share the round trip).
  489. // Rendering cannot start without downloading the xref table, so it's OK to
  490. // "delay" the first range.
  491. if (!(start === 0 || dynamicChunks.length > 0)) {
  492. return
  493. }
  494. let extra = []
  495. if (prefetchXRefTable) {
  496. // Prefetch the dynamic chunks around the xref table.
  497. extra = skipPrefetched(
  498. getInterleavingDynamicChunks(
  499. getMatchingChunks(file.ranges, startXRefTableRange, file.size),
  500. startXRefTableRange,
  501. file.size
  502. ),
  503. file.prefetched,
  504. startXRefTableRange,
  505. file.size
  506. )
  507. }
  508. // Stop at the xref table range if present -- we may prefetch it early ^^^.
  509. const prefetchEnd = startXRefTableRange || file.size
  510. extra = extra.concat(
  511. skipPrefetched(
  512. getInterleavingDynamicChunks(
  513. getMatchingChunks(file.ranges, end, prefetchEnd),
  514. end,
  515. prefetchEnd
  516. ),
  517. file.prefetched,
  518. end,
  519. prefetchEnd
  520. )
  521. )
  522. let sum =
  523. countBytes(dynamicChunks) +
  524. HEADER_OVERHEAD_PER_MULTI_PART_CHUNK * dynamicChunks.length
  525. for (const chunk of extra) {
  526. sum += chunk.end - chunk.start + HEADER_OVERHEAD_PER_MULTI_PART_CHUNK
  527. if (sum > PDF_JS_CHUNK_SIZE) {
  528. // Stop prefetching when the range is full.
  529. }
  530. const sibling = dynamicChunks.find(
  531. sibling => sibling.end === chunk.start || sibling.start === chunk.end
  532. )
  533. if (sibling) {
  534. // Just expand the chunk.
  535. sibling.start = Math.min(sibling.start, chunk.start)
  536. sibling.end = Math.max(sibling.end, chunk.end)
  537. continue
  538. }
  539. if (dynamicChunks.length === MULTI_PART_THRESHOLD) {
  540. // Stop prefetching when we would switch to a multipart request.
  541. // (We continue in case we are already doing a multipart request.)
  542. break
  543. }
  544. dynamicChunks.push(chunk)
  545. }
  546. dynamicChunks.sort(sortByStartASC)
  547. }
  548. /**
  549. *
  550. * @param {string} url
  551. * @param {number} start
  552. * @param {number} end
  553. * @param {Object} file
  554. * @param {Object} metrics
  555. * @param {Map} usageScore
  556. * @param {Map} cachedUrls
  557. * @param {boolean} verifyChunks
  558. * @param {boolean} prefetchingEnabled
  559. * @param {boolean} prefetchLargeEnabled
  560. * @param {boolean} tryOldCachedUrlEnabled
  561. * @param {AbortSignal} abortSignal
  562. */
  563. export async function fetchRange({
  564. url,
  565. start,
  566. end,
  567. file,
  568. metrics,
  569. usageScore,
  570. cachedUrls,
  571. verifyChunks,
  572. prefetchingEnabled,
  573. prefetchLargeEnabled,
  574. cachedUrlLookupEnabled,
  575. abortSignal,
  576. }) {
  577. preprocessFileOnce({ file, usageScore, cachedUrls })
  578. const startXRefTableRange =
  579. Math.floor(file.startXRefTable / PDF_JS_CHUNK_SIZE) * PDF_JS_CHUNK_SIZE
  580. const prefetchXRefTable = startXRefTableRange > 0 && start === 0
  581. const prefetched = getMatchingChunks(file.prefetched, start, end)
  582. // Check that handling the range request won't trigger excessive sub-requests,
  583. // (to avoid unwanted latency compared to the original request).
  584. const chunks = cutRequestAmplification({
  585. potentialChunks: skipPrefetched(
  586. getMatchingChunks(file.ranges, start, end),
  587. prefetched,
  588. start,
  589. end
  590. ),
  591. usageScore,
  592. cachedUrls,
  593. metrics,
  594. start,
  595. end,
  596. prefetchLargeEnabled,
  597. })
  598. const dynamicChunks = skipPrefetched(
  599. getInterleavingDynamicChunks(chunks, start, end),
  600. prefetched,
  601. start,
  602. end
  603. )
  604. const size = end - start
  605. if (
  606. chunks.length === 0 &&
  607. prefetched.length === 0 &&
  608. dynamicChunks.length === 1 &&
  609. !prefetchXRefTable
  610. ) {
  611. // fall back to the original range request when no chunks are cached.
  612. // Exception: The first range should fetch the xref table as well.
  613. trackDownloadStats(metrics, {
  614. size,
  615. cachedCount: 0,
  616. cachedBytes: 0,
  617. fetchedCount: 1,
  618. fetchedBytes: size,
  619. })
  620. return fallbackRequest({ url, start, end, abortSignal })
  621. }
  622. if (prefetchingEnabled) {
  623. addPrefetchingChunks({
  624. file,
  625. start,
  626. end,
  627. dynamicChunks,
  628. prefetchXRefTable,
  629. startXRefTableRange,
  630. })
  631. }
  632. const byteRanges = dynamicChunks
  633. .map(chunk => `${chunk.start}-${chunk.end - 1}`)
  634. .join(',')
  635. const coalescedDynamicChunks = []
  636. switch (true) {
  637. case dynamicChunks.length === 0:
  638. break
  639. case dynamicChunks.length === 1:
  640. coalescedDynamicChunks.push({
  641. chunk: dynamicChunks[0],
  642. url,
  643. init: { headers: { Range: `bytes=${byteRanges}` } },
  644. })
  645. break
  646. case dynamicChunks.length <= MULTI_PART_THRESHOLD:
  647. // There will always be an OPTIONS request for multi-ranges requests.
  648. // It is faster to request few ranges in parallel instead of waiting for
  649. // the OPTIONS request to round trip.
  650. dynamicChunks.forEach(chunk => {
  651. coalescedDynamicChunks.push({
  652. chunk,
  653. url,
  654. init: { headers: { Range: `bytes=${chunk.start}-${chunk.end - 1}` } },
  655. })
  656. })
  657. break
  658. default:
  659. coalescedDynamicChunks.push({
  660. chunk: dynamicChunks,
  661. url,
  662. init: { headers: { Range: `bytes=${byteRanges}` } },
  663. })
  664. }
  665. const params = new URL(url).searchParams
  666. // drop no needed params
  667. params.delete('enable_pdf_caching')
  668. params.delete('verify_chunks')
  669. const query = params.toString()
  670. // The schema of `url` is https://domain/project/:id/user/:id/build/... for
  671. // authenticated and https://domain/project/:id/build/... for
  672. // unauthenticated users. Cut it before /build/.
  673. // The path may have an optional /zone/b prefix too.
  674. const perUserPrefix = url.slice(0, url.indexOf('/build/'))
  675. const requests = chunks
  676. .map(chunk => ({
  677. chunk,
  678. url: `${perUserPrefix}/content/${file.contentId}/${chunk.hash}?${query}`,
  679. init: {},
  680. }))
  681. .concat(coalescedDynamicChunks)
  682. let cachedCount = 0
  683. let cachedBytes = 0
  684. let fetchedCount = 0
  685. let fetchedBytes = 0
  686. const reassembledBlob = new Uint8Array(size)
  687. const rawResponses = await Promise.all(
  688. requests.map(async ({ chunk, url, init }) => {
  689. try {
  690. const response = await fetchChunk({
  691. chunk,
  692. url,
  693. init: { ...init, signal: abortSignal },
  694. cachedUrls,
  695. metrics,
  696. cachedUrlLookupEnabled,
  697. })
  698. const boundary = getMultipartBoundary(response, chunk)
  699. const blobFetchDate = getServerTime(response)
  700. const blobSize = getResponseSize(response)
  701. if (blobFetchDate && blobSize) {
  702. // Example: 2MB PDF, 1MB image, 128KB PDF.js chunk.
  703. // | pdf.js chunk |
  704. // | A BIG IMAGE BLOB |
  705. // | THE FULL PDF |
  706. if (chunk.hash && blobFetchDate < file.createdAt) {
  707. const usedChunkSection =
  708. Math.min(end, chunk.end) - Math.max(start, chunk.start)
  709. cachedCount++
  710. cachedBytes += usedChunkSection
  711. // Roll the position of the hash in the Map.
  712. usageScore.delete(chunk.hash)
  713. usageScore.set(chunk.hash, CHUNK_USAGE_THRESHOLD_CACHED)
  714. } else {
  715. // Blobs are fetched in bulk, record the full size.
  716. fetchedCount++
  717. fetchedBytes += blobSize
  718. }
  719. }
  720. const data = backFillObjectContext(chunk, await response.arrayBuffer())
  721. if (!Array.isArray(chunk)) {
  722. return [{ chunk, data }]
  723. }
  724. return resolveMultiPartResponses({
  725. file,
  726. chunks: chunk,
  727. data,
  728. boundary,
  729. metrics,
  730. })
  731. } catch (err) {
  732. throw OError.tag(err, 'cannot fetch chunk', { chunk, url, init })
  733. }
  734. })
  735. )
  736. rawResponses
  737. .flat() // flatten after splitting multipart responses
  738. .concat(prefetched.map(chunk => ({ chunk, data: chunk.buffer })))
  739. .forEach(({ chunk, data }) => {
  740. if (!chunk.hash && chunk.end > end) {
  741. // This is a (partially) prefetched chunk.
  742. chunk.buffer = data
  743. file.prefetched.push(chunk)
  744. if (chunk.start > end) return // This is a fully prefetched chunk.
  745. }
  746. // overlap:
  747. // | REQUESTED_RANGE |
  748. // | CHUNK |
  749. const offsetStart = Math.max(start - chunk.start, 0)
  750. // overlap:
  751. // | REQUESTED_RANGE |
  752. // | CHUNK |
  753. const offsetEnd = Math.max(chunk.end - end, 0)
  754. if (offsetStart > 0 || offsetEnd > 0) {
  755. // compute index positions for slice to handle case where offsetEnd=0
  756. const chunkSize = chunk.end - chunk.start
  757. data = data.subarray(offsetStart, chunkSize - offsetEnd)
  758. }
  759. const insertPosition = Math.max(chunk.start - start, 0)
  760. reassembledBlob.set(data, insertPosition)
  761. })
  762. trackDownloadStats(metrics, {
  763. size,
  764. cachedCount,
  765. cachedBytes,
  766. fetchedCount,
  767. fetchedBytes,
  768. })
  769. if (verifyChunks) {
  770. return await verifyRange({
  771. url,
  772. start,
  773. end,
  774. metrics,
  775. actual: reassembledBlob,
  776. abortSignal,
  777. })
  778. }
  779. return reassembledBlob
  780. }