Merge pull request #28264 from overleaf/jpa-synctex

[web] use standard request handling for SyncTeX requests

GitOrigin-RevId: ad5ba1834241d5939675f2533940ade741fc5abf
This commit is contained in:
Jakob Ackermann
2025-09-04 09:44:42 +02:00
committed by Copybot
parent a85b2b34f5
commit 03320bb377
4 changed files with 162 additions and 102 deletions
@@ -818,9 +818,9 @@ function _finaliseRequest(projectId, options, project, docs, files) {
}
}
async function wordCount(projectId, userId, file, options, clsiserverid) {
const { compileBackendClass, compileGroup } = options
const req = await _buildRequest(projectId, options)
async function wordCount(projectId, userId, file, limits, clsiserverid) {
const { compileBackendClass, compileGroup } = limits
const req = await _buildRequest(projectId, limits)
const filename = file || req.compile.rootResourcePath
const url = _getCompilerUrl(
compileBackendClass,
@@ -847,6 +847,56 @@ async function wordCount(projectId, userId, file, options, clsiserverid) {
return body
}
async function syncTeX(
projectId,
userId,
{
direction,
compileFromClsiCache,
limits,
imageName,
validatedOptions,
clsiServerId,
}
) {
const { compileBackendClass, compileGroup } = limits
const url = _getCompilerUrl(
compileBackendClass,
compileGroup,
projectId,
userId,
`sync/${direction}`
)
url.searchParams.set(
'compileFromClsiCache',
compileFromClsiCache && ['alpha', 'priority'].includes(compileGroup)
)
url.searchParams.set('imageName', imageName)
for (const [key, value] of Object.entries(validatedOptions)) {
url.searchParams.set(key, value)
}
const opts = {
method: 'GET',
}
try {
const { body } = await _makeRequestWithClsiServerId(
projectId,
userId,
compileGroup,
compileBackendClass,
url,
opts,
clsiServerId
)
return body
} catch (err) {
if (err instanceof RequestFailedError && err.response.status === 404) {
throw new Errors.NotFoundError()
}
throw err
}
}
function _getClsiServerIdFromResponse(response) {
const setCookieHeaders = response.headers.raw()['set-cookie'] ?? []
for (const header of setCookieHeaders) {
@@ -883,6 +933,7 @@ module.exports = {
deleteAuxFiles: callbackify(deleteAuxFiles),
getOutputFileStream: callbackify(getOutputFileStream),
wordCount: callbackify(wordCount),
syncTeX: callbackify(syncTeX),
promises: {
sendRequest,
sendExternalRequest,
@@ -890,5 +941,6 @@ module.exports = {
deleteAuxFiles,
getOutputFileStream,
wordCount,
syncTeX,
},
}
@@ -8,6 +8,7 @@ const CompileManager = require('./CompileManager')
const ClsiManager = require('./ClsiManager')
const logger = require('@overleaf/logger')
const Settings = require('@overleaf/settings')
const Errors = require('../Errors/Errors')
const SessionManager = require('../Authentication/SessionManager')
const { RateLimiter } = require('../../infrastructure/RateLimiter')
const ClsiCookieManager = require('./ClsiCookieManager')(
@@ -38,16 +39,6 @@ function getOutputFilesArchiveSpecification(projectId, userId, buildId) {
}
}
async function getImageNameForProject(projectId) {
const project = await ProjectGetter.promises.getProject(projectId, {
imageName: 1,
})
if (!project) {
throw new Error('project not found')
}
return project.imageName
}
async function getPdfCachingMinChunkSize(req, res) {
const { variant } = await SplitTestHandler.promises.getAssignment(
req,
@@ -121,6 +112,32 @@ async function _getSplitTestOptions(req, res) {
}
}
async function _syncTeX(req, res, direction, validatedOptions) {
const projectId = req.params.Project_id
const { editorId, buildId, clsiserverid: clsiServerId } = req.query
if (!editorId?.match(/^[a-f0-9-]+$/)) throw new Error('invalid ?editorId')
if (!buildId?.match(/^[a-f0-9-]+$/)) throw new Error('invalid ?buildId')
const userId = CompileController._getUserIdForCompile(req)
const { compileFromClsiCache } = await _getSplitTestOptions(req, res)
try {
const body = await CompileManager.promises.syncTeX(projectId, userId, {
direction,
compileFromClsiCache,
validatedOptions: {
...validatedOptions,
editorId,
buildId,
},
clsiServerId,
})
res.json(body)
} catch (err) {
if (err instanceof Errors.NotFoundError) return res.status(404).end()
throw err
}
}
const _CompileController = {
async compile(req, res) {
res.setTimeout(COMPILE_TIMEOUT_MS)
@@ -470,18 +487,8 @@ const _CompileController = {
return url
},
// compute a POST url for a project, user (optional) and action
_getUrl(projectId, userId, action) {
let path = `/project/${projectId}`
if (userId != null) {
path += `/user/${userId}`
}
return `${path}/${action}`
},
async proxySyncPdf(req, res) {
const projectId = req.params.Project_id
const { page, h, v, editorId, buildId } = req.query
const { page, h, v } = req.query
if (!page?.match(/^\d+$/)) {
throw new Error('invalid page parameter')
}
@@ -491,28 +498,11 @@ const _CompileController = {
if (!v?.match(/^-?\d+\.\d+$/)) {
throw new Error('invalid v parameter')
}
// whether this request is going to a per-user container
const userId = CompileController._getUserIdForCompile(req)
const imageName = await getImageNameForProject(projectId)
const { compileFromClsiCache } = await _getSplitTestOptions(req, res)
const url = _CompileController._getUrl(projectId, userId, 'sync/pdf')
await CompileController._proxyToClsi(
projectId,
'sync-to-pdf',
url,
{ page, h, v, imageName, editorId, buildId, compileFromClsiCache },
req,
res
)
await _syncTeX(req, res, 'pdf', { page, h, v })
},
async proxySyncCode(req, res) {
const projectId = req.params.Project_id
const { file, line, column, editorId, buildId } = req.query
const { file, line, column } = req.query
if (file == null) {
throw new Error('missing file parameter')
}
@@ -531,40 +521,12 @@ const _CompileController = {
if (!column?.match(/^\d+$/)) {
throw new Error('invalid column parameter')
}
const userId = CompileController._getUserIdForCompile(req)
const imageName = await getImageNameForProject(projectId)
const { compileFromClsiCache } = await _getSplitTestOptions(req, res)
const url = _CompileController._getUrl(projectId, userId, 'sync/code')
await CompileController._proxyToClsi(
projectId,
'sync-to-code',
url,
{
file,
line,
column,
imageName,
editorId,
buildId,
compileFromClsiCache,
},
req,
res
)
await _syncTeX(req, res, 'code', { file, line, column })
},
async _proxyToClsi(projectId, action, url, qs, req, res) {
const limits =
await CompileManager.promises.getProjectCompileLimits(projectId)
if (
qs?.compileFromClsiCache &&
!['alpha', 'priority'].includes(limits.compileGroup)
) {
qs.compileFromClsiCache = false
}
return CompileController._proxyToClsiWithLimits(
projectId,
action,
@@ -110,7 +110,13 @@ async function getProjectCompileLimits(projectId) {
const project = await ProjectGetter.promises.getProject(projectId, {
owner_ref: 1,
})
return _getProjectCompileLimits(project)
}
async function _getProjectCompileLimits(project) {
if (!project) {
throw new Error('project not found')
}
const owner = await UserGetter.promises.getUser(project.owner_ref, {
_id: 1,
alphaProgram: 1,
@@ -162,6 +168,27 @@ async function wordCount(projectId, userId, file, clsiserverid) {
)
}
async function syncTeX(
projectId,
userId,
{ direction, compileFromClsiCache, validatedOptions, clsiServerId }
) {
const project = await ProjectGetter.promises.getProject(projectId, {
owner_ref: 1,
imageName: 1,
})
const limits = await _getProjectCompileLimits(project)
const { imageName } = project
return await ClsiManager.promises.syncTeX(projectId, userId, {
direction,
limits,
imageName,
compileFromClsiCache,
validatedOptions,
clsiServerId,
})
}
async function stopCompile(projectId, userId) {
const limits =
await CompileManager.promises.getProjectCompileLimits(projectId)
@@ -188,6 +215,7 @@ module.exports = CompileManager = {
getProjectCompileLimits,
stopCompile,
wordCount,
syncTeX,
},
compile: callbackifyMultiResult(instrumentedCompile, [
'status',
@@ -249,6 +277,7 @@ module.exports = CompileManager = {
},
wordCount: callbackify(wordCount),
syncTeX: callbackify(syncTeX),
}
const autoCompileRateLimiters = new Map()
@@ -22,6 +22,7 @@ describe('CompileController', function () {
promises: {
compile: sinon.stub(),
getProjectCompileLimits: sinon.stub(),
syncTeX: sinon.stub(),
},
}
this.ClsiManager = {
@@ -594,16 +595,24 @@ describe('CompileController', function () {
})
})
describe('proxySyncCode', function () {
let file, line, column, imageName, editorId, buildId
let file, line, column, imageName, editorId, buildId, clsiServerId
beforeEach(async function () {
this.req.params = { Project_id: this.projectId }
clsiServerId = 'clsi-1'
file = 'main.tex'
line = String(Date.now())
column = String(Date.now() + 1)
editorId = '172977cb-361e-4854-a4dc-a71cf11512e5'
buildId = '195b4a3f9e7-03e5be430a9e7796'
this.req.query = { file, line, column, editorId, buildId }
this.req.query = {
file,
line,
column,
editorId,
buildId,
clsiserverid: clsiServerId,
}
imageName = 'foo/bar:tag-0'
this.ProjectGetter.promises.getProject = sinon
@@ -615,37 +624,45 @@ describe('CompileController', function () {
await this.CompileController.proxySyncCode(this.req, this.res, this.next)
})
it('should proxy the request with an imageName', function () {
expect(this.CompileController._proxyToClsi).to.have.been.calledWith(
it('should parse the parameters', function () {
expect(this.CompileManager.promises.syncTeX).to.have.been.calledWith(
this.projectId,
'sync-to-code',
`/project/${this.projectId}/user/${this.user_id}/sync/code`,
this.user_id,
{
file,
line,
column,
imageName,
editorId,
buildId,
direction: 'code',
compileFromClsiCache: false,
},
this.req,
this.res
validatedOptions: {
file,
line,
column,
editorId,
buildId,
},
clsiServerId,
}
)
})
})
describe('proxySyncPdf', function () {
let page, h, v, imageName, editorId, buildId
let page, h, v, imageName, editorId, buildId, clsiServerId
beforeEach(async function () {
this.req.params = { Project_id: this.projectId }
clsiServerId = 'clsi-1'
page = String(Date.now())
h = String(Math.random())
v = String(Math.random())
editorId = '172977cb-361e-4854-a4dc-a71cf11512e5'
buildId = '195b4a3f9e7-03e5be430a9e7796'
this.req.query = { page, h, v, editorId, buildId }
this.req.query = {
page,
h,
v,
editorId,
buildId,
clsiserverid: clsiServerId,
}
imageName = 'foo/bar:tag-1'
this.ProjectGetter.promises.getProject = sinon
@@ -657,22 +674,22 @@ describe('CompileController', function () {
await this.CompileController.proxySyncPdf(this.req, this.res, this.next)
})
it('should proxy the request with an imageName', function () {
expect(this.CompileController._proxyToClsi).to.have.been.calledWith(
it('should parse the parameters', function () {
expect(this.CompileManager.promises.syncTeX).to.have.been.calledWith(
this.projectId,
'sync-to-pdf',
`/project/${this.projectId}/user/${this.user_id}/sync/pdf`,
this.user_id,
{
page,
h,
v,
imageName,
editorId,
buildId,
direction: 'pdf',
compileFromClsiCache: false,
},
this.req,
this.res
validatedOptions: {
page,
h,
v,
editorId,
buildId,
},
clsiServerId,
}
)
})
})