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.
@@ -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: custom_headers, // The user can pass in custom headers
110
- // that are forwared into the fetch
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
- // ── Subscription (GET) ──────────────────────────────────────────────
128
- //
129
- // Opens a long-lived GET subscription with retry: () => true, meaning
130
- // any disconnection (network error, HTTP error) triggers automatic
131
- // reconnection with backoff.
132
- //
133
- // The parents callback sends client_version on reconnect, so the
134
- // server knows where we left off and can send patches from there.
135
- //
136
- // IMPORTANT: No changed() / flush is called on reconnect. The
137
- // subscription simply resumes. Any queued PUTs are retried by
138
- // braid_fetch independently.
139
- braid_fetch(url, {
140
- peer,
141
- subscribe: true,
142
- heartbeats: 20,
143
- signal: ac.signal,
144
- retry: () => true,
145
- parents: () => client_version.length ? client_version : null,
146
- onSubscriptionStatus: status => on_online && on_online(status.online),
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 match our client_version exactly. This ensures
156
- // we stay on a single line of time.
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
- }, on_error)
165
- }).catch(on_error)
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: () => ac.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
- try {
330
- var r = await braid_fetch(url, {
331
- method: "PUT",
332
- peer, version, parents, patches,
333
- retry: (res) => res.status !== 550,
334
- headers: {
335
- ...custom_headers,
336
- "Merge-Type": "simpleton",
337
- ...send_digests && {
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.4",
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": "^1.3.106"
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
- // TODO: accept 'yjs' merge_type here for Braid-HTTP yjs clients
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 patches = braid_text.from_yjs_binary(
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 { version: resource.version, parents: [], patches }
750
+ return yjs_updates
752
751
 
753
- if (patches.length)
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(patches)
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]) resource.dt.known_versions[syn_actor] = new RangeSet()
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, parents: version_before_yjs_sync, body: resource.dt.doc.getPatchSince(yjs_v_before), encoding: 'dt' }
981
- : { version: resource.version, parents: version_before_yjs_sync, patches: xf }
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 patches, reuse them; otherwise
997
+ // If we received yjs-text updates, reuse them; otherwise
990
998
  // derive them from the binary update
991
- var yjs_patches = yjs_text_patches
992
- || braid_text.from_yjs_binary(yjs_binary)
993
- if (yjs_patches.length) {
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 yjs_patches = braid_text.from_yjs_binary(captured_yjs_update)
1237
- for (var client of resource.yjs.clients) {
1238
- if (!peer || client.peer !== peer) {
1239
- await client.send_update({
1240
- version: resource.version, // DT version space
1241
- patches: yjs_patches // patches[].version: Yjs version space
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)) if (/\.(dt|yjs)\.\d+$/.test(x)) pages.add(decode_filename(x.replace(/\.(dt|yjs)\.\d+$/, '')))
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 patches = []
3085
+ var updates = []
3080
3086
 
3081
- // Convert inserted structs to yjs-text patches
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
- if (!struct.content?.str) continue // skip non-text items
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
- patches.push({
3090
- unit: 'yjs-text',
3091
- range: `(${left}:${right})`,
3092
- content: struct.content.str,
3093
- version: `${id.client}-${id.clock}`
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
- // Convert delete set entries to yjs-text patches
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
- patches.push({
3103
- unit: 'yjs-text',
3104
- range: `[${left}:${right}]`,
3105
- content: ''
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 patches
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
- // Insert patches must have a .version field with the item ID as "client-clock".
3116
- braid_text.to_yjs_binary = function(patches) {
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 p of patches) {
3126
- var parsed = parse_yjs_range(p.range)
3127
- if (!parsed) throw new Error(`invalid yjs-text range: ${p.range}`)
3128
-
3129
- if (p.content.length > 0) {
3130
- // Insert — version is the item ID as "client-clock"
3131
- if (!p.version) throw new Error('insert patch requires .version = "client-clock"')
3132
- var v_parts = p.version.match(/^(\d+)-(\d+)$/)
3133
- if (!v_parts) throw new Error('invalid insert patch version: ' + p.version)
3134
- var item_id = { client: parseInt(v_parts[1]), clock: parseInt(v_parts[2]) }
3135
- var list = inserts_by_client.get(item_id.client) || []
3136
- list.push({ id: item_id, origin: parsed.left, rightOrigin: parsed.right, content: p.content })
3137
- inserts_by_client.set(item_id.client, list)
3138
- } else {
3139
- // Delete
3140
- if (!parsed.left) throw new Error('delete patch requires left ID')
3141
- var client = parsed.left.client
3142
- var list = deletes_by_client.get(client) || []
3143
- list.push({ clock: parsed.left.clock, len: parsed.right ? parsed.right.clock - parsed.left.clock + 1 : 1 })
3144
- deletes_by_client.set(client, list)
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