|
|
@@ -0,0 +1,230 @@
|
|
|
+const COMPILE_REQUEST_MATCHER = /^\/project\/[0-9a-f]{24}\/compile$/
|
|
|
+const MIN_CHUNK_SIZE = 128 * 1024
|
|
|
+
|
|
|
+const PDF_FILES = new Map()
|
|
|
+
|
|
|
+/**
|
|
|
+ * @param {FetchEvent} event
|
|
|
+ */
|
|
|
+function onFetch(event) {
|
|
|
+ const url = new URL(event.request.url)
|
|
|
+ const path = url.pathname
|
|
|
+
|
|
|
+ if (path.match(COMPILE_REQUEST_MATCHER)) {
|
|
|
+ return processCompileRequest(event)
|
|
|
+ }
|
|
|
+
|
|
|
+ const ctx = PDF_FILES.get(path)
|
|
|
+ if (ctx) {
|
|
|
+ return processPdfRequest(event, ctx)
|
|
|
+ }
|
|
|
+ // other request, ignore
|
|
|
+}
|
|
|
+
|
|
|
+function processCompileRequest(event) {
|
|
|
+ event.respondWith(
|
|
|
+ fetch(event.request).then(response => {
|
|
|
+ if (response.status !== 200) return response
|
|
|
+
|
|
|
+ return response.json().then(body => {
|
|
|
+ handleCompileResponse(body)
|
|
|
+ // The response body is consumed, serialize it again.
|
|
|
+ return new Response(JSON.stringify(body), response)
|
|
|
+ })
|
|
|
+ })
|
|
|
+ )
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ *
|
|
|
+ * @param {FetchEvent} event
|
|
|
+ * @param {Object} file
|
|
|
+ * @param {string} clsiServerId
|
|
|
+ * @param {string} compileGroup
|
|
|
+ */
|
|
|
+function processPdfRequest(event, { file, clsiServerId, compileGroup }) {
|
|
|
+ if (!event.request.headers.has('Range') && file.size > MIN_CHUNK_SIZE) {
|
|
|
+ // skip probe request
|
|
|
+ const headers = new Headers()
|
|
|
+ headers.set('Accept-Ranges', 'bytes')
|
|
|
+ headers.set('Content-Length', file.size)
|
|
|
+ headers.set('Content-Type', 'application/pdf')
|
|
|
+ return event.respondWith(
|
|
|
+ new Response('', {
|
|
|
+ headers,
|
|
|
+ status: 200,
|
|
|
+ statusText: 'OK',
|
|
|
+ })
|
|
|
+ )
|
|
|
+ }
|
|
|
+
|
|
|
+ const rangeHeader =
|
|
|
+ event.request.headers.get('Range') || `bytes=0-${file.size}`
|
|
|
+ const [start, last] = rangeHeader
|
|
|
+ .slice('bytes='.length)
|
|
|
+ .split('-')
|
|
|
+ .map(i => parseInt(i, 10))
|
|
|
+ const end = last + 1
|
|
|
+
|
|
|
+ const chunks = getMatchingChunks(file.ranges, start, end)
|
|
|
+
|
|
|
+ const dynamicChunks = getInterleavingDynamicChunks(chunks, start, end)
|
|
|
+
|
|
|
+ // URL prefix is /project/:id/user/:id/build/... or /project/:id/build/...
|
|
|
+ // for authenticated and unauthenticated users respectively.
|
|
|
+ const perUserPrefix = file.url.slice(0, file.url.indexOf('/build/'))
|
|
|
+ const requests = chunks
|
|
|
+ .map(chunk => {
|
|
|
+ const path = `${perUserPrefix}/content/${file.contentId}/${chunk.hash}`
|
|
|
+ const url = new URL(path, event.request.url)
|
|
|
+ if (clsiServerId) {
|
|
|
+ url.searchParams.set('clsiServerId', clsiServerId)
|
|
|
+ }
|
|
|
+ if (compileGroup) {
|
|
|
+ url.searchParams.set('compileGroup', compileGroup)
|
|
|
+ }
|
|
|
+ return { chunk, url: url.toString() }
|
|
|
+ })
|
|
|
+ .concat(
|
|
|
+ dynamicChunks.map(chunk => {
|
|
|
+ const { start, end } = chunk
|
|
|
+ return {
|
|
|
+ chunk,
|
|
|
+ url: event.request.url,
|
|
|
+ init: { headers: { Range: `bytes=${start}-${end - 1}` } },
|
|
|
+ }
|
|
|
+ })
|
|
|
+ )
|
|
|
+ const size = end - start
|
|
|
+ const reAssembledBlob = new Uint8Array(size)
|
|
|
+ event.respondWith(
|
|
|
+ Promise.all(
|
|
|
+ requests.map(({ chunk, url, init }) =>
|
|
|
+ fetch(url, init)
|
|
|
+ .then(response => {
|
|
|
+ if (!(response.status === 206 || response.status === 200)) {
|
|
|
+ throw new Error(
|
|
|
+ `could not fetch ${url} ${JSON.stringify(init)}: ${
|
|
|
+ response.status
|
|
|
+ }`
|
|
|
+ )
|
|
|
+ }
|
|
|
+ return response.arrayBuffer()
|
|
|
+ })
|
|
|
+ .then(arrayBuffer => {
|
|
|
+ return { chunk, arrayBuffer }
|
|
|
+ })
|
|
|
+ )
|
|
|
+ )
|
|
|
+ .then(responses => {
|
|
|
+ responses.map(({ chunk, arrayBuffer }) => {
|
|
|
+ // overlap:
|
|
|
+ // | REQUESTED_RANGE |
|
|
|
+ // | CHUNK |
|
|
|
+ const offsetStart = Math.max(start - chunk.start, 0)
|
|
|
+ // overlap:
|
|
|
+ // | REQUESTED_RANGE |
|
|
|
+ // | CHUNK |
|
|
|
+ const offsetEnd = Math.max(chunk.end - end, 0)
|
|
|
+ if (offsetStart > 0 || offsetEnd > 0) {
|
|
|
+ // compute index positions for slice to handle case where offsetEnd=0
|
|
|
+ const chunkSize = chunk.end - chunk.start
|
|
|
+ arrayBuffer = arrayBuffer.slice(offsetStart, chunkSize - offsetEnd)
|
|
|
+ }
|
|
|
+ const insertPosition = Math.max(chunk.start - start, 0)
|
|
|
+ reAssembledBlob.set(new Uint8Array(arrayBuffer), insertPosition)
|
|
|
+ })
|
|
|
+ return new Response(reAssembledBlob, {
|
|
|
+ status: 206,
|
|
|
+ headers: {
|
|
|
+ 'Accept-Ranges': 'bytes',
|
|
|
+ 'Content-Length': size,
|
|
|
+ 'Content-Range': `bytes ${start}-${last}/${file.size}`,
|
|
|
+ 'Content-Type': 'application/pdf',
|
|
|
+ },
|
|
|
+ })
|
|
|
+ })
|
|
|
+ .catch(error => {
|
|
|
+ console.error('Could not fetch partial pdf chunks', error)
|
|
|
+ return fetch(event.request)
|
|
|
+ })
|
|
|
+ )
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * @param {Object} body
|
|
|
+ */
|
|
|
+function handleCompileResponse(body) {
|
|
|
+ if (!body || body.status !== 'success') return
|
|
|
+
|
|
|
+ for (const file of body.outputFiles) {
|
|
|
+ if (file.path !== 'output.pdf') continue // not the pdf used for rendering
|
|
|
+ if (file.ranges) {
|
|
|
+ const { clsiServerId, compileGroup } = body
|
|
|
+ PDF_FILES.set(file.url, {
|
|
|
+ file,
|
|
|
+ clsiServerId,
|
|
|
+ compileGroup,
|
|
|
+ })
|
|
|
+ }
|
|
|
+ break
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * @param {Array} chunks
|
|
|
+ * @param {number} start
|
|
|
+ * @param {number} end
|
|
|
+ * @returns {Array}
|
|
|
+ */
|
|
|
+function getMatchingChunks(chunks, start, end) {
|
|
|
+ const matchingChunks = []
|
|
|
+ for (const chunk of chunks) {
|
|
|
+ if (chunk.end <= start) {
|
|
|
+ // no overlap:
|
|
|
+ // | REQUESTED_RANGE |
|
|
|
+ // | CHUNK |
|
|
|
+ continue
|
|
|
+ }
|
|
|
+ if (chunk.start >= end) {
|
|
|
+ // no overlap:
|
|
|
+ // | REQUESTED_RANGE |
|
|
|
+ // | CHUNK |
|
|
|
+ break
|
|
|
+ }
|
|
|
+ matchingChunks.push(chunk)
|
|
|
+ }
|
|
|
+ return matchingChunks
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * @param {Array} chunks
|
|
|
+ * @param {number} start
|
|
|
+ * @param {number} end
|
|
|
+ * @returns {Array}
|
|
|
+ */
|
|
|
+function getInterleavingDynamicChunks(chunks, start, end) {
|
|
|
+ const dynamicChunks = []
|
|
|
+ for (const chunk of chunks) {
|
|
|
+ if (start < chunk.start) {
|
|
|
+ dynamicChunks.push({ start, end: chunk.start })
|
|
|
+ }
|
|
|
+ start = chunk.end
|
|
|
+ }
|
|
|
+
|
|
|
+ if (start < end) {
|
|
|
+ dynamicChunks.push({ start, end })
|
|
|
+ }
|
|
|
+ return dynamicChunks
|
|
|
+}
|
|
|
+
|
|
|
+// listen to all network requests
|
|
|
+self.addEventListener('fetch', onFetch)
|
|
|
+
|
|
|
+// complete setup ASAP
|
|
|
+self.addEventListener('install', event => {
|
|
|
+ event.waitUntil(self.skipWaiting())
|
|
|
+})
|
|
|
+self.addEventListener('activate', event => {
|
|
|
+ event.waitUntil(self.clients.claim())
|
|
|
+})
|