braid-text 0.5.7 → 0.5.9

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.
@@ -1,508 +0,0 @@
1
- // ************************************************************************
2
- // ******* Reference Implementation of Simpleton Client Algorithm *********
3
- // ************************************************************************
4
- //
5
- // This is the canonical JS reference for implementing a simpleton client.
6
- // Other language implementations should mirror this logic exactly, with
7
- // adaptations only for language-specific details (e.g., string encoding).
8
- //
9
- // requires braid-http@~1.3/braid-http-client.js
10
- //
11
- // --- API ---
12
- //
13
- // url: resource endpoint
14
- //
15
- // headers: custom headers that get forwarded through into the fetch
16
- //
17
- // on_patches?: (patches) => void
18
- // processes incoming patches by applying them to the UI/textarea.
19
- // Patches are guaranteed to be in-order and non-overlapping.
20
- //
21
- // IMPORTANT: Patches have ABSOLUTE positions — each patch's range
22
- // refers to positions in the original state (before any patches in
23
- // this update). When applying multiple patches sequentially, you MUST
24
- // track a cumulative offset to adjust positions:
25
- //
26
- // var offset = 0
27
- // for (var p of patches) {
28
- // apply_at(p.range[0] + offset, p.range[1] + offset, p.content)
29
- // offset += p.content.length - (p.range[1] - p.range[0])
30
- // }
31
- //
32
- // Without offset tracking, multi-patch updates will corrupt the state.
33
- //
34
- // Optional. If not provided, simpleton applies patches internally
35
- // to its own copy of the state, and you can get the new state via
36
- // on_state (see "Local Edit Absorption" below).
37
- //
38
- // When provided, simpleton calls this to apply patches externally,
39
- // then reads back the state via get_state(). This means any un-flushed
40
- // local edits in the UI are absorbed into client_state after each server
41
- // update (see "Local Edit Absorption" below).
42
- //
43
- // on_state?: (state) => void
44
- // called after each server update with the new state
45
- //
46
- // get_patches?: (client_state) => patches
47
- // returns patches representing diff between client_state and current state,
48
- // which are guaranteed to be different if this method is being called.
49
- // (the default does this in a fast/simple way, finding a common prefix
50
- // and suffix, but you can supply something better, or possibly keep
51
- // track of patches as they come from your editor)
52
- //
53
- // get_state: () => current_state
54
- // returns the current state (e.g., textarea.value)
55
- //
56
- // content_type: used for Accept and Content-Type headers
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
- //
70
- // returns { changed, abort }
71
- // call changed() whenever there is a local change,
72
- // and the system will call a combination of get_state and
73
- // get_patches when it needs to. (get_state is required;
74
- // get_patches is optional.)
75
- // call abort() to abort the subscription.
76
- //
77
- // --- Retry and Reconnection Behavior ---
78
- //
79
- // Simpleton relies on braid_fetch for retry/reconnection:
80
- //
81
- // Subscription (GET):
82
- // retry: () => true — always reconnect on any error (network failure,
83
- // HTTP error, etc.). Reconnection backs off:
84
- // delay = Math.min(retry_count + 1, 3) * 1000 ms
85
- // i.e., 1s, 2s, 3s, 3s, 3s, ...
86
- // On reconnect, sends Parents via the parents callback to resume
87
- // from where the client left off.
88
- //
89
- // PUT requests:
90
- // retry: (res) => res.status !== 550 — retry all errors EXCEPT
91
- // HTTP 550 (Repr-Digest mismatch, meaning client is out of sync).
92
- // This means:
93
- // - Connection failure: retried with backoff
94
- // - HTTP 401, 403, 408, 429, 500, 502, 503, 504, etc.: retried
95
- // - HTTP 550: out of sync — stop retrying, throw error. The
96
- // client must be torn down and restarted from scratch.
97
- //
98
- // --- Local Edit Absorption ---
99
- //
100
- // When on_patches is provided, after applying server patches via
101
- // on_patches(), client_state is set to get_state(). If the UI has
102
- // un-flushed local edits (typed but changed() not yet called), those
103
- // edits are silently absorbed into client_state and will never be sent
104
- // as a diff. In practice, the JS avoids this because changed() is
105
- // called on every keystroke, and additionally, each time a PUT
106
- // completes, the code re-diffs and sends any edits that accumulated
107
- // while the PUT was in flight — so local edits are never stuck
108
- // waiting; they flush as soon as a PUT slot opens up.
109
- //
110
- // When on_patches is NOT provided, client_state is updated by applying
111
- // patches to the old client_state only. In this case, you should
112
- // provide on_state to receive the updated state after each server
113
- // update; otherwise your UI will not reflect remote changes.
114
- //
115
- function simpleton_client(url, {
116
- on_patches,
117
- on_state,
118
- get_patches,
119
- get_state,
120
- content_type,
121
- headers, // The user can pass in custom headers
122
- // that are forwarded into fetches
123
- on_error,
124
- on_online,
125
- on_ack,
126
- send_digests
127
- }) {
128
- var peer = Math.random().toString(36).slice(2)
129
- var client_version = [] // sorted version strings
130
- var client_state = "" // text as of client_version
131
- var char_counter = -1 // char-delta for version IDs
132
- var outstanding_changes = 0 // PUTs sent, not yet ACKed
133
- var max_outstanding_changes = 10 // throttle limit
134
- var throttled = false
135
- var throttled_updates = []
136
-
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 => {
157
- // ── Parent check ────────────────────────────────────────
158
- // Core simpleton invariant: only accept updates whose
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()).
164
- update.parents.sort()
165
- var last_queued = throttled_updates.length
166
- ? throttled_updates[throttled_updates.length - 1].version
167
- : client_version
168
- if (versions_eq(last_queued, update.parents))
169
- if (throttled) throttled_updates.push(update)
170
- else await apply_update(update)
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
- })
181
-
182
- async function apply_update(update) {
183
- // ── Parse and convert patches ───────────────────────────────
184
- // braid_fetch provides body and patch content as bytes;
185
- // body_text and content_text are dynamic properties that
186
- // decode bytes to a string via a UTF-8 decoder.
187
- var patches
188
- if (update.patches) {
189
- for (let patch of update.patches) {
190
- patch.range = patch.range.match(/\d+/g).map((x) => 1 * x)
191
- patch.content = patch.content_text
192
- }
193
- patches = update.patches.sort((a, b) => a.range[0] - b.range[0])
194
-
195
- // ── JS-SPECIFIC: Convert code-point ranges to UTF-16 indices ──
196
- // The wire protocol uses Unicode code-point offsets.
197
- // JS strings are UTF-16, so we must convert.
198
- //
199
- // OTHER LANGUAGES: Skip this conversion if your strings
200
- // are natively indexed by code points (e.g., Emacs Lisp,
201
- // Python, Rust's char iterator).
202
- convert_ranges_codepoints_to_utf16(patches, client_state)
203
- } else {
204
- // Initial snapshot: convert body to a patch replacing
205
- // [0,0] so it follows the same code path as incremental
206
- // patches.
207
- patches = [{range: [0, 0], content: update.body_text}]
208
- }
209
-
210
- // ── Apply the update ────────────────────────────────────────
211
- if (on_patches) {
212
- // Apply patches to the UI, then read back the
213
- // full state. Warning: if changed() hasn't been
214
- // called for recent local edits, get_state() will
215
- // absorb them into client_state silently — call
216
- // changed() after every local edit to avoid this.
217
- on_patches(patches)
218
- client_state = get_state()
219
- } else {
220
- // Apply patches to our internal state; the
221
- // result is delivered via on_state below.
222
- client_state = apply_patches(client_state, patches)
223
- }
224
-
225
- // ── Advance version ─────────────────────────────────────────
226
- // IMPORTANT: This must happen synchronously (before any await)
227
- // to prevent the changed() accumulation loop from interleaving
228
- // and capturing a stale client_version during a yield point.
229
- client_version = update.version
230
-
231
- // ── Notify listener ─────────────────────────────────────────
232
- // IMPORTANT: No changed() / flush is called here.
233
- // The JS does NOT send edits after receiving a server
234
- // update. The PUT response handler's async accumulation
235
- // loop handles flushing accumulated edits.
236
- if (on_state) on_state(client_state)
237
-
238
- // ── Digest verification ─────────────────────────────────────
239
- // If the server sent a repr-digest, verify our state
240
- // matches. On mismatch, THROW — this halts the
241
- // subscription handler. The document is corrupted and
242
- // continuing would compound the problem.
243
- //
244
- // This is placed after advancing client_version and calling
245
- // on_state so that the await does not create a yield point
246
- // between applying patches and advancing client_version.
247
- // That yield point previously allowed the changed() PUT loop
248
- // to interleave, capturing a stale client_version and causing
249
- // edit loss.
250
- if (update.extra_headers &&
251
- update.extra_headers["repr-digest"] &&
252
- update.extra_headers["repr-digest"].startsWith('sha-256=') &&
253
- update.extra_headers["repr-digest"] !== await get_digest(client_state)) {
254
- console.log('repr-digest mismatch!')
255
- console.log('repr-digest: ' + update.extra_headers["repr-digest"])
256
- console.log('state: ' + client_state)
257
- throw new Error('repr-digest mismatch')
258
- }
259
- }
260
-
261
- // ── Public interface ────────────────────────────────────────────────
262
- return {
263
- // ── abort() — cancel the subscription ─────────────────────────
264
- abort: () => channel.close(),
265
-
266
- // ── changed() — call when local edits occur ───────────────────
267
- // This is the entry point for sending local edits. It:
268
- // 1. Diffs client_state vs current state
269
- // 2. Checks the throttle (outstanding_changes >= max)
270
- // 3. Sends a PUT with the diff
271
- // 4. After the PUT completes, loops to check for MORE accumulated
272
- // edits (the async accumulation loop), sending them too.
273
- //
274
- // The async accumulation loop (while(true) {...}) is equivalent
275
- // to a callback-driven flush: after each PUT ACK, re-diff and
276
- // send again if changed. This ensures edits that accumulate
277
- // during a PUT round-trip are eventually sent.
278
- changed: () => {
279
- function get_change() {
280
- var new_state = get_state()
281
- if (new_state === client_state) return null
282
- var patches = get_patches ? get_patches(client_state) :
283
- [simple_diff(client_state, new_state)]
284
- return {patches, new_state}
285
- }
286
-
287
- var change = get_change()
288
- if (!change) {
289
- if (throttled) {
290
- throttled = false
291
- for (var update of throttled_updates)
292
- if (versions_eq(client_version, update.parents))
293
- apply_update(update).catch(on_error)
294
- throttled_updates = []
295
- }
296
- return
297
- }
298
-
299
- if (outstanding_changes >= max_outstanding_changes) {
300
- throttled = true
301
- return
302
- }
303
-
304
- var {patches, new_state} = change
305
-
306
- // Save JS-index patches before code-point conversion mutates them
307
- var js_patches = patches.map(p => ({range: [...p.range], content: p.content}))
308
-
309
- ;(async () => {
310
- while (true) {
311
- // ── JS-SPECIFIC: Convert JS UTF-16 ranges to code-points ──
312
- // The wire protocol uses code-point offsets. See the
313
- // inverse conversion in the receive path above.
314
- //
315
- // OTHER LANGUAGES: Skip this if your strings are
316
- // natively code-point indexed.
317
- convert_ranges_utf16_to_codepoints(patches, client_state)
318
-
319
- for (let patch of patches) {
320
- // ── Update char_counter ─────────────────────────
321
- // Increment by deleted chars + inserted chars
322
- char_counter += patch.range[1] - patch.range[0]
323
- char_counter += count_code_points(patch.content)
324
-
325
- patch.unit = "text"
326
- patch.range = `[${patch.range[0]}:${patch.range[1]}]`
327
- }
328
-
329
- // ── Compute version and advance optimistically ──────
330
- var version = [peer + "-" + char_counter]
331
-
332
- var parents = client_version
333
- client_version = version // optimistic advance
334
- client_state = new_state // update client_state
335
-
336
- // ── Send PUT ────────────────────────────────────────
337
- // Uses braid_fetch with retry: (res) => res.status !== 550
338
- // This means:
339
- // - Network failures: retried with backoff
340
- // - HTTP 401, 403, 408, 429, 500, 502, 503, 504: retried
341
- // - HTTP 550 (Repr-Digest mismatch / out of sync):
342
- // give up, throw — client must be re-created
343
- outstanding_changes++
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
-
353
- throttled = false
354
- outstanding_changes--
355
- if (on_ack && !outstanding_changes) on_ack()
356
-
357
- // ── Check for accumulated edits ─────────────────────
358
- // While the PUT was in flight, more local edits may
359
- // have occurred. Diff again and loop if changed.
360
- var more = get_change()
361
- if (!more) return
362
- ;({patches, new_state} = more)
363
- }
364
- })()
365
-
366
- return js_patches
367
- }
368
- }
369
-
370
- function versions_eq(a, b) {
371
- return a.length === b.length && a.every((v, i) => v === b[i])
372
- }
373
-
374
- // ── JS-SPECIFIC: UTF-16 helpers ─────────────────────────────────────
375
- // These handle surrogate pairs in UTF-16 JS strings. Characters
376
- // outside the Basic Multilingual Plane (BMP) are encoded as two
377
- // 16-bit code units (a surrogate pair: high 0xD800-0xDBFF, low
378
- // 0xDC00-0xDFFF). Such a pair represents one Unicode code point.
379
- //
380
- // OTHER LANGUAGES: You don't need these if your string type is
381
- // natively indexed by code points.
382
-
383
- function get_char_size(str, utf16_index) {
384
- const char_code = str.charCodeAt(utf16_index)
385
- return (char_code >= 0xd800 && char_code <= 0xdbff) ? 2 : 1
386
- }
387
-
388
- function count_code_points(str) {
389
- let code_points = 0
390
- for (let i = 0; i < str.length; i++) {
391
- if (str.charCodeAt(i) >= 0xd800 && str.charCodeAt(i) <= 0xdbff) i++
392
- code_points++
393
- }
394
- return code_points
395
- }
396
-
397
- // Converts patch ranges from code-point offsets to UTF-16 indices.
398
- // Patches must be sorted by range[0].
399
- function convert_ranges_codepoints_to_utf16(patches, str) {
400
- let codepoint_index = 0
401
- let utf16_index = 0
402
- for (let patch of patches) {
403
- while (codepoint_index < patch.range[0]) {
404
- utf16_index += get_char_size(str, utf16_index)
405
- codepoint_index++
406
- }
407
- patch.range[0] = utf16_index
408
-
409
- while (codepoint_index < patch.range[1]) {
410
- utf16_index += get_char_size(str, utf16_index)
411
- codepoint_index++
412
- }
413
- patch.range[1] = utf16_index
414
- }
415
- }
416
-
417
- // Converts patch ranges from UTF-16 indices to code-point offsets.
418
- // Patches must be sorted by range[0].
419
- function convert_ranges_utf16_to_codepoints(patches, str) {
420
- let codepoint_index = 0
421
- let utf16_index = 0
422
- for (let patch of patches) {
423
- while (utf16_index < patch.range[0]) {
424
- utf16_index += get_char_size(str, utf16_index)
425
- codepoint_index++
426
- }
427
- patch.range[0] = codepoint_index
428
-
429
- while (utf16_index < patch.range[1]) {
430
- utf16_index += get_char_size(str, utf16_index)
431
- codepoint_index++
432
- }
433
- patch.range[1] = codepoint_index
434
- }
435
- }
436
-
437
- // ── simple_diff ─────────────────────────────────────────────────────
438
- // Finds the longest common prefix and suffix between two strings,
439
- // returning the minimal edit that transforms `old_str` into `new_str`.
440
- //
441
- // Returns: { range: [prefix_len, old_str.length - suffix_len],
442
- // content: new_str.slice(prefix_len, new_str.length - suffix_len) }
443
- //
444
- // This produces a single contiguous edit. For multi-cursor or
445
- // multi-region edits, supply a custom get_patches function instead.
446
- function simple_diff(old_str, new_str) {
447
- // Find common prefix length
448
- var prefix_len = 0
449
- var min_len = Math.min(old_str.length, new_str.length)
450
- while (prefix_len < min_len && old_str[prefix_len] === new_str[prefix_len]) prefix_len++
451
-
452
- // Don't split a surrogate pair: if prefix ends on a low surrogate,
453
- // the preceding high surrogate only matched by coincidence (same
454
- // Unicode block), so back up to include the whole pair in the diff.
455
- if (prefix_len > 0 && is_low_surrogate(old_str, prefix_len))
456
- prefix_len--
457
-
458
- // Find common suffix length (from what remains after prefix)
459
- var suffix_len = 0
460
- min_len -= prefix_len
461
- while (suffix_len < min_len && old_str[old_str.length - suffix_len - 1] === new_str[new_str.length - suffix_len - 1]) suffix_len++
462
-
463
- // Same guard for suffixes: if the range end (old_str.length - suffix_len)
464
- // lands on a low surrogate, the suffix consumed it without its high
465
- // surrogate, so back up.
466
- if (suffix_len > 0 && is_low_surrogate(old_str, old_str.length - suffix_len))
467
- suffix_len--
468
-
469
- return {range: [prefix_len, old_str.length - suffix_len], content: new_str.slice(prefix_len, new_str.length - suffix_len)}
470
- }
471
-
472
- function is_low_surrogate(str, i) {
473
- var c = str.charCodeAt(i)
474
- return c >= 0xdc00 && c <= 0xdfff
475
- }
476
-
477
- function is_high_surrogate(str, i) {
478
- var c = str.charCodeAt(i)
479
- return c >= 0xd800 && c <= 0xdbff
480
- }
481
-
482
- // ── apply_patches ───────────────────────────────────────────────────
483
- // Applies patches to a string, tracking cumulative offset.
484
- // Used when on_patches is not provided, to update
485
- // client_state directly.
486
- //
487
- // Patches must have absolute coordinates (relative to the original
488
- // string, not to the string after previous patches). The offset
489
- // variable tracks the cumulative shift from previous patches.
490
- function apply_patches(state, patches) {
491
- var offset = 0
492
- for (var patch of patches) {
493
- state = state.substring(0, patch.range[0] + offset) + patch.content +
494
- state.substring(patch.range[1] + offset)
495
- offset += patch.content.length - (patch.range[1] - patch.range[0])
496
- }
497
- return state
498
- }
499
-
500
- // ── get_digest ──────────────────────────────────────────────────────
501
- // Computes SHA-256 of the UTF-8 encoding of the state string,
502
- // formatted as the Repr-Digest header value:
503
- // sha-256=:<base64-encoded-hash>:
504
- async function get_digest(str) {
505
- var bytes = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(str))
506
- return `sha-256=:${btoa(String.fromCharCode(...new Uint8Array(bytes)))}:`
507
- }
508
- }
@@ -1,75 +0,0 @@
1
-
2
- function set_acked_state(textarea, binary = true) {
3
- if (!binary) {
4
- textarea.old_caretColor = textarea.style.caretColor
5
-
6
- textarea.style.caretColor = 'red'
7
- } else {
8
- textarea.style.caretColor = textarea.old_caretColor
9
- }
10
- }
11
-
12
- function set_error_state(textarea, binary = true) {
13
- if (binary) {
14
- textarea.old_disabled = textarea.disabled
15
- textarea.old_background = textarea.style.background
16
- textarea.old_border = textarea.style.border
17
-
18
- textarea.disabled = true
19
- textarea.style.background = '#fee'
20
- textarea.style.border = '4px solid red'
21
- } else {
22
- textarea.disabled = textarea.old_disabled
23
- textarea.style.background = textarea.old_background
24
- textarea.style.border = textarea.old_border
25
- }
26
- }
27
-
28
- function diff(before, after) {
29
- let diff = diff_main(before, after)
30
- let patches = []
31
- let offset = 0
32
- for (let d of diff) {
33
- let p = null
34
- if (d[0] === 1) p = { range: [offset, offset], content: d[1] }
35
- else if (d[0] === -1) {
36
- p = { range: [offset, offset + d[1].length], content: "" }
37
- offset += d[1].length
38
- } else offset += d[1].length
39
- if (p) {
40
- p.unit = "text"
41
- patches.push(p)
42
- }
43
- }
44
- return patches
45
- }
46
-
47
- function apply_patches_and_update_selection(textarea, patches) {
48
- let offset = 0
49
- for (let p of patches) {
50
- p.range[0] += offset
51
- p.range[1] += offset
52
- offset -= p.range[1] - p.range[0]
53
- offset += p.content.length
54
- }
55
-
56
- let original = textarea.value
57
- let sel = [textarea.selectionStart, textarea.selectionEnd]
58
-
59
- for (var p of patches) {
60
- let range = p.range
61
-
62
- for (let i = 0; i < sel.length; i++)
63
- if (sel[i] > range[0])
64
- if (sel[i] > range[1]) sel[i] -= range[1] - range[0]
65
- else sel[i] = range[0]
66
-
67
- for (let i = 0; i < sel.length; i++) if (sel[i] > range[0]) sel[i] += p.content.length
68
-
69
- original = original.substring(0, range[0]) + p.content + original.substring(range[1])
70
- }
71
-
72
- textarea.value = original
73
- textarea.selectionStart = sel[0]
74
- textarea.selectionEnd = sel[1]
75
- }