braid-text 0.3.10 → 0.3.11

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.
@@ -14,6 +14,8 @@
14
14
  //
15
15
  // on_patches?: (patches) => void
16
16
  // processes incoming patches by applying them to the UI/textarea.
17
+ // Patches are guaranteed to be in-order and non-overlapping.
18
+ //
17
19
  // IMPORTANT: Patches have ABSOLUTE positions — each patch's range
18
20
  // refers to positions in the original state (before any patches in
19
21
  // this update). When applying multiple patches sequentially, you MUST
@@ -27,6 +29,10 @@
27
29
  //
28
30
  // Without offset tracking, multi-patch updates will corrupt the state.
29
31
  //
32
+ // Optional. If not provided, simpleton applies patches internally
33
+ // to its own copy of the state, and you can get the new state via
34
+ // on_state (see "Local Edit Absorption" below).
35
+ //
30
36
  // When provided, simpleton calls this to apply patches externally,
31
37
  // then reads back the state via get_state(). This means any un-flushed
32
38
  // local edits in the UI are absorbed into client_state after each server
@@ -49,7 +55,9 @@
49
55
  //
50
56
  // returns { changed, abort }
51
57
  // call changed() whenever there is a local change,
52
- // and the system will call get_patches when it needs to.
58
+ // and the system will call a combination of get_state and
59
+ // get_patches when it needs to. (get_state is required;
60
+ // get_patches is optional.)
53
61
  // call abort() to abort the subscription.
54
62
  //
55
63
  // --- Retry and Reconnection Behavior ---
@@ -58,7 +66,7 @@
58
66
  //
59
67
  // Subscription (GET):
60
68
  // retry: () => true — always reconnect on any error (network failure,
61
- // HTTP error, etc.). Reconnection uses exponential backoff:
69
+ // HTTP error, etc.). Reconnection backs off:
62
70
  // delay = Math.min(retry_count + 1, 3) * 1000 ms
63
71
  // i.e., 1s, 2s, 3s, 3s, 3s, ...
64
72
  // On reconnect, sends Parents via the parents callback to resume
@@ -80,12 +88,15 @@
80
88
  // un-flushed local edits (typed but changed() not yet called), those
81
89
  // edits are silently absorbed into client_state and will never be sent
82
90
  // as a diff. In practice, the JS avoids this because changed() is
83
- // called on every keystroke and the async accumulation loop clears
84
- // the backlog before a server update arrives.
91
+ // called on every keystroke, and additionally, each time a PUT
92
+ // completes, the code re-diffs and sends any edits that accumulated
93
+ // while the PUT was in flight — so local edits are never stuck
94
+ // waiting; they flush as soon as a PUT slot opens up.
85
95
  //
86
- // When on_patches is NOT provided (internal mode), client_state is
87
- // updated by applying patches to the old client_state only local
88
- // edits stay in the UI and will be captured by the next changed() diff.
96
+ // When on_patches is NOT provided, client_state is updated by applying
97
+ // patches to the old client_state only. In this case, you should
98
+ // provide on_state to receive the updated state after each server
99
+ // update; otherwise your UI will not reflect remote changes.
89
100
  //
90
101
  function simpleton_client(url, {
91
102
  on_patches,
@@ -100,12 +111,12 @@ function simpleton_client(url, {
100
111
  on_ack,
101
112
  send_digests
102
113
  }) {
103
- var peer = Math.random().toString(36).substr(2)
104
- var client_version = [] // sorted list of version strings; the version we think is current
105
- var client_state = "" // text content as of client_version (our "client_state")
106
- var char_counter = -1 // cumulative char-delta for generating version IDs
107
- var outstanding_changes = 0 // PUTs sent but not yet ACKed
108
- var max_outstanding_changes = 10 // throttle limit
114
+ var peer = Math.random().toString(36).slice(2)
115
+ var client_version = [] // sorted version strings
116
+ var client_state = "" // text as of client_version
117
+ var char_counter = -1 // char-delta for version IDs
118
+ var outstanding_changes = 0 // PUTs sent, not yet ACKed
119
+ var max_outstanding_changes = 10 // throttle limit
109
120
  var throttled = false
110
121
  var throttled_update = null
111
122
  var ac = new AbortController()
@@ -114,7 +125,7 @@ function simpleton_client(url, {
114
125
  //
115
126
  // Opens a long-lived GET subscription with retry: () => true, meaning
116
127
  // any disconnection (network error, HTTP error) triggers automatic
117
- // reconnection with exponential backoff.
128
+ // reconnection with backoff.
118
129
  //
119
130
  // The parents callback sends client_version on reconnect, so the
120
131
  // server knows where we left off and can send patches from there.
@@ -123,14 +134,14 @@ function simpleton_client(url, {
123
134
  // subscription simply resumes. Any queued PUTs are retried by
124
135
  // braid_fetch independently.
125
136
  braid_fetch(url, {
126
- headers: { "Merge-Type": "simpleton",
127
- ...(content_type ? {Accept: content_type} : {}) },
137
+ peer,
128
138
  subscribe: true,
139
+ signal: ac.signal,
129
140
  retry: () => true,
130
- onSubscriptionStatus: (status) => { if (on_online) on_online(status.online) },
131
141
  parents: () => client_version.length ? client_version : null,
132
- peer,
133
- signal: ac.signal
142
+ onSubscriptionStatus: status => on_online && on_online(status.online),
143
+ headers: { "Merge-Type": "simpleton",
144
+ ...content_type && {Accept: content_type} },
134
145
  }).then(res => {
135
146
  if (on_res) on_res(res)
136
147
  res.subscribe(async update => {
@@ -139,77 +150,56 @@ function simpleton_client(url, {
139
150
  // parents match our client_version exactly. This ensures
140
151
  // we stay on a single line of time.
141
152
  update.parents.sort()
142
- if (v_eq(client_version, update.parents)) {
153
+ if (versions_eq(client_version, update.parents))
143
154
  if (throttled) throttled_update = update
144
155
  else await apply_update(update)
145
- }
146
156
  }, on_error)
147
157
  }).catch(on_error)
148
158
 
149
159
  async function apply_update(update) {
150
- // ── Advance version BEFORE applying patches ─────────
151
- // (Single-threaded; no concurrent code runs between
152
- // these steps, so this is safe. Other implementations
153
- // may advance after applying both are equivalent.)
154
- client_version = update.version
155
- update.state = update.body_text
156
-
157
- // ── Parse and convert patches ───────────────────────
160
+ // ── Parse and convert patches ───────────────────────────────
161
+ // braid_fetch provides body and patch content as bytes;
162
+ // body_text and content_text are dynamic properties that
163
+ // decode bytes to a string via a UTF-8 decoder.
164
+ var patches
158
165
  if (update.patches) {
159
166
  for (let patch of update.patches) {
160
167
  patch.range = patch.range.match(/\d+/g).map((x) => 1 * x)
161
168
  patch.content = patch.content_text
162
169
  }
163
- update.patches.sort((a, b) => a.range[0] - b.range[0])
170
+ patches = update.patches.sort((a, b) => a.range[0] - b.range[0])
164
171
 
165
- // ── JS-SPECIFIC: Convert code-points to UTF-16 indices ──
172
+ // ── JS-SPECIFIC: Convert code-point ranges to UTF-16 indices ──
166
173
  // The wire protocol uses Unicode code-point offsets.
167
- // JS strings are UTF-16, so we must convert. Characters
168
- // outside the BMP (emoji, CJK extensions, etc.) take 2
169
- // UTF-16 code units (a surrogate pair) but count as 1
170
- // code point.
174
+ // JS strings are UTF-16, so we must convert.
171
175
  //
172
176
  // OTHER LANGUAGES: Skip this conversion if your strings
173
177
  // are natively indexed by code points (e.g., Emacs Lisp,
174
178
  // Python, Rust's char iterator).
175
- let codepoint_index = 0
176
- let utf16_index = 0
177
- for (let patch of update.patches) {
178
- while (codepoint_index < patch.range[0]) {
179
- utf16_index += get_char_size(client_state, utf16_index)
180
- codepoint_index++
181
- }
182
- patch.range[0] = utf16_index
183
-
184
- while (codepoint_index < patch.range[1]) {
185
- utf16_index += get_char_size(client_state, utf16_index)
186
- codepoint_index++
187
- }
188
- patch.range[1] = utf16_index
189
- }
179
+ convert_ranges_codepoints_to_utf16(patches, client_state)
180
+ } else {
181
+ // Initial snapshot: convert body to a patch replacing
182
+ // [0,0] so it follows the same code path as incremental
183
+ // patches.
184
+ patches = [{range: [0, 0], content: update.body_text}]
190
185
  }
191
186
 
192
- // ── Apply the update ────────────────────────────────
193
- // Convert initial snapshot body to a patch replacing
194
- // [0,0] — so initial load follows the same code path
195
- // as incremental patches.
196
- var patches = update.patches ||
197
- [{range: [0, 0], content: update.state}]
187
+ // ── Apply the update ────────────────────────────────────────
198
188
  if (on_patches) {
199
- // EXTERNAL MODE: Apply patches to the UI, then
200
- // read back the full state. Note: this absorbs
201
- // any un-flushed local edits into client_state.
189
+ // Apply patches to the UI, then read back the
190
+ // full state. Warning: if changed() hasn't been
191
+ // called for recent local edits, get_state() will
192
+ // absorb them into client_state silently — call
193
+ // changed() after every local edit to avoid this.
202
194
  on_patches(patches)
203
195
  client_state = get_state()
204
196
  } else {
205
- // INTERNAL MODE: Apply patches to our internal
206
- // state only. Local edits in the UI are NOT
207
- // absorbed — they will be captured by the next
208
- // changed() diff.
197
+ // Apply patches to our internal state; the
198
+ // result is delivered via on_state below.
209
199
  client_state = apply_patches(client_state, patches)
210
200
  }
211
201
 
212
- // ── Digest verification ─────────────────────────────
202
+ // ── Digest verification ─────────────────────────────────────
213
203
  // If the server sent a repr-digest, verify our state
214
204
  // matches. On mismatch, THROW — this halts the
215
205
  // subscription handler. The document is corrupted and
@@ -224,7 +214,10 @@ function simpleton_client(url, {
224
214
  throw new Error('repr-digest mismatch')
225
215
  }
226
216
 
227
- // ── Notify listener ─────────────────────────────────
217
+ // ── Advance version ─────────────────────────────────────────
218
+ client_version = update.version
219
+
220
+ // ── Notify listener ─────────────────────────────────────────
228
221
  // IMPORTANT: No changed() / flush is called here.
229
222
  // The JS does NOT send edits after receiving a server
230
223
  // update. The PUT response handler's async accumulation
@@ -234,9 +227,8 @@ function simpleton_client(url, {
234
227
 
235
228
  // ── Public interface ────────────────────────────────────────────────
236
229
  return {
237
- abort: async () => {
238
- ac.abort()
239
- },
230
+ // ── abort() cancel the subscription ─────────────────────────
231
+ abort: () => ac.abort(),
240
232
 
241
233
  // ── changed() — call when local edits occur ───────────────────
242
234
  // This is the entry point for sending local edits. It:
@@ -264,7 +256,7 @@ function simpleton_client(url, {
264
256
  if (throttled) {
265
257
  throttled = false
266
258
  if (throttled_update &&
267
- v_eq(client_version, throttled_update.parents))
259
+ versions_eq(client_version, throttled_update.parents))
268
260
  apply_update(throttled_update).catch(on_error)
269
261
  throttled_update = null
270
262
  }
@@ -283,28 +275,16 @@ function simpleton_client(url, {
283
275
 
284
276
  ;(async () => {
285
277
  while (true) {
286
- // ── JS-SPECIFIC: Convert JS UTF-16 indices to code-points ──
278
+ // ── JS-SPECIFIC: Convert JS UTF-16 ranges to code-points ──
287
279
  // The wire protocol uses code-point offsets. See the
288
280
  // inverse conversion in the receive path above.
289
281
  //
290
282
  // OTHER LANGUAGES: Skip this if your strings are
291
283
  // natively code-point indexed.
292
- let codepoint_index = 0
293
- let utf16_index = 0
284
+ convert_ranges_utf16_to_codepoints(patches, client_state)
285
+
294
286
  for (let patch of patches) {
295
- while (utf16_index < patch.range[0]) {
296
- utf16_index += get_char_size(client_state, utf16_index)
297
- codepoint_index++
298
- }
299
- patch.range[0] = codepoint_index
300
-
301
- while (utf16_index < patch.range[1]) {
302
- utf16_index += get_char_size(client_state, utf16_index)
303
- codepoint_index++
304
- }
305
- patch.range[1] = codepoint_index
306
-
307
- // ── Update char_counter ──────────────────────
287
+ // ── Update char_counter ─────────────────────────
308
288
  // Increment by deleted chars + inserted chars
309
289
  char_counter += patch.range[1] - patch.range[0]
310
290
  char_counter += count_code_points(patch.content)
@@ -313,14 +293,14 @@ function simpleton_client(url, {
313
293
  patch.range = `[${patch.range[0]}:${patch.range[1]}]`
314
294
  }
315
295
 
316
- // ── Compute version and advance optimistically ──
296
+ // ── Compute version and advance optimistically ──────
317
297
  var version = [peer + "-" + char_counter]
318
298
 
319
299
  var parents = client_version
320
300
  client_version = version // optimistic advance
321
- client_state = new_state // update client_state
301
+ client_state = new_state // update client_state
322
302
 
323
- // ── Send PUT ────────────────────────────────────
303
+ // ── Send PUT ────────────────────────────────────────
324
304
  // Uses braid_fetch with retry: (res) => res.status !== 550
325
305
  // This means:
326
306
  // - Network failures: retried with backoff
@@ -330,15 +310,16 @@ function simpleton_client(url, {
330
310
  outstanding_changes++
331
311
  try {
332
312
  var r = await braid_fetch(url, {
333
- headers: {
334
- "Merge-Type": "simpleton",
335
- ...send_digests && {"Repr-Digest": await get_digest(client_state)},
336
- ...content_type && {"Content-Type": content_type}
337
- },
338
313
  method: "PUT",
314
+ peer, version, parents, patches,
339
315
  retry: (res) => res.status !== 550,
340
- version, parents, patches,
341
- peer
316
+ headers: {
317
+ "Merge-Type": "simpleton",
318
+ ...send_digests && {
319
+ "Repr-Digest": await get_digest(client_state) },
320
+ ...content_type && {
321
+ "Content-Type": content_type }
322
+ }
342
323
  })
343
324
  if (!r.ok) throw new Error(`bad http status: ${r.status}`)
344
325
  } catch (e) {
@@ -348,12 +329,11 @@ function simpleton_client(url, {
348
329
  on_error(e)
349
330
  throw e
350
331
  }
332
+ throttled = false
351
333
  outstanding_changes--
352
334
  if (on_ack && !outstanding_changes) on_ack()
353
335
 
354
- throttled = false
355
-
356
- // ── Check for accumulated edits ─────────────────
336
+ // ── Check for accumulated edits ─────────────────────
357
337
  // While the PUT was in flight, more local edits may
358
338
  // have occurred. Diff again and loop if changed.
359
339
  var more = get_change()
@@ -366,7 +346,7 @@ function simpleton_client(url, {
366
346
  }
367
347
  }
368
348
 
369
- function v_eq(a, b) {
349
+ function versions_eq(a, b) {
370
350
  return a.length === b.length && a.every((v, i) => v === b[i])
371
351
  }
372
352
 
@@ -393,6 +373,46 @@ function simpleton_client(url, {
393
373
  return code_points
394
374
  }
395
375
 
376
+ // Converts patch ranges from code-point offsets to UTF-16 indices.
377
+ // Patches must be sorted by range[0].
378
+ function convert_ranges_codepoints_to_utf16(patches, str) {
379
+ let codepoint_index = 0
380
+ let utf16_index = 0
381
+ for (let patch of patches) {
382
+ while (codepoint_index < patch.range[0]) {
383
+ utf16_index += get_char_size(str, utf16_index)
384
+ codepoint_index++
385
+ }
386
+ patch.range[0] = utf16_index
387
+
388
+ while (codepoint_index < patch.range[1]) {
389
+ utf16_index += get_char_size(str, utf16_index)
390
+ codepoint_index++
391
+ }
392
+ patch.range[1] = utf16_index
393
+ }
394
+ }
395
+
396
+ // Converts patch ranges from UTF-16 indices to code-point offsets.
397
+ // Patches must be sorted by range[0].
398
+ function convert_ranges_utf16_to_codepoints(patches, str) {
399
+ let codepoint_index = 0
400
+ let utf16_index = 0
401
+ for (let patch of patches) {
402
+ while (utf16_index < patch.range[0]) {
403
+ utf16_index += get_char_size(str, utf16_index)
404
+ codepoint_index++
405
+ }
406
+ patch.range[0] = codepoint_index
407
+
408
+ while (utf16_index < patch.range[1]) {
409
+ utf16_index += get_char_size(str, utf16_index)
410
+ codepoint_index++
411
+ }
412
+ patch.range[1] = codepoint_index
413
+ }
414
+ }
415
+
396
416
  // ── simple_diff ─────────────────────────────────────────────────────
397
417
  // Finds the longest common prefix and suffix between two strings,
398
418
  // returning the minimal edit that transforms `old_str` into `new_str`.
@@ -418,8 +438,8 @@ function simpleton_client(url, {
418
438
 
419
439
  // ── apply_patches ───────────────────────────────────────────────────
420
440
  // Applies patches to a string, tracking cumulative offset.
421
- // Used in INTERNAL MODE (no on_patches callback) to update
422
- // client_state without touching the UI.
441
+ // Used when on_patches is not provided, to update
442
+ // client_state directly.
423
443
  //
424
444
  // Patches must have absolute coordinates (relative to the original
425
445
  // string, not to the string after previous patches). The offset
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "braid-text",
3
- "version": "0.3.10",
3
+ "version": "0.3.11",
4
4
  "description": "Library for collaborative text over http using braid.",
5
5
  "author": "Braid Working Group",
6
6
  "repository": "braid-org/braid-text",