braid-text 0.5.4 → 0.5.7
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/client/simpleton-sync.js +59 -57
- package/package.json +2 -2
- package/server-demo.js +3 -2
- package/server.js +88 -68
package/client/simpleton-sync.js
CHANGED
|
@@ -55,6 +55,18 @@
|
|
|
55
55
|
//
|
|
56
56
|
// content_type: used for Accept and Content-Type headers
|
|
57
57
|
//
|
|
58
|
+
// on_error?: (error) => void
|
|
59
|
+
// called when an error occurs (e.g., network failure, digest mismatch)
|
|
60
|
+
//
|
|
61
|
+
// on_online?: (is_online) => void
|
|
62
|
+
// called when the connection status changes
|
|
63
|
+
//
|
|
64
|
+
// on_ack?: () => void
|
|
65
|
+
// called when all outstanding PUTs have been acknowledged
|
|
66
|
+
//
|
|
67
|
+
// send_digests?: boolean
|
|
68
|
+
// if truthy, includes a Repr-Digest header with each PUT
|
|
69
|
+
//
|
|
58
70
|
// returns { changed, abort }
|
|
59
71
|
// call changed() whenever there is a local change,
|
|
60
72
|
// and the system will call a combination of get_state and
|
|
@@ -106,10 +118,9 @@ function simpleton_client(url, {
|
|
|
106
118
|
get_patches,
|
|
107
119
|
get_state,
|
|
108
120
|
content_type,
|
|
109
|
-
headers
|
|
110
|
-
// that are
|
|
121
|
+
headers, // The user can pass in custom headers
|
|
122
|
+
// that are forwarded into fetches
|
|
111
123
|
on_error,
|
|
112
|
-
on_res,
|
|
113
124
|
on_online,
|
|
114
125
|
on_ack,
|
|
115
126
|
send_digests
|
|
@@ -122,38 +133,34 @@ function simpleton_client(url, {
|
|
|
122
133
|
var max_outstanding_changes = 10 // throttle limit
|
|
123
134
|
var throttled = false
|
|
124
135
|
var throttled_updates = []
|
|
125
|
-
var ac = new AbortController()
|
|
126
136
|
|
|
127
|
-
//
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
//
|
|
135
|
-
//
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
headers: { ...custom_headers,
|
|
148
|
-
"Merge-Type": "simpleton",
|
|
149
|
-
...content_type && {Accept: content_type} },
|
|
150
|
-
}).then(res => {
|
|
151
|
-
if (on_res) on_res(res)
|
|
152
|
-
res.subscribe(async update => {
|
|
137
|
+
// extend the headers with merge-type and peer
|
|
138
|
+
headers = {
|
|
139
|
+
...headers,
|
|
140
|
+
"Merge-Type": "simpleton",
|
|
141
|
+
Peer: peer,
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Manages both the GET subscription and PUT requests through a single
|
|
145
|
+
// channel with automatic reconnection and PUT queuing.
|
|
146
|
+
var channel = reliable_update_channel(url, {
|
|
147
|
+
reconnect_from_parents: () => client_version.length ? client_version : null,
|
|
148
|
+
get_headers: {
|
|
149
|
+
...headers,
|
|
150
|
+
...content_type && {Accept: content_type}
|
|
151
|
+
},
|
|
152
|
+
put_headers: {
|
|
153
|
+
...headers,
|
|
154
|
+
...content_type && {"Content-Type": content_type}
|
|
155
|
+
},
|
|
156
|
+
on_update: async update => {
|
|
153
157
|
// ── Parent check ────────────────────────────────────────
|
|
154
158
|
// Core simpleton invariant: only accept updates whose
|
|
155
|
-
// parents
|
|
156
|
-
//
|
|
159
|
+
// parents form a continuous chain. We compare against the
|
|
160
|
+
// last queued update's version (if throttled) or
|
|
161
|
+
// client_version. When throttled, matching updates are
|
|
162
|
+
// queued but not applied — they'll be applied later when
|
|
163
|
+
// the throttle clears (see changed()).
|
|
157
164
|
update.parents.sort()
|
|
158
165
|
var last_queued = throttled_updates.length
|
|
159
166
|
? throttled_updates[throttled_updates.length - 1].version
|
|
@@ -161,8 +168,16 @@ function simpleton_client(url, {
|
|
|
161
168
|
if (versions_eq(last_queued, update.parents))
|
|
162
169
|
if (throttled) throttled_updates.push(update)
|
|
163
170
|
else await apply_update(update)
|
|
164
|
-
},
|
|
165
|
-
|
|
171
|
+
},
|
|
172
|
+
on_status: status => on_online && on_online(status.online),
|
|
173
|
+
on_error: err => on_error && on_error(err),
|
|
174
|
+
|
|
175
|
+
// this api is preliminary and undocumented;
|
|
176
|
+
// we use it to tell the reliable_update_channel to die,
|
|
177
|
+
// if there is a digest mismatch on the server,
|
|
178
|
+
// which will result in a 550 status code
|
|
179
|
+
no_retry_status_codes: [550]
|
|
180
|
+
})
|
|
166
181
|
|
|
167
182
|
async function apply_update(update) {
|
|
168
183
|
// ── Parse and convert patches ───────────────────────────────
|
|
@@ -246,7 +261,7 @@ function simpleton_client(url, {
|
|
|
246
261
|
// ── Public interface ────────────────────────────────────────────────
|
|
247
262
|
return {
|
|
248
263
|
// ── abort() — cancel the subscription ─────────────────────────
|
|
249
|
-
abort: () =>
|
|
264
|
+
abort: () => channel.close(),
|
|
250
265
|
|
|
251
266
|
// ── changed() — call when local edits occur ───────────────────
|
|
252
267
|
// This is the entry point for sending local edits. It:
|
|
@@ -326,28 +341,15 @@ function simpleton_client(url, {
|
|
|
326
341
|
// - HTTP 550 (Repr-Digest mismatch / out of sync):
|
|
327
342
|
// give up, throw — client must be re-created
|
|
328
343
|
outstanding_changes++
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
"Repr-Digest": await get_digest(client_state) },
|
|
339
|
-
...content_type && {
|
|
340
|
-
"Content-Type": content_type }
|
|
341
|
-
}
|
|
342
|
-
})
|
|
343
|
-
if (!r.ok) throw new Error(`bad http status: ${r.status}`)
|
|
344
|
-
} catch (e) {
|
|
345
|
-
// A 550 means Repr-Digest check failed — we're out
|
|
346
|
-
// of sync. The client must be torn down and
|
|
347
|
-
// re-created from scratch.
|
|
348
|
-
on_error(e)
|
|
349
|
-
throw e
|
|
350
|
-
}
|
|
344
|
+
|
|
345
|
+
await channel.put({
|
|
346
|
+
version, parents, patches,
|
|
347
|
+
headers: {
|
|
348
|
+
...send_digests && {
|
|
349
|
+
"Repr-Digest": await get_digest(client_state) }
|
|
350
|
+
}
|
|
351
|
+
})
|
|
352
|
+
|
|
351
353
|
throttled = false
|
|
352
354
|
outstanding_changes--
|
|
353
355
|
if (on_ack && !outstanding_changes) on_ack()
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "braid-text",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.7",
|
|
4
4
|
"description": "Library for collaborative text over http using braid.",
|
|
5
5
|
"author": "Braid Working Group",
|
|
6
6
|
"repository": "braid-org/braid-text",
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
],
|
|
26
26
|
"dependencies": {
|
|
27
27
|
"@braid.org/diamond-types-node": "^2.0.1",
|
|
28
|
-
"braid-http": "
|
|
28
|
+
"braid-http": "~1.3.123"
|
|
29
29
|
},
|
|
30
30
|
"peerDependencies": {
|
|
31
31
|
"yjs": "^13.6.0"
|
package/server-demo.js
CHANGED
|
@@ -14,14 +14,15 @@ var server = require("http").createServer(async (req, res) => {
|
|
|
14
14
|
if (req.method === 'OPTIONS') return res.end()
|
|
15
15
|
|
|
16
16
|
var q = req.url.split('?').slice(-1)[0]
|
|
17
|
-
if (q === 'editor' || q === 'markdown-editor') {
|
|
17
|
+
if (q === 'editor' || q === 'markdown-editor' || q === 'yjs-editor' || q === 'demo') {
|
|
18
18
|
res.writeHead(200, { "Content-Type": "text/html", "Cache-Control": "no-cache" })
|
|
19
19
|
require("fs").createReadStream(`./client/${q}.html`).pipe(res)
|
|
20
20
|
return
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
if (req.url === '/simpleton-sync.js' || req.url === '/web-utils.js'
|
|
24
|
-
|| req.url === '/textarea-highlights.js' || req.url === '/cursor-sync.js'
|
|
24
|
+
|| req.url === '/textarea-highlights.js' || req.url === '/cursor-sync.js'
|
|
25
|
+
|| req.url === '/yjs-sync.js') {
|
|
25
26
|
res.writeHead(200, { "Content-Type": "text/javascript", "Cache-Control": "no-cache" })
|
|
26
27
|
require("fs").createReadStream("./client" + req.url).pipe(res)
|
|
27
28
|
return
|
package/server.js
CHANGED
|
@@ -353,8 +353,7 @@ function create_braid_text() {
|
|
|
353
353
|
|
|
354
354
|
var peer = req.headers['peer'],
|
|
355
355
|
merge_type = req.headers['merge-type'] || 'simpleton'
|
|
356
|
-
|
|
357
|
-
if (merge_type !== 'simpleton' && merge_type !== 'dt')
|
|
356
|
+
if (merge_type !== 'simpleton' && merge_type !== 'dt' && merge_type !== 'yjs')
|
|
358
357
|
return my_end(400, `Unknown merge type: ${merge_type}`)
|
|
359
358
|
|
|
360
359
|
var is_read = req.method === 'GET' || req.method === 'HEAD',
|
|
@@ -744,16 +743,14 @@ function create_braid_text() {
|
|
|
744
743
|
if (getting.history === 'since-parents')
|
|
745
744
|
throw new Error('yjs-text from arbitrary parents not yet implemented')
|
|
746
745
|
|
|
747
|
-
var
|
|
746
|
+
var yjs_updates = braid_text.from_yjs_binary(
|
|
748
747
|
Y.encodeStateAsUpdate(resource.yjs.doc))
|
|
749
748
|
|
|
750
749
|
if (!getting.subscribe)
|
|
751
|
-
return
|
|
750
|
+
return yjs_updates
|
|
752
751
|
|
|
753
|
-
|
|
754
|
-
options.subscribe(
|
|
755
|
-
version: resource.version, parents: [], patches
|
|
756
|
-
})
|
|
752
|
+
for (var u of yjs_updates)
|
|
753
|
+
options.subscribe(u)
|
|
757
754
|
}
|
|
758
755
|
|
|
759
756
|
if (getting.subscribe) {
|
|
@@ -911,7 +908,10 @@ function create_braid_text() {
|
|
|
911
908
|
? options.yjs_update : new Uint8Array(options.yjs_update)
|
|
912
909
|
} else if (patches && patches.length && patches[0].unit === 'yjs-text') {
|
|
913
910
|
yjs_text_patches = patches
|
|
914
|
-
yjs_binary = braid_text.to_yjs_binary(
|
|
911
|
+
yjs_binary = braid_text.to_yjs_binary([{
|
|
912
|
+
version: options.version?.[0],
|
|
913
|
+
patches
|
|
914
|
+
}])
|
|
915
915
|
}
|
|
916
916
|
|
|
917
917
|
if (yjs_binary) {
|
|
@@ -959,7 +959,8 @@ function create_braid_text() {
|
|
|
959
959
|
}
|
|
960
960
|
for (var b of dt_bytes) resource.dt.doc.mergeBytes(b)
|
|
961
961
|
resource.version = resource.dt.doc.getRemoteVersion().map(x => x.join("-")).sort()
|
|
962
|
-
if (!resource.dt.known_versions[syn_actor])
|
|
962
|
+
if (!resource.dt.known_versions[syn_actor])
|
|
963
|
+
resource.dt.known_versions[syn_actor] = new RangeSet()
|
|
963
964
|
resource.dt.known_versions[syn_actor].add_range(0, syn_seq - 1)
|
|
964
965
|
await resource.dt.log.save(resource.dt.doc.getPatchSince(yjs_v_before))
|
|
965
966
|
|
|
@@ -977,8 +978,15 @@ function create_braid_text() {
|
|
|
977
978
|
if (!peer || client.peer !== peer)
|
|
978
979
|
await client.send_update(
|
|
979
980
|
client.accept_encoding_dt
|
|
980
|
-
? { version: resource.version,
|
|
981
|
-
|
|
981
|
+
? { version: resource.version,
|
|
982
|
+
parents: version_before_yjs_sync,
|
|
983
|
+
body: resource.dt.doc.getPatchSince(yjs_v_before),
|
|
984
|
+
encoding: 'dt'
|
|
985
|
+
}
|
|
986
|
+
: { version: resource.version,
|
|
987
|
+
parents: version_before_yjs_sync,
|
|
988
|
+
patches: xf
|
|
989
|
+
}
|
|
982
990
|
)
|
|
983
991
|
}
|
|
984
992
|
}
|
|
@@ -986,18 +994,15 @@ function create_braid_text() {
|
|
|
986
994
|
|
|
987
995
|
// Broadcast to yjs-text subscribers (skip sender)
|
|
988
996
|
if (resource.yjs) {
|
|
989
|
-
// If we received yjs-text
|
|
997
|
+
// If we received yjs-text updates, reuse them; otherwise
|
|
990
998
|
// derive them from the binary update
|
|
991
|
-
var
|
|
992
|
-
|
|
993
|
-
|
|
999
|
+
var yjs_updates = yjs_text_patches
|
|
1000
|
+
? [{version: options.version, patches: yjs_text_patches}]
|
|
1001
|
+
: braid_text.from_yjs_binary(yjs_binary)
|
|
1002
|
+
for (var yjs_update of yjs_updates) {
|
|
994
1003
|
for (var client of resource.yjs.clients) {
|
|
995
|
-
if (!peer || client.peer !== peer)
|
|
996
|
-
await client.send_update(
|
|
997
|
-
version: resource.version,
|
|
998
|
-
patches: yjs_patches
|
|
999
|
-
})
|
|
1000
|
-
}
|
|
1004
|
+
if (!peer || client.peer !== peer)
|
|
1005
|
+
await client.send_update(yjs_update)
|
|
1001
1006
|
}
|
|
1002
1007
|
}
|
|
1003
1008
|
}
|
|
@@ -1233,15 +1238,12 @@ function create_braid_text() {
|
|
|
1233
1238
|
// and we are mixing them. The update-level .version is the DT frontier.
|
|
1234
1239
|
// Each patch's .version is a Yjs item ID (clientID-clock).
|
|
1235
1240
|
if (resource.yjs && captured_yjs_update) {
|
|
1236
|
-
var
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
})
|
|
1243
|
-
}
|
|
1244
|
-
}
|
|
1241
|
+
var yjs_updates = braid_text.from_yjs_binary(captured_yjs_update)
|
|
1242
|
+
if (braid_text.verbose) console.log('DT→Yjs broadcast:', yjs_updates.length, 'updates to', resource.yjs.clients.size, 'yjs clients')
|
|
1243
|
+
for (var yjs_update of yjs_updates)
|
|
1244
|
+
for (var client of resource.yjs.clients)
|
|
1245
|
+
if (!peer || client.peer !== peer)
|
|
1246
|
+
await client.send_update(yjs_update)
|
|
1245
1247
|
}
|
|
1246
1248
|
|
|
1247
1249
|
// Persist Yjs delta
|
|
@@ -1355,7 +1357,9 @@ function create_braid_text() {
|
|
|
1355
1357
|
if (braid_text.db_folder) {
|
|
1356
1358
|
await db_folder_init()
|
|
1357
1359
|
var pages = new Set()
|
|
1358
|
-
for (let x of await require('fs').promises.readdir(braid_text.db_folder))
|
|
1360
|
+
for (let x of await require('fs').promises.readdir(braid_text.db_folder))
|
|
1361
|
+
if (/\.(dt|yjs)\.\d+$/.test(x))
|
|
1362
|
+
pages.add(decode_filename(x.replace(/\.(dt|yjs)\.\d+$/, '')))
|
|
1359
1363
|
return [...pages.keys()]
|
|
1360
1364
|
} else return Object.keys(braid_text.cache)
|
|
1361
1365
|
} catch (e) { return [] }
|
|
@@ -3072,48 +3076,60 @@ function create_braid_text() {
|
|
|
3072
3076
|
// Convert a Yjs binary update to yjs-text range patches.
|
|
3073
3077
|
// Decodes the binary without needing a Y.Doc.
|
|
3074
3078
|
// Returns array of {unit: 'yjs-text', range: '...', content: '...'}
|
|
3079
|
+
// Convert a Yjs binary update to an array of braid updates,
|
|
3080
|
+
// each with a version and patches in yjs-text format.
|
|
3075
3081
|
braid_text.from_yjs_binary = function(update) {
|
|
3076
3082
|
require_yjs()
|
|
3077
3083
|
var decoded = Y.decodeUpdate(
|
|
3078
3084
|
update instanceof Uint8Array ? update : new Uint8Array(update))
|
|
3079
|
-
var
|
|
3085
|
+
var updates = []
|
|
3080
3086
|
|
|
3081
|
-
//
|
|
3087
|
+
// Each inserted struct becomes one update with one insert patch.
|
|
3088
|
+
// GC'd structs (deleted items) have content.len but no content.str —
|
|
3089
|
+
// we emit placeholder text since the delete set will remove it anyway.
|
|
3082
3090
|
for (var struct of decoded.structs) {
|
|
3083
|
-
|
|
3091
|
+
var text = struct.content?.str
|
|
3092
|
+
if (!text && struct.content?.len) text = '_'.repeat(struct.content.len)
|
|
3093
|
+
if (!text) continue // skip non-text items (e.g. format, embed)
|
|
3084
3094
|
var id = struct.id
|
|
3085
3095
|
var origin = struct.origin
|
|
3086
3096
|
var rightOrigin = struct.rightOrigin
|
|
3087
3097
|
var left = origin ? `${origin.client}-${origin.clock}` : ''
|
|
3088
3098
|
var right = rightOrigin ? `${rightOrigin.client}-${rightOrigin.clock}` : ''
|
|
3089
|
-
|
|
3090
|
-
|
|
3091
|
-
|
|
3092
|
-
|
|
3093
|
-
|
|
3099
|
+
updates.push({
|
|
3100
|
+
version: [`${id.client}-${id.clock}`],
|
|
3101
|
+
patches: [{
|
|
3102
|
+
unit: 'yjs-text',
|
|
3103
|
+
range: `(${left}:${right})`,
|
|
3104
|
+
content: text,
|
|
3105
|
+
}]
|
|
3094
3106
|
})
|
|
3095
3107
|
}
|
|
3096
3108
|
|
|
3097
|
-
//
|
|
3109
|
+
// Each delete range becomes one update with one delete patch
|
|
3098
3110
|
for (var [clientID, deleteItems] of decoded.ds.clients) {
|
|
3099
3111
|
for (var item of deleteItems) {
|
|
3100
3112
|
var left = `${clientID}-${item.clock}`
|
|
3101
3113
|
var right = `${clientID}-${item.clock + item.len - 1}`
|
|
3102
|
-
|
|
3103
|
-
|
|
3104
|
-
|
|
3105
|
-
|
|
3114
|
+
updates.push({
|
|
3115
|
+
version: [`${clientID}-${item.clock}`],
|
|
3116
|
+
patches: [{
|
|
3117
|
+
unit: 'yjs-text',
|
|
3118
|
+
range: `[${left}:${right}]`,
|
|
3119
|
+
content: ''
|
|
3120
|
+
}]
|
|
3106
3121
|
})
|
|
3107
3122
|
}
|
|
3108
3123
|
}
|
|
3109
3124
|
|
|
3110
|
-
return
|
|
3125
|
+
return updates
|
|
3111
3126
|
}
|
|
3112
3127
|
|
|
3113
3128
|
// Convert yjs-text range patches to a Yjs binary update.
|
|
3129
|
+
// Convert braid updates with yjs-text patches to a Yjs binary update.
|
|
3114
3130
|
// This is the inverse of from_yjs_binary.
|
|
3115
|
-
//
|
|
3116
|
-
braid_text.to_yjs_binary = function(
|
|
3131
|
+
// Accepts an array of updates, each with {version, patches}.
|
|
3132
|
+
braid_text.to_yjs_binary = function(updates) {
|
|
3117
3133
|
require_yjs()
|
|
3118
3134
|
var lib0_encoding = require('lib0/encoding')
|
|
3119
3135
|
var encoder = new Y.UpdateEncoderV1()
|
|
@@ -3122,26 +3138,30 @@ function create_braid_text() {
|
|
|
3122
3138
|
var inserts_by_client = new Map()
|
|
3123
3139
|
var deletes_by_client = new Map()
|
|
3124
3140
|
|
|
3125
|
-
for (var
|
|
3126
|
-
|
|
3127
|
-
|
|
3128
|
-
|
|
3129
|
-
|
|
3130
|
-
|
|
3131
|
-
if (
|
|
3132
|
-
|
|
3133
|
-
|
|
3134
|
-
|
|
3135
|
-
|
|
3136
|
-
|
|
3137
|
-
|
|
3138
|
-
|
|
3139
|
-
|
|
3140
|
-
|
|
3141
|
-
|
|
3142
|
-
|
|
3143
|
-
|
|
3144
|
-
|
|
3141
|
+
for (var update of updates) {
|
|
3142
|
+
if (!update.patches) continue
|
|
3143
|
+
for (var p of update.patches) {
|
|
3144
|
+
var parsed = parse_yjs_range(p.range)
|
|
3145
|
+
if (!parsed) throw new Error(`invalid yjs-text range: ${p.range}`)
|
|
3146
|
+
|
|
3147
|
+
if (p.content.length > 0) {
|
|
3148
|
+
// Insert — version on the update is the item ID as ["client-clock"]
|
|
3149
|
+
var v_str = Array.isArray(update.version) ? update.version[0] : update.version
|
|
3150
|
+
if (!v_str) throw new Error('insert update requires .version = ["client-clock"]')
|
|
3151
|
+
var v_parts = v_str.match(/^(\d+)-(\d+)$/)
|
|
3152
|
+
if (!v_parts) throw new Error('invalid update version: ' + v_str)
|
|
3153
|
+
var item_id = { client: parseInt(v_parts[1]), clock: parseInt(v_parts[2]) }
|
|
3154
|
+
var list = inserts_by_client.get(item_id.client) || []
|
|
3155
|
+
list.push({ id: item_id, origin: parsed.left, rightOrigin: parsed.right, content: p.content })
|
|
3156
|
+
inserts_by_client.set(item_id.client, list)
|
|
3157
|
+
} else {
|
|
3158
|
+
// Delete
|
|
3159
|
+
if (!parsed.left) throw new Error('delete patch requires left ID')
|
|
3160
|
+
var client = parsed.left.client
|
|
3161
|
+
var list = deletes_by_client.get(client) || []
|
|
3162
|
+
list.push({ clock: parsed.left.clock, len: parsed.right ? parsed.right.clock - parsed.left.clock + 1 : 1 })
|
|
3163
|
+
deletes_by_client.set(client, list)
|
|
3164
|
+
}
|
|
3145
3165
|
}
|
|
3146
3166
|
}
|
|
3147
3167
|
|