Files
overleaf-cep/services/real-time/app/js/HttpApiController.js
T
Jakob Ackermann f6bb5ce1bd [real-time] adopt strict zod types and extend test coverage (#36031)
* [editor-core] extend coverage of zod schemas

* [real-time] adopt strict zod types and extend test coverage

* [project-history] make type check happy

* [web] make type check happy

GitOrigin-RevId: e1a65b9b3560b31ad2af817067a4b549865a5bb1
2026-09-17 11:42:50 +00:00

116 lines
3.3 KiB
JavaScript

import WebsocketLoadBalancer from './WebsocketLoadBalancer.js'
import DrainManager from './DrainManager.js'
import ConnectedUsersManager from './ConnectedUsersManager.js'
import logger from '@overleaf/logger'
import { z, zz, parseReq } from '@overleaf/validation-tools'
import { clientIdSchema } from './schemas.js'
const countConnectedClientsSchema = z.object({
params: z.strictObject({
projectId: zz.objectId(),
}),
})
// A generic relay: real-time never reads a named field from this payload,
// it forwards it verbatim to connected socket.io clients in the project's
// room (see WebsocketLoadBalancer.emitToRoom) as the named `message` event.
// The payload is a genuinely open, structured map (or an array of them, one
// message per element) -- there is no fixed shape to model here.
const sendMessageSchema = z.object({
params: z.strictObject({
project_id: zz.objectId(),
message: z.string().min(1),
}),
body: z
.array(z.record(z.string(), z.unknown()))
.or(z.record(z.string(), z.unknown())),
})
const startDrainSchema = z.object({
query: z.strictObject({
rate: z.coerce.number().default(4),
}),
})
// Rollout-temporary fallback (loosened primary schema; no zod validation
// existed for this route on main); delete when this route's
// REQ_VALIDATION_MODE instrumentation is removed.
const startDrainFallbackSchema = z.object({
query: z.object({
rate: z.coerce.number().default(4),
}),
})
const disconnectClientSchema = z.object({
params: z.strictObject({
client_id: clientIdSchema,
}),
})
export default {
countConnectedClients(req, res) {
const {
params: { projectId },
} = parseReq(req, countConnectedClientsSchema, { logOnly: true })
ConnectedUsersManager.countConnectedClients(
projectId,
(err, nConnectedClients) => {
if (err) {
logger.err({ err, projectId }, 'count connected clients failed')
return res.sendStatus(500)
}
res.json({ nConnectedClients })
}
)
},
sendMessage(req, res) {
const { params, body } = parseReq(req, sendMessageSchema, {
logOnly: true,
})
logger.debug({ message: params.message }, 'sending message')
if (Array.isArray(body)) {
for (const payload of body) {
WebsocketLoadBalancer.emitToRoom(
params.project_id,
params.message,
payload
)
}
} else {
WebsocketLoadBalancer.emitToRoom(params.project_id, params.message, body)
}
res.sendStatus(204)
},
startDrain(req, res) {
const io = req.app.get('io')
const {
query: { rate },
} = parseReq(req, startDrainSchema, {
logOnly: true,
fallbackSchema: startDrainFallbackSchema,
})
logger.info({ rate }, 'setting client drain rate')
DrainManager.startDrain(io, rate)
res.sendStatus(204)
},
disconnectClient(req, res, next) {
const io = req.app.get('io')
const {
params: { client_id: clientId },
} = parseReq(req, disconnectClientSchema, { logOnly: true })
const client = io.sockets.sockets[clientId]
if (!client) {
logger.debug({ clientId }, 'api: client already disconnected')
res.sendStatus(404)
return
}
logger.info({ clientId }, 'api: requesting client disconnect')
client.on('disconnect', () => res.sendStatus(204))
client.disconnect()
},
}