[web] integrate clsi-cache into download pdf from project dashboard (#31138)

* [monorepo] fix downloads from non-sharded clsi-cache

* [web] check some compile from cache options server-side

* [web] integrate clsi-cache into download pdf from project dashboard

* [web] remove frontend tests for server-side validation

* [web] remove unused fetch mock

* [web] use helper that adds polyfill for AbortSignal.any()

* [web] upgrade fetch-mock to fix leaking AbortSignal

* [web] do not add an extra timeout to clsi-cache request

The web backend service has a low timeout already.

GitOrigin-RevId: a90984b92f5d4f24005db5a09f2c5d2424436886
This commit is contained in:
Jakob Ackermann
2026-02-02 09:11:07 +01:00
committed by Copybot
parent 774d8434d8
commit 5829a7fe43
8 changed files with 195 additions and 71 deletions

34
package-lock.json generated
View File

@@ -29962,6 +29962,22 @@
"node": ">=4.0.0"
}
},
"node_modules/fetch-mock": {
"version": "12.6.0",
"resolved": "https://registry.npmjs.org/fetch-mock/-/fetch-mock-12.6.0.tgz",
"integrity": "sha512-oAy0OqAvjAvduqCeWveBix7LLuDbARPqZZ8ERYtBcCURA3gy7EALA3XWq0tCNxsSg+RmmJqyaeeZlOCV9abv6w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/glob-to-regexp": "^0.4.4",
"dequal": "^2.0.3",
"glob-to-regexp": "^0.4.1",
"regexparam": "^3.0.0"
},
"engines": {
"node": ">=18.11.0"
}
},
"node_modules/fflate": {
"version": "0.8.2",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
@@ -58447,7 +58463,7 @@
"eventsource-client": "^1.1.4",
"fake-indexeddb": "^6.0.0",
"fast-fuzzy": "^1.12.0",
"fetch-mock": "^12.5.2",
"fetch-mock": "^12.6.0",
"formik": "^2.2.9",
"fuse.js": "^7.0.0",
"glob": "^12.0.0",
@@ -59775,22 +59791,6 @@
}
}
},
"services/web/node_modules/fetch-mock": {
"version": "12.5.2",
"resolved": "https://registry.npmjs.org/fetch-mock/-/fetch-mock-12.5.2.tgz",
"integrity": "sha512-b5KGDFmdmado2MPQjZl6ix3dAG3iwCitb0XQwN72y2s9VnWZ3ObaGNy+bkpm1390foiLDybdJ7yjRGKD36kATw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/glob-to-regexp": "^0.4.4",
"dequal": "^2.0.3",
"glob-to-regexp": "^0.4.1",
"regexparam": "^3.0.0"
},
"engines": {
"node": ">=18.11.0"
}
},
"services/web/node_modules/foreground-child": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",

View File

@@ -1,3 +1,4 @@
import Path from 'node:path'
import _ from 'lodash'
import { NotFoundError, ResourceGoneError } from '../Errors/Errors.js'
import ClsiCacheHandler from './ClsiCacheHandler.mjs'
@@ -27,17 +28,21 @@ const ClsiCookieManager = ClsiCookieManagerFactory(
* @param userId
* @param filename
* @param signal
* @return {Promise<{internal: {location: string}, external: {zone: string, shard: string, isUpToDate: boolean, lastUpdated: Date, size: number, allFiles: string[]}}>}
* @return {Promise<{internal: {location: string, imageName: string, compiler: string}, external: {zone: string, shard: string, isUpToDate: boolean, lastUpdated: Date, size: number, allFiles: string[]}}>}
*/
async function getLatestBuildFromCache(projectId, userId, filename, signal) {
const [
{ location, lastModified: lastCompiled, zone, shard, size, allFiles },
lastUpdatedInRedis,
{ lastUpdated: lastUpdatedInMongo },
{ lastUpdated: lastUpdatedInMongo, imageName: fullImageName, compiler },
] = await Promise.all([
ClsiCacheHandler.getLatestOutputFile(projectId, userId, filename, signal),
DocumentUpdaterHandler.promises.getProjectLastUpdatedAt(projectId),
ProjectGetter.promises.getProject(projectId, { lastUpdated: 1 }),
ProjectGetter.promises.getProject(projectId, {
lastUpdated: 1,
imageName: 1,
compiler: 1,
}),
])
const lastUpdated =
@@ -45,10 +50,13 @@ async function getLatestBuildFromCache(projectId, userId, filename, signal) {
? lastUpdatedInRedis
: lastUpdatedInMongo
const isUpToDate = lastCompiled >= lastUpdated
const imageName = Path.basename(fullImageName)
return {
internal: {
location,
imageName,
compiler,
},
external: {
isUpToDate,
@@ -80,7 +88,7 @@ async function getLatestCompileResult(projectId, userId) {
async function tryGetLatestCompileResult(projectId, userId, signal) {
const {
internal: { location: metaLocation },
internal: { location: metaLocation, imageName, compiler },
external: {
isUpToDate,
allFiles,
@@ -127,6 +135,10 @@ async function tryGetLatestCompileResult(projectId, userId, signal) {
timings,
} = meta
if (options.imageName !== imageName || options.compiler !== compiler) {
throw new ResourceGoneError()
}
let baseURL = `/project/${projectId}`
if (userId) {
baseURL += `/user/${userId}`

View File

@@ -4,7 +4,7 @@ import { Project } from '../../../../../../../../types/project/dashboard/api'
import * as eventTracking from '../../../../../../infrastructure/event-tracking'
import { useLocation } from '../../../../../../shared/hooks/use-location'
import useAbortController from '../../../../../../shared/hooks/use-abort-controller'
import { postJSON } from '../../../../../../infrastructure/fetch-json'
import { getJSON, postJSON } from '../../../../../../infrastructure/fetch-json'
import { isSmallDevice } from '../../../../../../infrastructure/event-tracking'
import OLTooltip from '@/shared/components/ol/ol-tooltip'
import OLButton from '@/shared/components/ol/ol-button'
@@ -16,6 +16,10 @@ import {
OLModalTitle,
} from '@/shared/components/ol/ol-modal'
import OLIconButton from '@/shared/components/ol/ol-icon-button'
import getMeta from '@/utils/meta'
import { v4 as uuid } from 'uuid'
const FAKE_EDITOR_ID = uuid()
type CompileAndDownloadProjectPDFButtonProps = {
project: Project
@@ -49,43 +53,70 @@ function CompileAndDownloadProjectPDFButton({
isSmallDevice,
})
postJSON(`/project/${project.id}/compile`, {
body: {
check: 'silent',
draft: false,
incrementalCompilesEnabled: true,
},
signal,
})
.catch(() => ({ status: 'error' }))
.then(data => {
setPendingCompile(false)
if (data.status === 'success') {
const { isOverleaf } = getMeta('ol-ExposedSettings')
;(async () => {
if (isOverleaf) {
try {
const data = await getJSON(
`/project/${project.id}/output/cached/output.overleaf.json`,
{ signal }
)
if (data.options.draft) throw new Error('options changed')
setPendingCompile(false)
const outputFile = data.outputFiles
.filter((file: { path: string }) => file.path === 'output.pdf')
.pop()
const params = new URLSearchParams({
compileGroup: data.compileGroup,
popupDownload: 'true',
})
if (data.clsiServerId) {
params.set('clsiserverid', data.clsiServerId)
}
// Note: Triggering concurrent downloads does not work.
// Note: This is affecting the download of .zip files as well.
// When creating a dynamic `a` element with `download` attribute,
// another "actual" UI click is needed to trigger downloads.
// Forwarding the click `event` to the dynamic `a` element does
// not work either.
location.assign(
`/download/project/${project.id}/build/${outputFile.build}/output/output.pdf?${params}`
)
location.assign(outputFile.downloadURL)
onDone?.(e)
} else {
setShowErrorModal(true)
return
} catch {
// fall back to compile
}
}
let data
try {
data = await postJSON(`/project/${project.id}/compile`, {
body: {
check: 'silent',
draft: false,
incrementalCompilesEnabled: true,
// Add a fake editorId for enabling clsi-cache.
editorId: FAKE_EDITOR_ID,
},
signal,
})
} catch {
data = { status: 'error' }
}
setPendingCompile(false)
if (data.status !== 'success') {
setShowErrorModal(true)
return
}
const outputFile = data.outputFiles
.filter((file: { path: string }) => file.path === 'output.pdf')
.pop()
const params = new URLSearchParams({
compileGroup: data.compileGroup,
popupDownload: 'true',
})
if (data.clsiServerId) {
params.set('clsiserverid', data.clsiServerId)
}
// Note: Triggering concurrent downloads does not work.
// Note: This is affecting the download of .zip files as well.
// When creating a dynamic `a` element with `download` attribute,
// another "actual" UI click is needed to trigger downloads.
// Forwarding the click `event` to the dynamic `a` element does
// not work either.
location.assign(
`/download/project/${project.id}/build/${outputFile.build}/output/output.pdf?${params}`
)
onDone?.(e)
})()
return true
})
},

View File

@@ -398,8 +398,6 @@ export const LocalCompileProvider: FC<React.PropsWithChildren> = ({
const rootDocOverride = compiler.getRootDocOverrideId() || rootDocId
settingsUpToDate =
rootDocOverride === dataFromCache.rootDocId &&
dataFromCache.options.imageName === imageName &&
dataFromCache.options.compiler === compilerName &&
dataFromCache.options.draft === draft &&
// Allow stopOnFirstError to be enabled in the compile from cache and disabled locally.
// Compiles that passed with stopOnFirstError=true will also pass with stopOnFirstError=false. The inverse does not hold, and we need to recompile.

View File

@@ -330,7 +330,7 @@
"eventsource-client": "^1.1.4",
"fake-indexeddb": "^6.0.0",
"fast-fuzzy": "^1.12.0",
"fetch-mock": "^12.5.2",
"fetch-mock": "^12.6.0",
"formik": "^2.2.9",
"fuse.js": "^7.0.0",
"glob": "^12.0.0",

View File

@@ -9,7 +9,6 @@ import {
} from '../../../../frontend/js/shared/context/layout-context'
import { FC, PropsWithChildren, ReactElement, useEffect } from 'react'
import { useLocalCompileContext } from '@/shared/context/local-compile-context'
import { ProjectCompiler } from '../../../../types/project-settings'
const storeAndFireEvent = (win: typeof window, key: string, value: unknown) => {
localStorage.setItem(key, value)
@@ -211,20 +210,6 @@ describe('<PdfPreview/>', function () {
setup: () => {},
props: {},
},
'ignores the compile from cache when imageName changed': {
cached: false,
setup: () => {},
props: {
imageName: 'texlive-full:2025.1',
},
},
'ignores the compile from cache when compiler changed': {
cached: false,
setup: () => {},
props: {
compiler: 'lualatex' as ProjectCompiler,
},
},
'ignores the compile from cache when draft mode changed': {
cached: false,
setup: () => {

View File

@@ -790,6 +790,7 @@ describe('<UserNotifications />', function () {
})
const resendButton = resendButtons[0]
fireEvent.click(resendButton)
await fetchMock.callHistory.flush(true)
await screen.findByRole('dialog')

View File

@@ -6,6 +6,7 @@ import { location } from '@/shared/components/location'
import { CompileAndDownloadProjectPDFButtonTooltip } from '../../../../../../../../frontend/js/features/project-list/components/table/cells/action-buttons/compile-and-download-project-pdf-button'
import fetchMock from 'fetch-mock'
import * as eventTracking from '@/infrastructure/event-tracking'
import getMeta from '@/utils/meta'
describe('<CompileAndDownloadProjectPDFButton />', function () {
let sendMBSpy: sinon.SinonSpy
@@ -31,6 +32,102 @@ describe('<CompileAndDownloadProjectPDFButton />', function () {
await screen.findByRole('tooltip', { name: 'Download PDF' })
})
it('downloads the project PDF from cache when clicked', async function () {
Object.assign(getMeta('ol-ExposedSettings'), {
isOverleaf: true,
})
fetchMock.get(
`/project/${projectsData[0].id}/output/cached/output.overleaf.json`,
{
status: 'success',
compileGroup: 'standard',
clsiServerId: 'server-1',
outputFiles: [
{ path: 'output.pdf', build: '123-321', downloadURL: 'cached.pdf' },
],
options: { draft: false },
},
{ delay: 10 }
)
const btn = screen.getByRole('button', { name: 'Download PDF' })
fireEvent.click(btn)
await waitFor(() => {
screen.getByRole('button', { name: 'Compiling…' })
})
const assignStub = this.locationWrapperStub.assign
await waitFor(() => {
expect(assignStub).to.have.been.called
})
expect(assignStub).to.have.been.calledOnce
expect(assignStub).to.have.been.calledWith('cached.pdf')
expect(sendMBSpy).to.have.been.calledOnce
expect(sendMBSpy).to.have.been.calledWith('project-list-page-interaction', {
action: 'downloadPDF',
page: '/',
projectId: projectsData[0].id,
isSmallDevice: true,
})
})
it('ignores cached draft PDF and downloads the project PDF when clicked', async function () {
Object.assign(getMeta('ol-ExposedSettings'), {
isOverleaf: true,
})
fetchMock.get(
`/project/${projectsData[0].id}/output/cached/output.overleaf.json`,
{
status: 'success',
compileGroup: 'standard',
clsiServerId: 'server-1',
outputFiles: [
{ path: 'output.pdf', build: '123-321', downloadURL: 'cached.pdf' },
],
options: { draft: true },
},
{ delay: 10 }
)
fetchMock.post(
`/project/${projectsData[0].id}/compile`,
{
status: 'success',
compileGroup: 'standard',
clsiServerId: 'server-1',
outputFiles: [{ path: 'output.pdf', build: '123-321' }],
},
{ delay: 10 }
)
const btn = screen.getByRole('button', { name: 'Download PDF' })
fireEvent.click(btn)
await waitFor(() => {
screen.getByRole('button', { name: 'Compiling…' })
})
const assignStub = this.locationWrapperStub.assign
await waitFor(() => {
expect(assignStub).to.have.been.called
})
expect(assignStub).to.have.been.calledOnce
expect(assignStub).to.have.been.calledWith(
`/download/project/${projectsData[0].id}/build/123-321/output/output.pdf?compileGroup=standard&popupDownload=true&clsiserverid=server-1`
)
expect(sendMBSpy).to.have.been.calledOnce
expect(sendMBSpy).to.have.been.calledWith('project-list-page-interaction', {
action: 'downloadPDF',
page: '/',
projectId: projectsData[0].id,
isSmallDevice: true,
})
})
it('downloads the project PDF when clicked', async function () {
fetchMock.post(
`/project/${projectsData[0].id}/compile`,