1+ import type { Connection } from '@hocuspocus/server'
12import { Server } from '@hocuspocus/server'
23
34import HocuspocusConfig from './config/hocuspocus.config'
5+ import { handleHistoryStateless } from './lib/history-stateless'
46import { logger } from './lib/logger'
5- import { prisma , shutdownDatabase } from './lib/prisma'
7+ import { shutdownDatabase } from './lib/prisma'
68import { disconnectRedis } from './lib/redis'
7- import type { HistoryPayload } from './types'
9+ import type { HistoryPayload } from './types/document.types '
810import { verifyJWT } from './utils'
911
1012process . env . NODE_ENV = process . env . NODE_ENV || 'development'
1113
12- // Create logger for WebSocket server
1314const wsLogger = logger . child ( { service : 'websocket' } )
1415
15- async function handleHistoryEvents ( payload : HistoryPayload , _context : any , _document : any ) {
16- const { type, documentId } = payload
17-
18- switch ( type ) {
19- case 'history.list' : {
20- const docs = await prisma . documents . findMany ( {
21- where : { documentId } ,
22- orderBy : { createdAt : 'desc' } ,
23- select : { version : true , commitMessage : true , createdAt : true }
24- } )
25- return docs
26- }
27-
28- case 'history.watch' : {
29- const doc = await prisma . documents . findFirst ( {
30- where : { documentId, version : payload . version } ,
31- select : { data : true , version : true , commitMessage : true , createdAt : true }
32- } )
33-
34- if ( ! doc ) return null
35-
36- // Convert Buffer to Base64 string for transport
37- return {
38- data : Buffer . from ( doc . data ) . toString ( 'base64' ) ,
39- version : doc . version ,
40- commitMessage : doc . commitMessage ,
41- createdAt : doc . createdAt
42- }
43- }
44-
45- case 'history.prev' :
46- return prisma . documents . findFirst ( {
47- where : { documentId, version : { lt : payload . currentVersion || 0 } } ,
48- orderBy : { version : 'desc' }
49- } )
50-
51- case 'history.next' :
52- return prisma . documents . findFirst ( {
53- where : { documentId, version : { gt : payload . currentVersion || 0 } } ,
54- orderBy : { version : 'asc' }
55- } )
56-
57- default :
58- return payload
59- }
16+ function sendHistoryResponse (
17+ connection : Connection ,
18+ type : string ,
19+ response : unknown ,
20+ error ?: string
21+ ) {
22+ connection . sendStateless (
23+ JSON . stringify ( {
24+ msg : 'history.response' ,
25+ type,
26+ response,
27+ ...( error ? { error } : { } )
28+ } )
29+ )
6030}
6131
62- const broadcastToAll = ( document : any , payload : any ) => {
63- document . broadcastStateless ( JSON . stringify ( payload ) )
32+ /** Hocuspocus document name === provider `name` / room id; never trust client `documentId` for Prisma. */
33+ function roomDocumentId ( document : { name ?: string } ) : string | null {
34+ const n = document ?. name
35+ return typeof n === 'string' && n . length > 0 ? n : null
6436}
6537
6638const statelessExtension = {
67- async onStateless ( { payload, context, document, _connection } : any ) {
68- const parsedPayload = JSON . parse ( payload )
39+ async onStateless ( {
40+ payload,
41+ connection,
42+ document
43+ } : {
44+ payload : string
45+ connection : Connection
46+ document : { name ?: string ; broadcastStateless : ( p : string ) => void }
47+ } ) {
48+ let parsedPayload : {
49+ msg ?: string
50+ type ?: string
51+ documentId ?: string
52+ version ?: number
53+ currentVersion ?: number
54+ }
55+ try {
56+ parsedPayload = JSON . parse ( payload ) as typeof parsedPayload
57+ } catch {
58+ return
59+ }
6960
7061 if ( parsedPayload . msg === 'history' ) {
62+ const canonicalId = roomDocumentId ( document )
63+ const type = parsedPayload . type
64+
65+ if ( ! canonicalId || ! type ) {
66+ wsLogger . warn (
67+ { parsedPayload, hasRoomName : Boolean ( canonicalId ) } ,
68+ 'history stateless missing room or type'
69+ )
70+ if ( type ) sendHistoryResponse ( connection , type , null , 'history_failed' )
71+ return
72+ }
73+
74+ if ( parsedPayload . documentId != null && parsedPayload . documentId !== canonicalId ) {
75+ wsLogger . warn (
76+ { clientDocumentId : parsedPayload . documentId , canonicalId } ,
77+ 'history stateless documentId does not match connection room'
78+ )
79+ sendHistoryResponse ( connection , type , null , 'history_failed' )
80+ return
81+ }
82+
83+ const historyPayload : HistoryPayload = {
84+ type,
85+ documentId : canonicalId ,
86+ version : parsedPayload . version ,
87+ currentVersion : parsedPayload . currentVersion
88+ }
89+
7190 try {
72- const response = await handleHistoryEvents ( parsedPayload , context , document )
73- broadcastToAll ( document , {
74- msg : 'history.response' ,
75- type : parsedPayload . type ,
76- response
77- } )
91+ const response = await handleHistoryStateless ( historyPayload )
92+ sendHistoryResponse ( connection , type , response )
7893 } catch ( error ) {
7994 wsLogger . error ( { err : error } , 'Error handling history event' )
95+ sendHistoryResponse ( connection , type , null , 'history_failed' )
8096 }
81- } else {
82- document . broadcastStateless ( JSON . stringify ( parsedPayload ) )
97+ return
8398 }
99+
100+ document . broadcastStateless ( JSON . stringify ( parsedPayload ) )
84101 }
85102}
86103
87- // Get the base configuration and add our stateless extension
88104const baseConfig = HocuspocusConfig ( )
89105const serverConfig = {
90106 ...baseConfig ,
@@ -98,7 +114,6 @@ const serverConfig = {
98114
99115 try {
100116 const tokenData = JSON . parse ( token )
101- // Extract deviceType from token (sent by webapp)
102117 const deviceType = tokenData . deviceType || 'desktop'
103118
104119 if ( tokenData . accessToken ) {
@@ -109,7 +124,6 @@ const serverConfig = {
109124 return { user, slug : tokenData . slug || '' , documentId : documentName , deviceType }
110125 }
111126
112- // Token invalid
113127 if ( process . env . NODE_ENV === 'production' ) {
114128 throw new Error ( 'Invalid authentication token' )
115129 }
@@ -118,7 +132,6 @@ const serverConfig = {
118132 return { user : null , slug : tokenData . slug || '' , documentId : documentName , deviceType }
119133 }
120134
121- // No accessToken, allow with slug only
122135 return { user : null , slug : tokenData . slug || '' , documentId : documentName , deviceType }
123136 } catch ( error ) {
124137 wsLogger . error ( { err : error , documentName } , 'Auth error' )
@@ -132,10 +145,8 @@ const serverConfig = {
132145 }
133146}
134147
135- // Configure and start the server
136148const server = new Server ( serverConfig )
137149
138- // Start listening
139150server . listen ( )
140151
141152wsLogger . info ( {
@@ -145,7 +156,6 @@ wsLogger.info({
145156 url : `ws://localhost:${ baseConfig . port } `
146157} )
147158
148- // Graceful shutdown
149159const shutdown = async ( ) => {
150160 wsLogger . info ( 'Shutting down WebSocket server gracefully...' )
151161
0 commit comments