@symbo.ls/sdk 3.1.2 → 3.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/dist/cjs/config/environment.js +5 -21
- package/dist/cjs/index.js +6 -26
- package/dist/cjs/services/AIService.js +3 -3
- package/dist/cjs/services/CollabService.js +420 -0
- package/dist/cjs/services/CoreService.js +651 -107
- package/dist/cjs/services/SocketService.js +207 -59
- package/dist/cjs/services/index.js +5 -13
- package/dist/cjs/state/RootStateManager.js +86 -0
- package/dist/cjs/state/rootEventBus.js +65 -0
- package/dist/cjs/utils/CollabClient.js +157 -0
- package/dist/cjs/utils/TokenManager.js +62 -27
- package/dist/cjs/utils/jsonDiff.js +103 -0
- package/dist/cjs/utils/services.js +129 -88
- package/dist/cjs/utils/symstoryClient.js +5 -5
- package/dist/esm/config/environment.js +5 -21
- package/dist/esm/index.js +20459 -9286
- package/dist/esm/services/AIService.js +3 -3
- package/dist/esm/services/BasedService.js +5 -21
- package/dist/esm/services/CollabService.js +18028 -0
- package/dist/esm/services/CoreService.js +718 -155
- package/dist/esm/services/SocketService.js +323 -58
- package/dist/esm/services/SymstoryService.js +10 -26
- package/dist/esm/services/index.js +20305 -9158
- package/dist/esm/state/RootStateManager.js +102 -0
- package/dist/esm/state/rootEventBus.js +47 -0
- package/dist/esm/utils/CollabClient.js +17483 -0
- package/dist/esm/utils/TokenManager.js +62 -27
- package/dist/esm/utils/jsonDiff.js +6096 -0
- package/dist/esm/utils/services.js +129 -88
- package/dist/esm/utils/symstoryClient.js +10 -26
- package/dist/node/config/environment.js +5 -21
- package/dist/node/index.js +10 -34
- package/dist/node/services/AIService.js +3 -3
- package/dist/node/services/CollabService.js +401 -0
- package/dist/node/services/CoreService.js +651 -107
- package/dist/node/services/SocketService.js +197 -59
- package/dist/node/services/index.js +5 -13
- package/dist/node/state/RootStateManager.js +57 -0
- package/dist/node/state/rootEventBus.js +46 -0
- package/dist/node/utils/CollabClient.js +128 -0
- package/dist/node/utils/TokenManager.js +62 -27
- package/dist/node/utils/jsonDiff.js +74 -0
- package/dist/node/utils/services.js +129 -88
- package/dist/node/utils/symstoryClient.js +5 -5
- package/package.json +12 -6
- package/src/config/environment.js +5 -19
- package/src/index.js +9 -31
- package/src/services/AIService.js +3 -3
- package/src/services/BasedService.js +1 -0
- package/src/services/CollabService.js +491 -0
- package/src/services/CoreService.js +715 -110
- package/src/services/SocketService.js +227 -59
- package/src/services/index.js +6 -13
- package/src/state/RootStateManager.js +71 -0
- package/src/state/rootEventBus.js +48 -0
- package/src/utils/CollabClient.js +161 -0
- package/src/utils/TokenManager.js +68 -30
- package/src/utils/jsonDiff.js +109 -0
- package/src/utils/services.js +140 -88
- package/src/utils/symstoryClient.js +5 -5
- package/dist/cjs/services/SocketIOService.js +0 -307
- package/dist/esm/services/SocketIOService.js +0 -470
- package/dist/node/services/SocketIOService.js +0 -278
- package/src/services/SocketIOService.js +0 -334
|
@@ -1,4 +1,11 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { connect, send, disconnect } from '@symbo.ls/socket/client.js'
|
|
2
|
+
import { BaseService } from './BaseService.js'
|
|
3
|
+
|
|
4
|
+
import * as utils from '@domql/utils'
|
|
5
|
+
import { router } from '@symbo.ls/router'
|
|
6
|
+
import environment from '../config/environment.js'
|
|
7
|
+
|
|
8
|
+
const { deepStringify, deepDestringify, isString } = utils.default || utils
|
|
2
9
|
|
|
3
10
|
export class SocketService extends BaseService {
|
|
4
11
|
constructor (config) {
|
|
@@ -8,14 +15,31 @@ export class SocketService extends BaseService {
|
|
|
8
15
|
this._maxReconnectAttempts = config?.maxReconnectAttempts || 5
|
|
9
16
|
this._reconnectDelay = config?.reconnectDelay || 1000
|
|
10
17
|
this._handlers = new Map()
|
|
18
|
+
this._sessionId = Math.random()
|
|
19
|
+
|
|
20
|
+
this._ignoreSync = [
|
|
21
|
+
'userId',
|
|
22
|
+
'username',
|
|
23
|
+
'usersName',
|
|
24
|
+
'email',
|
|
25
|
+
'projects',
|
|
26
|
+
'feedbacks',
|
|
27
|
+
'userRoles',
|
|
28
|
+
'loading',
|
|
29
|
+
'appKey',
|
|
30
|
+
'projectName',
|
|
31
|
+
'followingUser',
|
|
32
|
+
'activeProject',
|
|
33
|
+
'user',
|
|
34
|
+
'sessionId',
|
|
35
|
+
'clients'
|
|
36
|
+
]
|
|
11
37
|
}
|
|
12
38
|
|
|
13
|
-
|
|
39
|
+
init () {
|
|
14
40
|
try {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
const { authToken } = context
|
|
18
|
-
const { socketUrl } = context.socket || {}
|
|
41
|
+
const { _context, _options } = this
|
|
42
|
+
const socketUrl = environment.socketUrl || _options.socketUrl
|
|
19
43
|
|
|
20
44
|
if (!socketUrl) {
|
|
21
45
|
throw new Error('Socket URL is required')
|
|
@@ -24,12 +48,11 @@ export class SocketService extends BaseService {
|
|
|
24
48
|
this._info = {
|
|
25
49
|
config: {
|
|
26
50
|
url: socketUrl,
|
|
27
|
-
hasToken: Boolean(authToken),
|
|
51
|
+
hasToken: Boolean(_context.authToken),
|
|
28
52
|
status: 'initializing'
|
|
29
53
|
}
|
|
30
54
|
}
|
|
31
55
|
|
|
32
|
-
await this.connect()
|
|
33
56
|
this._setReady()
|
|
34
57
|
} catch (error) {
|
|
35
58
|
this._setError(error)
|
|
@@ -39,93 +62,233 @@ export class SocketService extends BaseService {
|
|
|
39
62
|
|
|
40
63
|
connect () {
|
|
41
64
|
try {
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
65
|
+
// Check if already connected or connecting
|
|
66
|
+
if (
|
|
67
|
+
this._socket &&
|
|
68
|
+
['connected', 'connecting'].includes(this._info?.config?.status)
|
|
69
|
+
) {
|
|
70
|
+
console.warn(
|
|
71
|
+
'Socket connection already exists:',
|
|
72
|
+
this._info?.config?.status
|
|
73
|
+
)
|
|
74
|
+
return true
|
|
75
|
+
}
|
|
45
76
|
|
|
46
|
-
|
|
47
|
-
this._reconnectAttempts = 0
|
|
48
|
-
this._updateStatus('connected')
|
|
77
|
+
const { _context } = this
|
|
49
78
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
this.send('auth', { token: authToken })
|
|
53
|
-
}
|
|
79
|
+
if (!_context.appKey) {
|
|
80
|
+
throw new Error('App key is required')
|
|
54
81
|
}
|
|
55
82
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
83
|
+
// Update status to connecting before attempting connection
|
|
84
|
+
this._updateStatus('connecting')
|
|
85
|
+
|
|
86
|
+
const config = {
|
|
87
|
+
source: 'platform',
|
|
88
|
+
userId: _context.user?.id,
|
|
89
|
+
socketUrl: this._info.config.url,
|
|
90
|
+
location: window.location.host,
|
|
91
|
+
// onConnect: () => {
|
|
92
|
+
// console.log('waz')
|
|
93
|
+
// },
|
|
94
|
+
onChange: this._handleMessage.bind(this),
|
|
95
|
+
sessionId: this._sessionId,
|
|
96
|
+
usersName: _context.user?.name,
|
|
97
|
+
route: window.location.pathname,
|
|
98
|
+
onDisconnect: this._handleDisconnect.bind(this)
|
|
59
99
|
}
|
|
60
100
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
this.
|
|
101
|
+
// If a previous socket exists but wasn't properly cleaned up, destroy it
|
|
102
|
+
if (this._socket) {
|
|
103
|
+
this.destroy()
|
|
64
104
|
}
|
|
65
105
|
|
|
66
|
-
this._socket
|
|
67
|
-
|
|
106
|
+
this._socket = connect(_context.appKey, config)
|
|
107
|
+
this._updateStatus('connected')
|
|
108
|
+
|
|
109
|
+
if (environment.isDevelopment) {
|
|
110
|
+
console.log('Socket connection established:', {
|
|
111
|
+
appKey: _context.appKey,
|
|
112
|
+
userId: _context.user?.id,
|
|
113
|
+
sessionId: this._sessionId,
|
|
114
|
+
url: this._info.config.url
|
|
115
|
+
})
|
|
68
116
|
}
|
|
117
|
+
|
|
118
|
+
return true
|
|
69
119
|
} catch (error) {
|
|
120
|
+
this._updateStatus('failed')
|
|
121
|
+
console.error('Socket connection failed:', error)
|
|
70
122
|
throw new Error(`Socket connection failed: ${error.message}`)
|
|
71
123
|
}
|
|
72
124
|
}
|
|
73
125
|
|
|
74
|
-
|
|
75
|
-
send (type, data) {
|
|
126
|
+
send (type, data, opts = {}) {
|
|
76
127
|
this._requireReady()
|
|
77
128
|
|
|
78
|
-
if (!this._socket
|
|
129
|
+
if (!this._socket) {
|
|
79
130
|
throw new Error('Socket is not connected')
|
|
80
131
|
}
|
|
81
132
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
if (!this._handlers.has(type)) {
|
|
88
|
-
this._handlers.set(type, new Set())
|
|
133
|
+
const payload = {
|
|
134
|
+
sessionId: this._sessionId,
|
|
135
|
+
userId: this._context.user?.id,
|
|
136
|
+
usersName: this._context.user?.name,
|
|
137
|
+
...data
|
|
89
138
|
}
|
|
90
139
|
|
|
91
|
-
|
|
140
|
+
send.call(
|
|
141
|
+
this._socket,
|
|
142
|
+
type,
|
|
143
|
+
opts.useDeepStringify ? deepStringify(payload) : payload
|
|
144
|
+
)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
_handleMessage (event, data) {
|
|
148
|
+
try {
|
|
149
|
+
const d = isString(data) ? deepDestringify(JSON.parse(data)) : data
|
|
150
|
+
if (this._sessionId === d.sessionId) {
|
|
151
|
+
return
|
|
152
|
+
}
|
|
92
153
|
|
|
93
|
-
|
|
94
|
-
const handlers = this._handlers.get(type)
|
|
154
|
+
const handlers = this._handlers.get(event)
|
|
95
155
|
if (handlers) {
|
|
96
|
-
handlers.
|
|
97
|
-
if (handlers.size === 0) {
|
|
98
|
-
this._handlers.delete(type)
|
|
99
|
-
}
|
|
156
|
+
handlers.forEach(handler => handler(d))
|
|
100
157
|
}
|
|
158
|
+
|
|
159
|
+
// Handle specific events
|
|
160
|
+
switch (event) {
|
|
161
|
+
case 'change':
|
|
162
|
+
this._handleChangeEvent(d)
|
|
163
|
+
break
|
|
164
|
+
case 'clients':
|
|
165
|
+
this._handleClientsEvent(d)
|
|
166
|
+
break
|
|
167
|
+
case 'route':
|
|
168
|
+
this._handleRouteEvent(d)
|
|
169
|
+
break
|
|
170
|
+
default:
|
|
171
|
+
break
|
|
172
|
+
}
|
|
173
|
+
} catch (error) {
|
|
174
|
+
this._setError(new Error(`Failed to handle message: ${error.message}`))
|
|
101
175
|
}
|
|
102
176
|
}
|
|
103
177
|
|
|
104
|
-
|
|
105
|
-
|
|
178
|
+
_handleChangeEvent (data) {
|
|
179
|
+
const { type, changes, version } = data
|
|
180
|
+
if (version) {
|
|
181
|
+
this._context.state.version = version
|
|
182
|
+
}
|
|
183
|
+
if (changes) {
|
|
184
|
+
window.requestAnimationFrame(async () => {
|
|
185
|
+
await this._context.state.setPathCollection(changes, {
|
|
186
|
+
preventReplace: type === 'canvas',
|
|
187
|
+
preventUpdate: true,
|
|
188
|
+
fromSocket: true,
|
|
189
|
+
userId: data.userId,
|
|
190
|
+
changes
|
|
191
|
+
})
|
|
192
|
+
})
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// monaco updates
|
|
196
|
+
// if (data.canvas) {
|
|
197
|
+
// const { clients } = data.canvas
|
|
198
|
+
// const [firstClientKey] = Object.keys(clients)
|
|
199
|
+
// const monaco =
|
|
200
|
+
// clients && clients[firstClientKey] && clients[firstClientKey].monaco
|
|
201
|
+
// if (monaco) {
|
|
202
|
+
// const Canvas =
|
|
203
|
+
// this._context.element && this._context.element.getCanvas()
|
|
204
|
+
// if (Canvas) {
|
|
205
|
+
// Canvas.Chosen.EditorPanels.update({}, { forceMonacoUpdate: true })
|
|
206
|
+
// }
|
|
207
|
+
// }
|
|
208
|
+
// return
|
|
209
|
+
// }
|
|
106
210
|
}
|
|
107
211
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
try {
|
|
111
|
-
const { type, data } = JSON.parse(rawData)
|
|
112
|
-
const handlers = this._handlers.get(type)
|
|
212
|
+
_handleClientsEvent (data) {
|
|
213
|
+
const { root } = this._context.state
|
|
113
214
|
|
|
114
|
-
|
|
115
|
-
|
|
215
|
+
root.replace(
|
|
216
|
+
{ clients: data },
|
|
217
|
+
{
|
|
218
|
+
fromSocket: true,
|
|
219
|
+
preventUpdate: true
|
|
116
220
|
}
|
|
117
|
-
|
|
118
|
-
|
|
221
|
+
)
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
_handleRouteEvent (data) {
|
|
225
|
+
const { element } = this._context
|
|
226
|
+
const { state } = this._context
|
|
227
|
+
|
|
228
|
+
if (data.userId && data.type === 'routeChanged') {
|
|
229
|
+
const isModalOpen = this.getWindow('modal')
|
|
230
|
+
const isFollowing = state.followingUser === data.userId
|
|
231
|
+
const isRouteSyncEnabled =
|
|
232
|
+
element.getUserSettings('presentMode') && data.userId === state.userId
|
|
233
|
+
|
|
234
|
+
if ((isFollowing || isRouteSyncEnabled) && !isModalOpen) {
|
|
235
|
+
router(
|
|
236
|
+
data.route,
|
|
237
|
+
element.__ref.root,
|
|
238
|
+
{},
|
|
239
|
+
{
|
|
240
|
+
fromSocket: true,
|
|
241
|
+
updateStateOptions: {
|
|
242
|
+
fromSocket: true,
|
|
243
|
+
preventStateUpdateListener: 1 // !isModalRoute(data.route, element)
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
)
|
|
247
|
+
}
|
|
248
|
+
} else if (data.reload) {
|
|
249
|
+
window.location.reload()
|
|
250
|
+
} else if (data.route && data.type === 'routeForced') {
|
|
251
|
+
router(
|
|
252
|
+
data.route,
|
|
253
|
+
element.__ref.root,
|
|
254
|
+
{},
|
|
255
|
+
{
|
|
256
|
+
fromSocket: true,
|
|
257
|
+
updateStateOptions: {
|
|
258
|
+
fromSocket: true
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
)
|
|
262
|
+
} else if (data.componentKey) {
|
|
263
|
+
if (!element.getData('components')[data.componentKey]) {
|
|
264
|
+
return
|
|
265
|
+
}
|
|
266
|
+
element.activateSelected(data.componentKey)
|
|
119
267
|
}
|
|
120
268
|
}
|
|
121
269
|
|
|
270
|
+
_handleDisconnect () {
|
|
271
|
+
this._updateStatus('disconnected')
|
|
272
|
+
this._handleReconnect()
|
|
273
|
+
}
|
|
274
|
+
|
|
122
275
|
_handleReconnect () {
|
|
123
276
|
if (this._reconnectAttempts < this._maxReconnectAttempts) {
|
|
124
277
|
this._reconnectAttempts++
|
|
125
278
|
this._updateStatus('reconnecting')
|
|
126
279
|
|
|
127
280
|
setTimeout(() => {
|
|
128
|
-
|
|
281
|
+
try {
|
|
282
|
+
const connected = this.connect()
|
|
283
|
+
if (connected) {
|
|
284
|
+
this._reconnectAttempts = 0
|
|
285
|
+
} else {
|
|
286
|
+
this._handleReconnect()
|
|
287
|
+
}
|
|
288
|
+
} catch (error) {
|
|
289
|
+
console.error('Reconnection failed:', error)
|
|
290
|
+
this._handleReconnect()
|
|
291
|
+
}
|
|
129
292
|
}, this._reconnectDelay * this._reconnectAttempts)
|
|
130
293
|
} else {
|
|
131
294
|
this._updateStatus('failed')
|
|
@@ -143,19 +306,24 @@ export class SocketService extends BaseService {
|
|
|
143
306
|
}
|
|
144
307
|
}
|
|
145
308
|
|
|
146
|
-
// Cleanup
|
|
147
309
|
destroy () {
|
|
148
310
|
if (this._socket) {
|
|
149
|
-
this._socket
|
|
311
|
+
disconnect.call(this._socket)
|
|
150
312
|
this._socket = null
|
|
151
313
|
}
|
|
152
314
|
this._handlers.clear()
|
|
153
315
|
this._setReady(false)
|
|
154
316
|
}
|
|
155
317
|
|
|
318
|
+
reconnect () {
|
|
319
|
+
this.destroy()
|
|
320
|
+
this.connect()
|
|
321
|
+
}
|
|
322
|
+
|
|
156
323
|
_checkRequiredContext () {
|
|
157
|
-
|
|
158
|
-
|
|
324
|
+
return Boolean(
|
|
325
|
+
this._context?.appKey && this._context?.authToken && this._socket
|
|
326
|
+
)
|
|
159
327
|
}
|
|
160
328
|
|
|
161
329
|
isReady () {
|
package/src/services/index.js
CHANGED
|
@@ -1,23 +1,16 @@
|
|
|
1
|
-
|
|
1
|
+
|
|
2
2
|
import { AuthService } from './AuthService.js'
|
|
3
|
-
import { AIService } from './AIService.js'
|
|
4
|
-
import { SocketService } from './SocketIOService.js'
|
|
5
3
|
import { CoreService } from './CoreService.js'
|
|
4
|
+
import { CollabService } from './CollabService.js'
|
|
6
5
|
|
|
7
6
|
const createService = (ServiceClass, config) => new ServiceClass(config)
|
|
8
7
|
|
|
9
8
|
// Export service creators
|
|
10
|
-
export const createSymstoryService = config =>
|
|
11
|
-
createService(SymstoryService, config)
|
|
12
|
-
|
|
13
9
|
export const createAuthService = config => createService(AuthService, config)
|
|
14
10
|
|
|
15
|
-
export const createAIService = config => createService(AIService, config)
|
|
16
|
-
|
|
17
|
-
export const createSocketService = config =>
|
|
18
|
-
createService(SocketService, config)
|
|
19
|
-
|
|
20
11
|
export const createCoreService = config => createService(CoreService, config)
|
|
21
12
|
|
|
22
|
-
|
|
23
|
-
|
|
13
|
+
export const createCollabService = config =>
|
|
14
|
+
createService(CollabService, config)
|
|
15
|
+
|
|
16
|
+
export { AuthService, CoreService, CollabService }
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import * as utils from '@domql/utils'
|
|
2
|
+
import { rootBus } from './rootEventBus.js'
|
|
3
|
+
|
|
4
|
+
const { isFunction } = utils.default || utils
|
|
5
|
+
|
|
6
|
+
export class RootStateManager {
|
|
7
|
+
constructor (rootState) {
|
|
8
|
+
this._rootState = rootState
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Apply change tuples to the root state using the built-in setPathCollection
|
|
13
|
+
* of Symbo.ls APP state tree.
|
|
14
|
+
*
|
|
15
|
+
* @param {Array} changes – eg. ['update', ['foo'], 'bar']
|
|
16
|
+
* @param {Object} opts – forwarded to setPathCollection
|
|
17
|
+
*/
|
|
18
|
+
applyChanges (changes = [], opts = {}) {
|
|
19
|
+
if (!this._rootState || !isFunction(this._rootState.setPathCollection)) {
|
|
20
|
+
return
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const result = this._rootState.setPathCollection(changes, {
|
|
24
|
+
preventUpdate: true,
|
|
25
|
+
...opts
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
// Identify library component mutations and notify UI once per batch
|
|
29
|
+
try {
|
|
30
|
+
const changedKeys = new Set()
|
|
31
|
+
changes.forEach(tuple => {
|
|
32
|
+
const [, path = []] = tuple
|
|
33
|
+
if (!Array.isArray(path) || !path.length) {return}
|
|
34
|
+
|
|
35
|
+
// Direct component value: ['components', key]
|
|
36
|
+
if (path[0] === 'components' && typeof path[1] === 'string') {
|
|
37
|
+
changedKeys.add(path[1])
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Component schema: ['schema', 'components', key, ...]
|
|
41
|
+
if (
|
|
42
|
+
path[0] === 'schema' &&
|
|
43
|
+
path[1] === 'components' &&
|
|
44
|
+
typeof path[2] === 'string'
|
|
45
|
+
) {
|
|
46
|
+
changedKeys.add(path[2])
|
|
47
|
+
}
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
if (changedKeys.size) {
|
|
51
|
+
console.log('emit components:changed', [...changedKeys])
|
|
52
|
+
rootBus.emit('components:changed', [...changedKeys])
|
|
53
|
+
}
|
|
54
|
+
} catch (err) {
|
|
55
|
+
// Do not interrupt the main apply flow if notification fails
|
|
56
|
+
console.error('[RootStateManager] emit components:changed failed', err)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return result
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
setVersion (v) {
|
|
63
|
+
if (this._rootState) {
|
|
64
|
+
this._rootState.version = v
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
get root () {
|
|
69
|
+
return this._rootState
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// Ensure we always reuse a single instance even if this file is evaluated
|
|
2
|
+
// multiple times from different bundle copies or with differing specifiers.
|
|
3
|
+
const getGlobalBus = () => {
|
|
4
|
+
if (globalThis.__SMBLS_ROOT_BUS__) {
|
|
5
|
+
return globalThis.__SMBLS_ROOT_BUS__
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const events = {}
|
|
9
|
+
|
|
10
|
+
const bus = {
|
|
11
|
+
on (event, handler) {
|
|
12
|
+
(events[event] ||= []).push(handler)
|
|
13
|
+
},
|
|
14
|
+
|
|
15
|
+
off (event, handler) {
|
|
16
|
+
const list = events[event]
|
|
17
|
+
if (!list) {return}
|
|
18
|
+
const idx = list.indexOf(handler)
|
|
19
|
+
if (idx !== -1) {list.splice(idx, 1)}
|
|
20
|
+
},
|
|
21
|
+
|
|
22
|
+
emit (event, payload) {
|
|
23
|
+
const list = events[event]
|
|
24
|
+
if (!list || !list.length) {return}
|
|
25
|
+
// copy to avoid mutation during iteration
|
|
26
|
+
list.slice().forEach(fn => {
|
|
27
|
+
try {
|
|
28
|
+
fn(payload)
|
|
29
|
+
} catch (err) {
|
|
30
|
+
console.error('[rootBus] handler error for', event, err)
|
|
31
|
+
}
|
|
32
|
+
})
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// expose for debugging if needed
|
|
37
|
+
Object.defineProperty(bus, '_listeners', {
|
|
38
|
+
value: events,
|
|
39
|
+
enumerable: false
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
globalThis.__SMBLS_ROOT_BUS__ = bus
|
|
43
|
+
return bus
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export const rootBus = getGlobalBus()
|
|
47
|
+
|
|
48
|
+
export default rootBus
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { io } from 'socket.io-client'
|
|
2
|
+
import * as Y from 'yjs'
|
|
3
|
+
import { IndexeddbPersistence } from 'y-indexeddb'
|
|
4
|
+
import Dexie from 'dexie'
|
|
5
|
+
import { nanoid } from 'nanoid'
|
|
6
|
+
import environment from '../config/environment.js'
|
|
7
|
+
// import gzip from 'gzip-js' // reserved for future compression features
|
|
8
|
+
|
|
9
|
+
// diff / patch helpers
|
|
10
|
+
import { diffJson, applyOpsToJson } from './jsonDiff.js'
|
|
11
|
+
|
|
12
|
+
/* eslint-disable no-use-before-define, no-new, no-promise-executor-return */
|
|
13
|
+
|
|
14
|
+
export class CollabClient {
|
|
15
|
+
/* public fields */
|
|
16
|
+
socket = null
|
|
17
|
+
ydoc = null
|
|
18
|
+
branch = 'main'
|
|
19
|
+
live = false
|
|
20
|
+
projectId = null
|
|
21
|
+
jwt = null
|
|
22
|
+
|
|
23
|
+
/* private state */
|
|
24
|
+
_buffer = []
|
|
25
|
+
_flushTimer = null
|
|
26
|
+
_clientId = nanoid()
|
|
27
|
+
_outboxStore = null // Dexie table
|
|
28
|
+
_readyResolve
|
|
29
|
+
ready = new Promise(res => (this._readyResolve = res))
|
|
30
|
+
|
|
31
|
+
constructor ({ jwt, projectId, branch = 'main', live = false }) {
|
|
32
|
+
Object.assign(this, { jwt, projectId, branch, live })
|
|
33
|
+
|
|
34
|
+
/* 1️⃣ create Yjs doc + offline persistence */
|
|
35
|
+
this.ydoc = new Y.Doc()
|
|
36
|
+
new IndexeddbPersistence(`${projectId}:${branch}`, this.ydoc)
|
|
37
|
+
|
|
38
|
+
/* 2️⃣ init Dexie for outbox */
|
|
39
|
+
this._outboxStore = createDexieOutbox(`${projectId}:${branch}`)
|
|
40
|
+
|
|
41
|
+
/* 3️⃣ WebSocket transport */
|
|
42
|
+
this.socket = io(environment.socketUrl, {
|
|
43
|
+
path: '/collab-socket',
|
|
44
|
+
transports: ['websocket'],
|
|
45
|
+
auth: { token: jwt, projectId, branch, live },
|
|
46
|
+
reconnectionAttempts: Infinity,
|
|
47
|
+
reconnectionDelayMax: 4000
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
/* socket events */
|
|
51
|
+
this.socket
|
|
52
|
+
.on('snapshot', this._onSnapshot)
|
|
53
|
+
.on('ops', this._onOps)
|
|
54
|
+
.on('commit', this._onCommit)
|
|
55
|
+
.on('liveMode', flag => { this.live = flag })
|
|
56
|
+
.on('connect', this._onConnect)
|
|
57
|
+
.on('error', e => console.warn('[collab] socket error', e))
|
|
58
|
+
|
|
59
|
+
/* Track last known JSON representation so we can compute granular diffs. */
|
|
60
|
+
this._prevJson = this.ydoc.getMap('root').toJSON()
|
|
61
|
+
|
|
62
|
+
/* 4️⃣ hook Yjs change listener */
|
|
63
|
+
this.ydoc.on('afterTransaction', tr => {
|
|
64
|
+
// Ignore changes that originated from remote patches.
|
|
65
|
+
if (tr.origin === 'remote') {return}
|
|
66
|
+
|
|
67
|
+
const currentJson = this.ydoc.getMap('root').toJSON()
|
|
68
|
+
|
|
69
|
+
// Compute minimal diff between previous and current state.
|
|
70
|
+
const ops = diffJson(this._prevJson, currentJson)
|
|
71
|
+
|
|
72
|
+
// Cache new snapshot for next diff calculation.
|
|
73
|
+
this._prevJson = currentJson
|
|
74
|
+
|
|
75
|
+
if (!ops.length) {return}
|
|
76
|
+
this._queueOps(ops)
|
|
77
|
+
})
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/* ---------- public helpers ---------- */
|
|
81
|
+
toggleLive (flag) { this.socket.emit('toggleLive', Boolean(flag)) }
|
|
82
|
+
sendCursor (data) { this.socket.emit('cursor', data) }
|
|
83
|
+
sendPresence (d) { this.socket.emit('presence', d) }
|
|
84
|
+
|
|
85
|
+
/* ---------- private handlers ---------- */
|
|
86
|
+
_onSnapshot = ({ data /* Uint8Array */ }) => {
|
|
87
|
+
// first paint; trust server compressed payload (≤256 kB)
|
|
88
|
+
Y.applyUpdate(this.ydoc, Uint8Array.from(data))
|
|
89
|
+
|
|
90
|
+
// Store current state as baseline for future diffs.
|
|
91
|
+
this._prevJson = this.ydoc.getMap('root').toJSON()
|
|
92
|
+
if (typeof this._readyResolve === 'function') {
|
|
93
|
+
this._readyResolve()
|
|
94
|
+
this._readyResolve = null
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
_onOps = ({ changes }) => {
|
|
99
|
+
// Apply remote ops
|
|
100
|
+
applyOpsToJson(changes, this.ydoc)
|
|
101
|
+
|
|
102
|
+
// Refresh baseline snapshot so we don't generate redundant diffs for the
|
|
103
|
+
// just-applied remote changes.
|
|
104
|
+
this._prevJson = this.ydoc.getMap('root').toJSON()
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
_onCommit = async ({ version }) => {
|
|
108
|
+
await this._outboxStore.clear()
|
|
109
|
+
console.info('[collab] committed', version)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
_onConnect = async () => {
|
|
113
|
+
// Mark client as ready if we haven't received a snapshot yet
|
|
114
|
+
if (typeof this._readyResolve === 'function') {
|
|
115
|
+
this._readyResolve()
|
|
116
|
+
// Prevent multiple resolutions
|
|
117
|
+
this._readyResolve = null
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// flush locally stored ops
|
|
121
|
+
const queued = await this._outboxStore.toArray()
|
|
122
|
+
if (queued.length) {
|
|
123
|
+
this.socket.emit('ops', {
|
|
124
|
+
changes: queued.flatMap(e => e.ops),
|
|
125
|
+
ts: Date.now(),
|
|
126
|
+
clientId: this._clientId
|
|
127
|
+
})
|
|
128
|
+
await this._outboxStore.clear()
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/* ---------- buffering & debounce ---------- */
|
|
133
|
+
_queueOps (ops) {
|
|
134
|
+
this._buffer.push(...ops)
|
|
135
|
+
this._outboxStore.put({ id: nanoid(), ops })
|
|
136
|
+
|
|
137
|
+
if (this.live && this.socket.connected) {
|
|
138
|
+
this._flushNow()
|
|
139
|
+
} else {
|
|
140
|
+
clearTimeout(this._flushTimer)
|
|
141
|
+
this._flushTimer = setTimeout(() => this._flushNow(), 40)
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
_flushNow () {
|
|
146
|
+
if (!this._buffer.length || !this.socket.connected) {return}
|
|
147
|
+
this.socket.emit('ops', {
|
|
148
|
+
changes: this._buffer,
|
|
149
|
+
ts: Date.now(),
|
|
150
|
+
clientId: this._clientId
|
|
151
|
+
})
|
|
152
|
+
this._buffer.length = 0
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/* ---------- Dexie helper ---------- */
|
|
157
|
+
function createDexieOutbox (name) {
|
|
158
|
+
const db = new Dexie(`collab-${name}`)
|
|
159
|
+
db.version(1).stores({ outbox: 'id, ops' })
|
|
160
|
+
return db.table('outbox')
|
|
161
|
+
}
|