platform/server/collaborator/src/server.ts
2024-08-29 15:10:37 +07:00

208 lines
6.4 KiB
TypeScript

//
// Copyright © 2023 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
//
import { Analytics } from '@hcengineering/analytics'
import { MeasureContext, generateId, metricsAggregate } from '@hcengineering/core'
import type { StorageAdapter } from '@hcengineering/server-core'
import { Token, decodeToken } from '@hcengineering/server-token'
import { Hocuspocus } from '@hocuspocus/server'
import bp from 'body-parser'
import cors from 'cors'
import express from 'express'
import { IncomingMessage, createServer } from 'http'
import { WebSocket, WebSocketServer } from 'ws'
import { Config } from './config'
import { Context } from './context'
import { AuthenticationExtension } from './extensions/authentication'
import { StorageExtension } from './extensions/storage'
import { simpleClientFactory } from './platform'
import { RpcErrorResponse, RpcRequest, RpcResponse, methods } from './rpc'
import { PlatformStorageAdapter } from './storage/platform'
import { MarkupTransformer } from './transformers/markup'
/**
* @public
*/
export type Shutdown = () => Promise<void>
/**
* @public
*/
export async function start (ctx: MeasureContext, config: Config, storageAdapter: StorageAdapter): Promise<Shutdown> {
const port = config.Port
ctx.info('Starting collaborator server', { port })
const app = express()
app.use(cors())
app.use(bp.json())
const extensionsCtx = ctx.newChild('extensions', {})
const transformer = new MarkupTransformer()
const hocuspocus = new Hocuspocus({
address: '0.0.0.0',
port,
/**
* Defines in which interval the server sends a ping, and closes the connection when no pong is sent back.
*/
timeout: 30000,
/**
* Debounces the call of the `onStoreDocument` hook for the given amount of time in ms.
* Otherwise every single update would be persisted.
*/
debounce: 10000,
/**
* Makes sure to call `onStoreDocument` at least in the given amount of time (ms).
*/
maxDebounce: 60000,
/**
* options to pass to the ydoc document
*/
yDocOptions: {
// we intentionally disable gc in order to make snapshots working
// see https://github.com/yjs/yjs/blob/v13.5.52/src/utils/Snapshot.js#L162
gc: false,
gcFilter: () => false
},
/**
* If set to false, respects the debounce time of `onStoreDocument` before unloading a document.
* Otherwise, the document will be unloaded immediately.
*
* This prevents a client from DOSing the server by repeatedly connecting and disconnecting when
* your onStoreDocument is rate-limited.
*/
unloadImmediately: false,
extensions: [
new AuthenticationExtension({
ctx: extensionsCtx.newChild('authenticate', {})
}),
new StorageExtension({
ctx: extensionsCtx.newChild('storage', {}),
adapter: new PlatformStorageAdapter(storageAdapter),
transformer
})
]
})
const rpcCtx = ctx.newChild('rpc', {})
const getContext = (token: Token): Context => {
return {
connectionId: generateId(),
workspaceId: token.workspace,
clientFactory: simpleClientFactory(token)
}
}
app.get('/api/v1/statistics', (req, res) => {
try {
const token = req.query.token as string
const payload = decodeToken(token)
const admin = payload.extra?.admin === 'true'
res.status(200)
res.setHeader('Content-Type', 'application/json')
res.setHeader('Cache-Control', 'public, no-store, no-cache, must-revalidate, max-age=0')
const json = JSON.stringify({
metrics: metricsAggregate((ctx as any).metrics),
statistics: {
activeSessions: {}
},
admin
})
res.end(json)
} catch (err: any) {
ctx.error('statistics error', { err })
Analytics.handleError(err)
res.writeHead(404, {})
res.end()
}
})
// eslint-disable-next-line @typescript-eslint/no-misused-promises
app.post('/rpc', async (req, res) => {
const authHeader = req.headers.authorization
if (authHeader === undefined) {
res.status(403).send({ error: 'Unauthorized' })
return
}
const request = req.body as RpcRequest
const documentId = request.documentId
if (documentId === undefined || documentId === '') {
const response: RpcErrorResponse = {
error: 'Missing documentId'
}
res.status(400).send(response)
return
}
const method = methods[request.method]
if (method === undefined) {
const response: RpcErrorResponse = {
error: 'Unknown method'
}
res.status(400).send(response)
return
}
const token = decodeToken(authHeader.split(' ')[1])
const context = getContext(token)
rpcCtx.info('rpc', { method: request.method, connectionId: context.connectionId, mode: token.extra?.mode ?? '' })
await rpcCtx.with('/rpc', { method: request.method }, async (ctx) => {
try {
const response: RpcResponse = await rpcCtx.with(request.method, {}, async (ctx) => {
return await method(ctx, context, documentId, request.payload, { hocuspocus, storageAdapter, transformer })
})
res.status(200).send(response)
} catch (err: any) {
res.status(500).send({ error: err.message })
}
})
})
const wss = new WebSocketServer({
noServer: true,
perMessageDeflate: false
})
wss.on('connection', (incoming: WebSocket, request: IncomingMessage) => {
const context: Partial<Context> = { connectionId: generateId() }
hocuspocus.handleConnection(incoming, request, context)
})
const server = createServer(app)
server.on('upgrade', (request: IncomingMessage, socket: any, head: Buffer) => {
wss.handleUpgrade(request, socket, head, (ws) => {
wss.emit('connection', ws, request)
})
})
server.listen(port)
ctx.info('Running collaborator server', { port })
return async () => {
server.close()
}
}