@soulcraft/sdk 1.5.5 → 1.6.1
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/dist/client/index.d.ts +2 -0
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +2 -0
- package/dist/client/index.js.map +1 -1
- package/dist/modules/billing/portal-provider.d.ts +28 -0
- package/dist/modules/billing/portal-provider.d.ts.map +1 -1
- package/dist/modules/billing/portal-provider.js +58 -1
- package/dist/modules/billing/portal-provider.js.map +1 -1
- package/dist/modules/billing/types.d.ts +2 -0
- package/dist/modules/billing/types.d.ts.map +1 -1
- package/dist/modules/billing/usage-buffer.d.ts.map +1 -1
- package/dist/modules/billing/usage-buffer.js +7 -1
- package/dist/modules/billing/usage-buffer.js.map +1 -1
- package/dist/modules/brainy/ydoc-manager.d.ts +175 -0
- package/dist/modules/brainy/ydoc-manager.d.ts.map +1 -0
- package/dist/modules/brainy/ydoc-manager.js +424 -0
- package/dist/modules/brainy/ydoc-manager.js.map +1 -0
- package/dist/modules/skills/types.d.ts +1 -1
- package/dist/modules/skills/types.js +1 -1
- package/dist/server/handlers.d.ts +108 -12
- package/dist/server/handlers.d.ts.map +1 -1
- package/dist/server/handlers.js +167 -5
- package/dist/server/handlers.js.map +1 -1
- package/dist/server/index.d.ts +1 -0
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +2 -0
- package/dist/server/index.js.map +1 -1
- package/dist/transports/ws.d.ts +10 -0
- package/dist/transports/ws.d.ts.map +1 -1
- package/dist/transports/ws.js +15 -0
- package/dist/transports/ws.js.map +1 -1
- package/dist/transports/yjs-provider.d.ts +101 -0
- package/dist/transports/yjs-provider.d.ts.map +1 -0
- package/dist/transports/yjs-provider.js +241 -0
- package/dist/transports/yjs-provider.js.map +1 -0
- package/dist/types.d.ts +1 -1
- package/dist/types.d.ts.map +1 -1
- package/docs/ADR-001-sdk-design.md +2 -2
- package/docs/IMPLEMENTATION-PLAN.md +6 -6
- package/docs/KIT-APP-GUIDE.md +2 -2
- package/package.json +5 -2
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module modules/brainy/ydoc-manager
|
|
3
|
+
* @description Server-side Y.js document pool with binary append-log persistence.
|
|
4
|
+
*
|
|
5
|
+
* `YDocManager` maintains a pool of live `Y.Doc` instances, one per open file across
|
|
6
|
+
* all connected peers. Every Y.js update (each keypress or structural change) is
|
|
7
|
+
* immediately appended to a `.ydoc` binary file in Brainy's VFS alongside the
|
|
8
|
+
* human-readable `.wdoc`/`.wslide`/`.wviz` file. Brainy's mmap/SSD storage makes
|
|
9
|
+
* per-keypress appends essentially free.
|
|
10
|
+
*
|
|
11
|
+
* ## Persistence strategy
|
|
12
|
+
*
|
|
13
|
+
* ```
|
|
14
|
+
* /project/chapter-1.wdoc ← WDOC JSON, flushed from Y.Doc every 30s
|
|
15
|
+
* /project/chapter-1.ydoc ← binary Y.js update log, every keypress
|
|
16
|
+
* ```
|
|
17
|
+
*
|
|
18
|
+
* - **On every Y.js update:** append binary update to `.ydoc` via `vfs.appendFile`.
|
|
19
|
+
* - **Every 30s (debounced):** flush the live Y.Doc to `.wdoc` JSON so the canonical
|
|
20
|
+
* format stays fresh for API consumers that read `.wdoc` directly.
|
|
21
|
+
* - **On last peer disconnect:** flush `.wdoc` immediately, then discard the Y.Doc
|
|
22
|
+
* from memory. No warm cache — `.ydoc` binary replay is instant on mmap/SSD.
|
|
23
|
+
* - **On cold start (next peer):** reconstruct Y.Doc by replaying the `.ydoc` log
|
|
24
|
+
* via `Y.applyUpdate`. Falls back to converting `.wdoc` JSON if no `.ydoc` exists.
|
|
25
|
+
*
|
|
26
|
+
* ## Invalidation
|
|
27
|
+
*
|
|
28
|
+
* When a VFS write bypasses Y.js (e.g. import, API overwrite), call `invalidate()`
|
|
29
|
+
* to rebuild the `.ydoc` log from the new `.wdoc` content so future peers get
|
|
30
|
+
* the correct document state.
|
|
31
|
+
*
|
|
32
|
+
* ## Scope key
|
|
33
|
+
*
|
|
34
|
+
* Documents are keyed by `${scope}:${filePath}` where scope is `${emailHash}:${workspaceId}`.
|
|
35
|
+
* This matches the WebSocket session scope so the manager can be addressed directly
|
|
36
|
+
* from message handlers without additional routing.
|
|
37
|
+
*/
|
|
38
|
+
import * as Y from 'yjs';
|
|
39
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
40
|
+
// Types
|
|
41
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
42
|
+
/** Extension appended to the source file path to form the Y.js update log path. */
|
|
43
|
+
const YDOC_EXT = '.ydoc';
|
|
44
|
+
/** Interval between debounced flushes of the live Y.Doc to the canonical format (ms). */
|
|
45
|
+
const FLUSH_INTERVAL_MS = 30_000;
|
|
46
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
47
|
+
// YDocManager
|
|
48
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
49
|
+
/**
|
|
50
|
+
* Server-side pool of Y.js documents with binary append-log persistence.
|
|
51
|
+
*
|
|
52
|
+
* One shared instance per product server process. Pass it to `createBrainyWsHandler`
|
|
53
|
+
* so the WebSocket message handler can route Y.js messages to the correct doc.
|
|
54
|
+
*
|
|
55
|
+
* @example
|
|
56
|
+
* ```typescript
|
|
57
|
+
* import { YDocManager } from '@soulcraft/sdk/server'
|
|
58
|
+
* import { pool } from './instance-pool'
|
|
59
|
+
*
|
|
60
|
+
* export const ydocManager = new YDocManager()
|
|
61
|
+
*
|
|
62
|
+
* const wsHandler = createBrainyWsHandler({
|
|
63
|
+
* resolveBrain: (scope) => pool.forTenant(scope),
|
|
64
|
+
* authenticate: (token, scope) => verifyCapabilityToken(token, process.env.WS_TOKEN_SECRET!),
|
|
65
|
+
* ydocManager,
|
|
66
|
+
* })
|
|
67
|
+
* ```
|
|
68
|
+
*/
|
|
69
|
+
export class YDocManager {
|
|
70
|
+
/** Pool of live Y.Doc instances keyed by `${scope}:${filePath}`. */
|
|
71
|
+
docs = new Map();
|
|
72
|
+
// ── Public API ─────────────────────────────────────────────────────────────
|
|
73
|
+
/**
|
|
74
|
+
* Returns the live Y.Doc for the given scope + file, loading from the `.ydoc`
|
|
75
|
+
* binary log (or converting from the canonical format) on first access.
|
|
76
|
+
*
|
|
77
|
+
* Registers `peerId` as an active peer so the manager can track when the last
|
|
78
|
+
* peer disconnects.
|
|
79
|
+
*
|
|
80
|
+
* @param scope - The session scope (`${emailHash}:${workspaceId}`).
|
|
81
|
+
* @param filePath - Absolute VFS path to the source file (e.g. `/proj/doc.wdoc`).
|
|
82
|
+
* @param brain - The Brainy instance owning the VFS for this scope.
|
|
83
|
+
* @param peerId - Unique ID for the connecting peer.
|
|
84
|
+
* @returns The live `Y.Doc` for this file.
|
|
85
|
+
*/
|
|
86
|
+
async getOrCreate(scope, filePath, brain, peerId) {
|
|
87
|
+
const key = _key(scope, filePath);
|
|
88
|
+
let entry = this.docs.get(key);
|
|
89
|
+
if (!entry) {
|
|
90
|
+
const ydoc = await this._load(filePath, brain);
|
|
91
|
+
entry = {
|
|
92
|
+
ydoc,
|
|
93
|
+
peers: new Set(),
|
|
94
|
+
flushTimer: null,
|
|
95
|
+
flushPending: false,
|
|
96
|
+
brain,
|
|
97
|
+
filePath,
|
|
98
|
+
ydocPath: filePath + YDOC_EXT,
|
|
99
|
+
format: _formatFromPath(filePath),
|
|
100
|
+
};
|
|
101
|
+
this.docs.set(key, entry);
|
|
102
|
+
this._scheduleFlush(key, entry);
|
|
103
|
+
}
|
|
104
|
+
entry.peers.add(peerId);
|
|
105
|
+
return entry.ydoc;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Applies a Y.js update received from a peer.
|
|
109
|
+
*
|
|
110
|
+
* - Applies the update to the in-memory Y.Doc.
|
|
111
|
+
* - Appends the raw binary update to the `.ydoc` log immediately.
|
|
112
|
+
*
|
|
113
|
+
* The debounced 30s flush to the canonical format runs independently.
|
|
114
|
+
*
|
|
115
|
+
* @param scope - Session scope.
|
|
116
|
+
* @param filePath - Absolute VFS path to the source file.
|
|
117
|
+
* @param update - Raw binary Y.js update (from `Y.encodeStateAsUpdate` or an observer).
|
|
118
|
+
*/
|
|
119
|
+
async applyUpdate(scope, filePath, update) {
|
|
120
|
+
const key = _key(scope, filePath);
|
|
121
|
+
const entry = this.docs.get(key);
|
|
122
|
+
if (!entry)
|
|
123
|
+
return;
|
|
124
|
+
Y.applyUpdate(entry.ydoc, update);
|
|
125
|
+
// Append to the binary log — mmap/SSD makes this free.
|
|
126
|
+
await entry.brain.vfs.appendFile(entry.ydocPath, Buffer.from(update));
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Returns the current Y.js state vector for the given document.
|
|
130
|
+
*
|
|
131
|
+
* Used during the Y.js sync protocol: the client sends its state vector,
|
|
132
|
+
* the server replies with the diff (all updates the client is missing).
|
|
133
|
+
*
|
|
134
|
+
* @param scope - Session scope.
|
|
135
|
+
* @param filePath - Absolute VFS path to the source file.
|
|
136
|
+
* @returns Encoded state vector as `Uint8Array`.
|
|
137
|
+
*/
|
|
138
|
+
async getStateVector(scope, filePath, brain) {
|
|
139
|
+
const key = _key(scope, filePath);
|
|
140
|
+
const entry = this.docs.get(key);
|
|
141
|
+
if (entry)
|
|
142
|
+
return Y.encodeStateVector(entry.ydoc);
|
|
143
|
+
// Not in memory — load transiently just for the state vector, then discard.
|
|
144
|
+
const ydoc = await this._load(filePath, brain);
|
|
145
|
+
const sv = Y.encodeStateVector(ydoc);
|
|
146
|
+
ydoc.destroy();
|
|
147
|
+
return sv;
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Returns the Y.js diff between the server's state and the client's state vector.
|
|
151
|
+
*
|
|
152
|
+
* Called after the client sends a `y-sync` message. The diff contains all updates
|
|
153
|
+
* the client is missing so it can catch up to the server's current state.
|
|
154
|
+
*
|
|
155
|
+
* @param scope - Session scope.
|
|
156
|
+
* @param filePath - Absolute VFS path to the source file.
|
|
157
|
+
* @param clientStateVector - The state vector encoded by the client.
|
|
158
|
+
* @returns Encoded update diff as `Uint8Array`.
|
|
159
|
+
*/
|
|
160
|
+
async getDiff(scope, filePath, brain, clientStateVector) {
|
|
161
|
+
const key = _key(scope, filePath);
|
|
162
|
+
const entry = this.docs.get(key);
|
|
163
|
+
if (entry)
|
|
164
|
+
return Y.encodeStateAsUpdate(entry.ydoc, clientStateVector);
|
|
165
|
+
// Not in memory — load transiently, compute diff, discard.
|
|
166
|
+
const ydoc = await this._load(filePath, brain);
|
|
167
|
+
const diff = Y.encodeStateAsUpdate(ydoc, clientStateVector);
|
|
168
|
+
ydoc.destroy();
|
|
169
|
+
return diff;
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Notifies the manager that a peer has disconnected from the given document.
|
|
173
|
+
*
|
|
174
|
+
* If this was the last peer:
|
|
175
|
+
* 1. Flushes the Y.Doc to the canonical format immediately.
|
|
176
|
+
* 2. Cancels the periodic flush timer.
|
|
177
|
+
* 3. Destroys and discards the Y.Doc from memory.
|
|
178
|
+
*
|
|
179
|
+
* No warm cache — `.ydoc` binary replay is instant on mmap/SSD.
|
|
180
|
+
*
|
|
181
|
+
* @param scope - Session scope.
|
|
182
|
+
* @param filePath - Absolute VFS path to the source file.
|
|
183
|
+
* @param peerId - The peer that disconnected.
|
|
184
|
+
*/
|
|
185
|
+
async onPeerDisconnect(scope, filePath, peerId) {
|
|
186
|
+
const key = _key(scope, filePath);
|
|
187
|
+
const entry = this.docs.get(key);
|
|
188
|
+
if (!entry)
|
|
189
|
+
return;
|
|
190
|
+
entry.peers.delete(peerId);
|
|
191
|
+
if (entry.peers.size === 0) {
|
|
192
|
+
// Last peer — flush immediately and discard. No warm cache.
|
|
193
|
+
if (entry.flushTimer) {
|
|
194
|
+
clearInterval(entry.flushTimer);
|
|
195
|
+
entry.flushTimer = null;
|
|
196
|
+
}
|
|
197
|
+
await this._flush(entry);
|
|
198
|
+
entry.ydoc.destroy();
|
|
199
|
+
this.docs.delete(key);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Invalidates a cached Y.Doc when the canonical file is overwritten externally
|
|
204
|
+
* (e.g. via import or direct API write that bypasses Y.js).
|
|
205
|
+
*
|
|
206
|
+
* If the document is currently in memory (has active peers), it is rebuilt from
|
|
207
|
+
* the new canonical file content. If no peers are connected, any stale `.ydoc`
|
|
208
|
+
* log is deleted so the next cold start reads the new canonical file.
|
|
209
|
+
*
|
|
210
|
+
* @param scope - Session scope.
|
|
211
|
+
* @param filePath - Absolute VFS path to the source file.
|
|
212
|
+
* @param brain - The Brainy instance owning the VFS.
|
|
213
|
+
*/
|
|
214
|
+
async invalidate(scope, filePath, brain) {
|
|
215
|
+
const key = _key(scope, filePath);
|
|
216
|
+
const entry = this.docs.get(key);
|
|
217
|
+
const ydocPath = filePath + YDOC_EXT;
|
|
218
|
+
if (entry) {
|
|
219
|
+
// Rebuild in-place so active peers automatically get the new state.
|
|
220
|
+
const newYdoc = await _loadFromCanonical(filePath, brain);
|
|
221
|
+
const update = Y.encodeStateAsUpdate(newYdoc);
|
|
222
|
+
Y.applyUpdate(entry.ydoc, update);
|
|
223
|
+
newYdoc.destroy();
|
|
224
|
+
// Rewrite the .ydoc log with the new full state.
|
|
225
|
+
await brain.vfs.writeFile(ydocPath, Buffer.from(Y.encodeStateAsUpdate(entry.ydoc)));
|
|
226
|
+
}
|
|
227
|
+
else {
|
|
228
|
+
// No active peers — just delete the stale .ydoc so next cold start is clean.
|
|
229
|
+
try {
|
|
230
|
+
await brain.vfs.writeFile(ydocPath, Buffer.from(Y.encodeStateAsUpdate(await _loadFromCanonical(filePath, brain))));
|
|
231
|
+
}
|
|
232
|
+
catch {
|
|
233
|
+
// File may not exist yet — ignore.
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* Notifies the manager that a peer has disconnected from ALL documents it had open.
|
|
239
|
+
*
|
|
240
|
+
* Called from `BrainyWsHandler.handleClose()` when a WebSocket connection closes.
|
|
241
|
+
* Iterates all documents in this scope and removes the peer — if it was the last
|
|
242
|
+
* peer on any document, that document is flushed and discarded immediately.
|
|
243
|
+
*
|
|
244
|
+
* @param scope - Session scope (`${emailHash}:${workspaceId}`).
|
|
245
|
+
* @param peerId - The peer that disconnected.
|
|
246
|
+
*/
|
|
247
|
+
async onPeerDisconnectAll(scope, peerId) {
|
|
248
|
+
const prefix = scope + ':';
|
|
249
|
+
for (const [key, entry] of this.docs) {
|
|
250
|
+
if (key.startsWith(prefix) && entry.peers.has(peerId)) {
|
|
251
|
+
entry.peers.delete(peerId);
|
|
252
|
+
if (entry.peers.size === 0) {
|
|
253
|
+
if (entry.flushTimer) {
|
|
254
|
+
clearInterval(entry.flushTimer);
|
|
255
|
+
entry.flushTimer = null;
|
|
256
|
+
}
|
|
257
|
+
await this._flush(entry);
|
|
258
|
+
entry.ydoc.destroy();
|
|
259
|
+
this.docs.delete(key);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* Flushes and discards all Y.Docs for a given scope.
|
|
266
|
+
*
|
|
267
|
+
* Called during unpublish to clean up all open documents for a project before
|
|
268
|
+
* the project is removed from the publish registry.
|
|
269
|
+
*
|
|
270
|
+
* @param scope - Session scope (`${emailHash}:${workspaceId}`).
|
|
271
|
+
*/
|
|
272
|
+
async flushAndDiscardScope(scope) {
|
|
273
|
+
const prefix = scope + ':';
|
|
274
|
+
const toDiscard = [];
|
|
275
|
+
for (const [key, entry] of this.docs) {
|
|
276
|
+
if (key.startsWith(prefix)) {
|
|
277
|
+
if (entry.flushTimer)
|
|
278
|
+
clearInterval(entry.flushTimer);
|
|
279
|
+
await this._flush(entry);
|
|
280
|
+
entry.ydoc.destroy();
|
|
281
|
+
toDiscard.push(key);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
for (const key of toDiscard)
|
|
285
|
+
this.docs.delete(key);
|
|
286
|
+
}
|
|
287
|
+
// ── Internal ───────────────────────────────────────────────────────────────
|
|
288
|
+
/**
|
|
289
|
+
* Loads a Y.Doc from the `.ydoc` binary log, falling back to converting the
|
|
290
|
+
* canonical file if no log exists yet.
|
|
291
|
+
*/
|
|
292
|
+
async _load(filePath, brain) {
|
|
293
|
+
const ydocPath = filePath + YDOC_EXT;
|
|
294
|
+
const ydoc = new Y.Doc();
|
|
295
|
+
try {
|
|
296
|
+
const buf = await brain.vfs.readFile(ydocPath);
|
|
297
|
+
if (buf && buf.length > 0) {
|
|
298
|
+
// Binary replay — instant on mmap/SSD.
|
|
299
|
+
Y.applyUpdate(ydoc, new Uint8Array(buf));
|
|
300
|
+
return ydoc;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
catch {
|
|
304
|
+
// .ydoc not found — fall through to canonical load.
|
|
305
|
+
}
|
|
306
|
+
// No .ydoc yet — bootstrap from the canonical format.
|
|
307
|
+
const bootstrapYdoc = await _loadFromCanonical(filePath, brain);
|
|
308
|
+
const bootstrapUpdate = Y.encodeStateAsUpdate(bootstrapYdoc);
|
|
309
|
+
Y.applyUpdate(ydoc, bootstrapUpdate);
|
|
310
|
+
bootstrapYdoc.destroy();
|
|
311
|
+
// Write the initial .ydoc log so future cold starts use binary replay.
|
|
312
|
+
await brain.vfs.writeFile(ydocPath, Buffer.from(bootstrapUpdate));
|
|
313
|
+
return ydoc;
|
|
314
|
+
}
|
|
315
|
+
/**
|
|
316
|
+
* Schedules the periodic 30s flush for the given document entry.
|
|
317
|
+
*/
|
|
318
|
+
_scheduleFlush(key, entry) {
|
|
319
|
+
entry.flushTimer = setInterval(async () => {
|
|
320
|
+
if (this.docs.has(key)) {
|
|
321
|
+
await this._flush(entry);
|
|
322
|
+
}
|
|
323
|
+
}, FLUSH_INTERVAL_MS);
|
|
324
|
+
}
|
|
325
|
+
/**
|
|
326
|
+
* Flushes the live Y.Doc to the canonical format in the VFS.
|
|
327
|
+
*/
|
|
328
|
+
async _flush(entry) {
|
|
329
|
+
try {
|
|
330
|
+
const json = _ydocToCanonicalJson(entry.ydoc, entry.format);
|
|
331
|
+
await entry.brain.vfs.writeFile(entry.filePath, JSON.stringify(json, null, 2));
|
|
332
|
+
}
|
|
333
|
+
catch (err) {
|
|
334
|
+
console.error(`[YDocManager] flush failed for ${entry.filePath}:`, err);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
339
|
+
// Helpers — format conversion
|
|
340
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
341
|
+
/**
|
|
342
|
+
* Builds the pool key from scope and file path.
|
|
343
|
+
*
|
|
344
|
+
* @param scope - Session scope.
|
|
345
|
+
* @param filePath - Absolute VFS path.
|
|
346
|
+
* @returns Compound key string.
|
|
347
|
+
*/
|
|
348
|
+
function _key(scope, filePath) {
|
|
349
|
+
return `${scope}:${filePath}`;
|
|
350
|
+
}
|
|
351
|
+
/**
|
|
352
|
+
* Derives the flush format from a file path extension.
|
|
353
|
+
*
|
|
354
|
+
* @param filePath - Absolute VFS path.
|
|
355
|
+
* @returns One of the supported flush format identifiers.
|
|
356
|
+
*/
|
|
357
|
+
function _formatFromPath(filePath) {
|
|
358
|
+
if (filePath.endsWith('.wdoc'))
|
|
359
|
+
return 'wdoc';
|
|
360
|
+
if (filePath.endsWith('.wslide'))
|
|
361
|
+
return 'wslide';
|
|
362
|
+
if (filePath.endsWith('.wviz'))
|
|
363
|
+
return 'wviz';
|
|
364
|
+
if (filePath.endsWith('.wquiz'))
|
|
365
|
+
return 'wquiz';
|
|
366
|
+
return 'text';
|
|
367
|
+
}
|
|
368
|
+
/**
|
|
369
|
+
* Loads a Y.Doc from the canonical format file (`.wdoc`, `.wslide`, etc.).
|
|
370
|
+
*
|
|
371
|
+
* Each format stores its content as the root XmlFragment's children in the Y.Doc.
|
|
372
|
+
* For v1, all formats serialize as a JSON string stored in a Y.Text field named
|
|
373
|
+
* `'content'` — simple and sufficient for the sync protocol to work. The rich
|
|
374
|
+
* TipTap↔Y.js binding (`prosemirror-binding`) handles the structured mapping
|
|
375
|
+
* on the client side; the server only needs the raw bytes.
|
|
376
|
+
*
|
|
377
|
+
* @param filePath - Absolute VFS path to the canonical file.
|
|
378
|
+
* @param brain - The Brainy instance owning the VFS.
|
|
379
|
+
* @returns A bootstrapped `Y.Doc` containing the file's current content.
|
|
380
|
+
*/
|
|
381
|
+
async function _loadFromCanonical(filePath, brain) {
|
|
382
|
+
const ydoc = new Y.Doc();
|
|
383
|
+
try {
|
|
384
|
+
const raw = await brain.vfs.readFile(filePath);
|
|
385
|
+
if (raw && raw.length > 0) {
|
|
386
|
+
// Store the canonical JSON as a Y.Text so Y.js can manage it as a shared type.
|
|
387
|
+
// TipTap's Collaboration extension replaces this with the ProseMirror Y.XmlFragment
|
|
388
|
+
// once the first peer connects with a real editor.
|
|
389
|
+
const text = ydoc.getText('content');
|
|
390
|
+
text.insert(0, raw.toString('utf-8'));
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
catch {
|
|
394
|
+
// File doesn't exist yet — empty doc.
|
|
395
|
+
}
|
|
396
|
+
return ydoc;
|
|
397
|
+
}
|
|
398
|
+
/**
|
|
399
|
+
* Serializes a Y.Doc back to the canonical JSON format for writing to the VFS.
|
|
400
|
+
*
|
|
401
|
+
* For the `text` format (code files), returns the raw string content.
|
|
402
|
+
* For all document formats (wdoc, wslide, wviz, wquiz), returns the parsed JSON
|
|
403
|
+
* object so the VFS file stays human-readable and API-consumable.
|
|
404
|
+
*
|
|
405
|
+
* @param ydoc - The live Y.Doc to serialize.
|
|
406
|
+
* @param format - The target format.
|
|
407
|
+
* @returns The canonical content as a JavaScript value (JSON.stringify'd by the caller).
|
|
408
|
+
*/
|
|
409
|
+
function _ydocToCanonicalJson(ydoc, format) {
|
|
410
|
+
const text = ydoc.getText('content').toString();
|
|
411
|
+
if (format === 'text' || !text)
|
|
412
|
+
return text;
|
|
413
|
+
try {
|
|
414
|
+
return JSON.parse(text);
|
|
415
|
+
}
|
|
416
|
+
catch {
|
|
417
|
+
// If TipTap's Y.XmlFragment binding has taken over, the 'content' Y.Text may be
|
|
418
|
+
// empty. In that case, fall back to the XmlFragment serialization path.
|
|
419
|
+
// This is handled by the TipTap binding on the client — server sees the canonical
|
|
420
|
+
// JSON via the 30s flush path which the client drives via onChange.
|
|
421
|
+
return {};
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
//# sourceMappingURL=ydoc-manager.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ydoc-manager.js","sourceRoot":"","sources":["../../../src/modules/brainy/ydoc-manager.ts"],"names":[],"mappings":"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"}
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*
|
|
5
5
|
* Skills are Markdown files (SKILL.md convention) that define an AI persona,
|
|
6
6
|
* capabilities, glossary, and workflow steps for a specific domain. They drive
|
|
7
|
-
*
|
|
7
|
+
* Brainy AI system prompt construction and define what Claude knows how to do
|
|
8
8
|
* within a kit's context.
|
|
9
9
|
*
|
|
10
10
|
* ## Skill sources (in priority order)
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*
|
|
5
5
|
* Skills are Markdown files (SKILL.md convention) that define an AI persona,
|
|
6
6
|
* capabilities, glossary, and workflow steps for a specific domain. They drive
|
|
7
|
-
*
|
|
7
|
+
* Brainy AI system prompt construction and define what Claude knows how to do
|
|
8
8
|
* within a kit's context.
|
|
9
9
|
*
|
|
10
10
|
* ## Skill sources (in priority order)
|
|
@@ -72,6 +72,7 @@
|
|
|
72
72
|
import type { Brainy } from '@soulcraft/brainy';
|
|
73
73
|
import { LocalTransport } from '../transports/local.js';
|
|
74
74
|
import type { BrainyChangeEvent } from '../modules/brainy/events.js';
|
|
75
|
+
import { YDocManager } from '../modules/brainy/ydoc-manager.js';
|
|
75
76
|
/**
|
|
76
77
|
* Configuration for {@link createBrainyHandler}.
|
|
77
78
|
*/
|
|
@@ -138,16 +139,16 @@ export interface BrainyWsHandlerConfig {
|
|
|
138
139
|
/**
|
|
139
140
|
* Authenticates the WebSocket upgrade request.
|
|
140
141
|
*
|
|
141
|
-
* The `token` is extracted from `Authorization: Bearer <token
|
|
142
|
-
*
|
|
142
|
+
* The `token` is extracted from `Authorization: Bearer <token>` (Bun/Node) or
|
|
143
|
+
* `?token=` query param (browser). Return `null` to reject with close code 4001.
|
|
143
144
|
*
|
|
144
|
-
* @param token - The bearer token from the
|
|
145
|
+
* @param token - The bearer token from the header or query string.
|
|
145
146
|
* @param scope - The requested scope from the URL.
|
|
146
147
|
* @returns An auth context on success, or `null` to reject.
|
|
147
148
|
*/
|
|
148
149
|
authenticate: (token: string, scope: string) => Promise<unknown | null>;
|
|
149
150
|
/**
|
|
150
|
-
* Optional per-call authorization check.
|
|
151
|
+
* Optional per-call authorization check for Brainy RPC methods.
|
|
151
152
|
*
|
|
152
153
|
* @param method - Dot-separated Brainy method name.
|
|
153
154
|
* @param args - Positional arguments.
|
|
@@ -157,8 +158,18 @@ export interface BrainyWsHandlerConfig {
|
|
|
157
158
|
authorize?: (method: string, args: unknown[], auth: unknown) => boolean | null | Promise<boolean | null>;
|
|
158
159
|
/** Methods blocked for all callers. @default [] */
|
|
159
160
|
blockedMethods?: string[];
|
|
161
|
+
/**
|
|
162
|
+
* Optional Y.js document manager for real-time collaborative editing.
|
|
163
|
+
*
|
|
164
|
+
* When provided, the handler routes `y-sync`, `y-update`, and `y-awareness`
|
|
165
|
+
* messages through the `YDocManager` instead of the Brainy RPC dispatcher.
|
|
166
|
+
* Y.js update broadcasts are sent to all other peers via the `broadcast` callback
|
|
167
|
+
* on the session — the product server must populate this callback when a peer
|
|
168
|
+
* connects (see {@link WsSession.broadcast}).
|
|
169
|
+
*/
|
|
170
|
+
ydocManager?: YDocManager;
|
|
160
171
|
}
|
|
161
|
-
/** Per-connection session state stored on the WebSocket. */
|
|
172
|
+
/** Per-connection session state stored on the WebSocket data field. */
|
|
162
173
|
export interface WsSession {
|
|
163
174
|
/** The tenant scope extracted from `?scope=`. */
|
|
164
175
|
scope: string;
|
|
@@ -168,6 +179,23 @@ export interface WsSession {
|
|
|
168
179
|
brain: Brainy;
|
|
169
180
|
/** Local transport wrapping the Brainy instance for this session. */
|
|
170
181
|
transport: LocalTransport;
|
|
182
|
+
/**
|
|
183
|
+
* Unique peer ID for this connection. Used by `YDocManager` to track per-file
|
|
184
|
+
* peer counts so it knows when the last peer has disconnected.
|
|
185
|
+
*/
|
|
186
|
+
peerId: string;
|
|
187
|
+
/**
|
|
188
|
+
* Broadcast callback set by the product server after upgrade.
|
|
189
|
+
*
|
|
190
|
+
* Called by the handler to fan out `y-update` and `y-awareness` messages to all
|
|
191
|
+
* other peers connected to this scope. The product server owns the connection map
|
|
192
|
+
* (keyed by scope) and provides this callback so the SDK doesn't need to manage
|
|
193
|
+
* WebSocket handles directly.
|
|
194
|
+
*
|
|
195
|
+
* @param msg - Encoded MessagePack frame to send to all OTHER peers in this scope.
|
|
196
|
+
* @param excludePeerId - The peer that sent the message — must be excluded from broadcast.
|
|
197
|
+
*/
|
|
198
|
+
broadcast: (msg: Uint8Array, excludePeerId: string) => void;
|
|
171
199
|
}
|
|
172
200
|
/**
|
|
173
201
|
* The object returned by {@link createBrainyWsHandler}.
|
|
@@ -180,6 +208,9 @@ export interface BrainyWsHandler {
|
|
|
180
208
|
* the Brainy instance. Returns `null` if authentication fails (caller should
|
|
181
209
|
* respond with 401 and not proceed with the upgrade).
|
|
182
210
|
*
|
|
211
|
+
* After a successful upgrade the product server must set `session.broadcast` to
|
|
212
|
+
* a function that sends a `Uint8Array` to all other peers in the same scope.
|
|
213
|
+
*
|
|
183
214
|
* @param request - The HTTP upgrade Request.
|
|
184
215
|
* @returns Session state on success, or `null` on auth failure.
|
|
185
216
|
*/
|
|
@@ -187,19 +218,29 @@ export interface BrainyWsHandler {
|
|
|
187
218
|
/**
|
|
188
219
|
* Processes an incoming WebSocket message.
|
|
189
220
|
*
|
|
190
|
-
* Decodes the MessagePack frame
|
|
191
|
-
*
|
|
221
|
+
* Decodes the MessagePack frame and routes it to either:
|
|
222
|
+
* - The Brainy RPC dispatcher (frames with an `id` field), or
|
|
223
|
+
* - The `YDocManager` (frames with `type: 'y-sync' | 'y-update' | 'y-awareness'`).
|
|
224
|
+
*
|
|
225
|
+
* Y.js update and awareness frames are also broadcast to other peers via
|
|
226
|
+
* `session.broadcast`.
|
|
192
227
|
*
|
|
193
228
|
* @param session - The session state from `handleUpgrade()`.
|
|
194
229
|
* @param data - Raw binary message frame.
|
|
195
|
-
* @param send - Callback to send a binary response to
|
|
230
|
+
* @param send - Callback to send a binary response to THIS client only.
|
|
196
231
|
*/
|
|
197
232
|
handleMessage(session: WsSession, data: ArrayBuffer | Uint8Array, send: (msg: Uint8Array) => void): Promise<void>;
|
|
198
233
|
/**
|
|
199
|
-
*
|
|
234
|
+
* Called when a peer's WebSocket connection closes.
|
|
235
|
+
*
|
|
236
|
+
* Notifies the `YDocManager` so it can flush and discard Y.Docs that no longer
|
|
237
|
+
* have any active peers.
|
|
200
238
|
*
|
|
201
|
-
*
|
|
202
|
-
|
|
239
|
+
* @param session - The session state of the disconnecting peer.
|
|
240
|
+
*/
|
|
241
|
+
handleClose(session: WsSession): Promise<void>;
|
|
242
|
+
/**
|
|
243
|
+
* Broadcasts a Brainy change event to a connected client.
|
|
203
244
|
*
|
|
204
245
|
* @param event - The change event to broadcast.
|
|
205
246
|
* @param send - Callback to send the encoded event to the target client.
|
|
@@ -207,7 +248,62 @@ export interface BrainyWsHandler {
|
|
|
207
248
|
broadcastChange(event: BrainyChangeEvent, send: (msg: Uint8Array) => void): void;
|
|
208
249
|
}
|
|
209
250
|
/**
|
|
210
|
-
* Creates a server-side WebSocket handler for bidirectional Brainy RPC
|
|
251
|
+
* Creates a server-side WebSocket handler for bidirectional Brainy RPC, real-time
|
|
252
|
+
* change push, and optional Y.js collaborative document editing.
|
|
253
|
+
*
|
|
254
|
+
* ## Y.js collaborative editing
|
|
255
|
+
*
|
|
256
|
+
* Pass a `ydocManager` in the config to enable co-editing. The handler will
|
|
257
|
+
* automatically route `y-sync`, `y-update`, and `y-awareness` messages through the
|
|
258
|
+
* manager. The product server must:
|
|
259
|
+
*
|
|
260
|
+
* 1. Maintain a `Map<scope, Map<peerId, sendFn>>` of connected peers.
|
|
261
|
+
* 2. Set `session.broadcast` after `handleUpgrade()` to fan out Y.js frames.
|
|
262
|
+
* 3. Call `handleClose(session)` when a peer disconnects.
|
|
263
|
+
*
|
|
264
|
+
* @example Workshop server wiring with Y.js
|
|
265
|
+
* ```typescript
|
|
266
|
+
* import { createBrainyWsHandler, YDocManager } from '@soulcraft/sdk/server'
|
|
267
|
+
*
|
|
268
|
+
* const ydocManager = new YDocManager()
|
|
269
|
+
* const wsHandler = createBrainyWsHandler({ resolveBrain, authenticate, ydocManager })
|
|
270
|
+
*
|
|
271
|
+
* // Map<scope, Map<peerId, send>>
|
|
272
|
+
* const peers = new Map<string, Map<string, (msg: Uint8Array) => void>>()
|
|
273
|
+
*
|
|
274
|
+
* Bun.serve({
|
|
275
|
+
* async fetch(req, server) {
|
|
276
|
+
* if (new URL(req.url).pathname === '/api/brainy/ws') {
|
|
277
|
+
* const session = await wsHandler.handleUpgrade(req)
|
|
278
|
+
* if (!session) return new Response('Unauthorized', { status: 401 })
|
|
279
|
+
* // Register peer in broadcast map
|
|
280
|
+
* if (!peers.has(session.scope)) peers.set(session.scope, new Map())
|
|
281
|
+
* server.upgrade(req, { data: session })
|
|
282
|
+
* return undefined
|
|
283
|
+
* }
|
|
284
|
+
* },
|
|
285
|
+
* websocket: {
|
|
286
|
+
* open(ws) {
|
|
287
|
+
* const session = ws.data as WsSession
|
|
288
|
+
* peers.get(session.scope)!.set(session.peerId, (msg) => ws.send(msg))
|
|
289
|
+
* session.broadcast = (msg, excludeId) => {
|
|
290
|
+
* for (const [id, send] of peers.get(session.scope) ?? []) {
|
|
291
|
+
* if (id !== excludeId) send(msg)
|
|
292
|
+
* }
|
|
293
|
+
* }
|
|
294
|
+
* ws.send(encode({ type: 'ready', scope: session.scope }))
|
|
295
|
+
* },
|
|
296
|
+
* async message(ws, data) {
|
|
297
|
+
* await wsHandler.handleMessage(ws.data, data as ArrayBuffer, (msg) => ws.send(msg))
|
|
298
|
+
* },
|
|
299
|
+
* async close(ws) {
|
|
300
|
+
* const session = ws.data as WsSession
|
|
301
|
+
* peers.get(session.scope)?.delete(session.peerId)
|
|
302
|
+
* await wsHandler.handleClose(session)
|
|
303
|
+
* },
|
|
304
|
+
* },
|
|
305
|
+
* })
|
|
306
|
+
* ```
|
|
211
307
|
*
|
|
212
308
|
* @param config - Handler configuration.
|
|
213
309
|
* @returns A {@link BrainyWsHandler} instance.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"handlers.d.ts","sourceRoot":"","sources":["../../src/server/handlers.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsEG;AAGH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,mBAAmB,CAAA;AAC/C,OAAO,EAAE,cAAc,EAAE,MAAM,wBAAwB,CAAA;AACvD,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,6BAA6B,CAAA;
|
|
1
|
+
{"version":3,"file":"handlers.d.ts","sourceRoot":"","sources":["../../src/server/handlers.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsEG;AAGH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,mBAAmB,CAAA;AAC/C,OAAO,EAAE,cAAc,EAAE,MAAM,wBAAwB,CAAA;AACvD,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,6BAA6B,CAAA;AACpE,OAAO,EAAE,WAAW,EAAE,MAAM,mCAAmC,CAAA;AAM/D;;GAEG;AACH,MAAM,WAAW,mBAAmB;IAClC;;;;;;;;OAQG;IACH,YAAY,EAAE,CAAC,OAAO,EAAE,OAAO,KAAK,OAAO,CAAC,MAAM,CAAC,CAAA;IAEnD;;;;;;;;OAQG;IACH,YAAY,EAAE,CAAC,OAAO,EAAE,OAAO,KAAK,OAAO,CAAC,OAAO,GAAG,IAAI,CAAC,CAAA;IAE3D;;;;;;;;;;OAUG;IACH,SAAS,CAAC,EAAE,CACV,MAAM,EAAE,MAAM,EACd,IAAI,EAAE,OAAO,EAAE,EACf,IAAI,EAAE,OAAO,KACV,OAAO,GAAG,IAAI,GAAG,OAAO,CAAC,OAAO,GAAG,IAAI,CAAC,CAAA;IAE7C;;;;OAIG;IACH,cAAc,CAAC,EAAE,MAAM,EAAE,CAAA;CAC1B;AAQD;;;;;;;GAOG;AACH,wBAAgB,mBAAmB,CACjC,MAAM,EAAE,mBAAmB,GAC1B,CAAC,OAAO,EAAE,OAAO,KAAK,OAAO,CAAC,QAAQ,CAAC,CA0DzC;AAMD;;GAEG;AACH,MAAM,WAAW,qBAAqB;IACpC;;;;;OAKG;IACH,YAAY,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,CAAC,CAAA;IAEhD;;;;;;;;;OASG;IACH,YAAY,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,KAAK,OAAO,CAAC,OAAO,GAAG,IAAI,CAAC,CAAA;IAEvE;;;;;;;OAOG;IACH,SAAS,CAAC,EAAE,CACV,MAAM,EAAE,MAAM,EACd,IAAI,EAAE,OAAO,EAAE,EACf,IAAI,EAAE,OAAO,KACV,OAAO,GAAG,IAAI,GAAG,OAAO,CAAC,OAAO,GAAG,IAAI,CAAC,CAAA;IAE7C,mDAAmD;IACnD,cAAc,CAAC,EAAE,MAAM,EAAE,CAAA;IAEzB;;;;;;;;OAQG;IACH,WAAW,CAAC,EAAE,WAAW,CAAA;CAC1B;AAoDD,uEAAuE;AACvE,MAAM,WAAW,SAAS;IACxB,iDAAiD;IACjD,KAAK,EAAE,MAAM,CAAA;IACb,qDAAqD;IACrD,IAAI,EAAE,OAAO,CAAA;IACb,mDAAmD;IACnD,KAAK,EAAE,MAAM,CAAA;IACb,qEAAqE;IACrE,SAAS,EAAE,cAAc,CAAA;IACzB;;;OAGG;IACH,MAAM,EAAE,MAAM,CAAA;IACd;;;;;;;;;;OAUG;IACH,SAAS,EAAE,CAAC,GAAG,EAAE,UAAU,EAAE,aAAa,EAAE,MAAM,KAAK,IAAI,CAAA;CAC5D;AAiBD;;GAEG;AACH,MAAM,WAAW,eAAe;IAC9B;;;;;;;;;;;;OAYG;IACH,aAAa,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,SAAS,GAAG,IAAI,CAAC,CAAA;IAE1D;;;;;;;;;;;;;OAaG;IACH,aAAa,CACX,OAAO,EAAE,SAAS,EAClB,IAAI,EAAE,WAAW,GAAG,UAAU,EAC9B,IAAI,EAAE,CAAC,GAAG,EAAE,UAAU,KAAK,IAAI,GAC9B,OAAO,CAAC,IAAI,CAAC,CAAA;IAEhB;;;;;;;OAOG;IACH,WAAW,CAAC,OAAO,EAAE,SAAS,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IAE9C;;;;;OAKG;IACH,eAAe,CAAC,KAAK,EAAE,iBAAiB,EAAE,IAAI,EAAE,CAAC,GAAG,EAAE,UAAU,KAAK,IAAI,GAAG,IAAI,CAAA;CACjF;AAMD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4DG;AACH,wBAAgB,qBAAqB,CAAC,MAAM,EAAE,qBAAqB,GAAG,eAAe,CA2GpF"}
|