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.
- package/client/simpleton-sync.js +121 -101
- package/package.json +1 -1
package/client/simpleton-sync.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
84
|
-
// the
|
|
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
|
|
87
|
-
//
|
|
88
|
-
//
|
|
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).
|
|
104
|
-
var client_version = []
|
|
105
|
-
var client_state = "" // text
|
|
106
|
-
var char_counter = -1
|
|
107
|
-
var outstanding_changes = 0
|
|
108
|
-
var max_outstanding_changes = 10
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
133
|
-
|
|
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 (
|
|
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
|
-
// ──
|
|
151
|
-
//
|
|
152
|
-
//
|
|
153
|
-
//
|
|
154
|
-
|
|
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-
|
|
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.
|
|
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
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
//
|
|
200
|
-
//
|
|
201
|
-
//
|
|
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
|
-
//
|
|
206
|
-
//
|
|
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
|
-
// ──
|
|
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
|
-
|
|
238
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
293
|
-
|
|
284
|
+
convert_ranges_utf16_to_codepoints(patches, client_state)
|
|
285
|
+
|
|
294
286
|
for (let patch of patches) {
|
|
295
|
-
|
|
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
|
|
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
|
-
|
|
341
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
422
|
-
// client_state
|
|
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
|