braid-text 0.3.7 → 0.3.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,48 +1,97 @@
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
+ //
1
9
  // requires braid-http@~1.3/braid-http-client.js
2
- //
10
+ //
11
+ // --- API ---
12
+ //
3
13
  // url: resource endpoint
4
14
  //
5
15
  // on_patches?: (patches) => void
6
- // processes incoming patches
16
+ // processes incoming patches by applying them to the UI/textarea.
17
+ // IMPORTANT: Patches have ABSOLUTE positions — each patch's range
18
+ // refers to positions in the original state (before any patches in
19
+ // this update). When applying multiple patches sequentially, you MUST
20
+ // track a cumulative offset to adjust positions:
7
21
  //
8
- // on_state?: (state) => void
9
- // processes incoming state
22
+ // var offset = 0
23
+ // for (var p of patches) {
24
+ // apply_at(p.range[0] + offset, p.range[1] + offset, p.content)
25
+ // offset += p.content.length - (p.range[1] - p.range[0])
26
+ // }
10
27
  //
11
- // get_patches?: (prev_state) => patches
12
- // returns patches representing diff
13
- // between prev_state and current state,
14
- // which are guaranteed to be different
15
- // if this method is being called
16
- // (the default does this in a fast/simple way,
17
- // finding a common prefix and suffix,
18
- // but you can supply something better,
19
- // or possibly keep track of patches as they come from your editor)
28
+ // Without offset tracking, multi-patch updates will corrupt the state.
20
29
  //
21
- // get_state: () => current_state
22
- // returns the current state
30
+ // When provided, simpleton calls this to apply patches externally,
31
+ // then reads back the state via get_state(). This means any un-flushed
32
+ // local edits in the UI are absorbed into client_state after each server
33
+ // update (see "Local Edit Absorption" below).
23
34
  //
24
- // [DEPRECATED] apply_remote_update: ({patches, state}) => {...}
25
- // this is for incoming changes;
26
- // one of these will be non-null,
27
- // and can be applied to the current state.
35
+ // on_state?: (state) => void
36
+ // called after each server update with the new state
37
+ //
38
+ // get_patches?: (client_state) => patches
39
+ // returns patches representing diff between client_state and current state,
40
+ // which are guaranteed to be different if this method is being called.
41
+ // (the default does this in a fast/simple way, finding a common prefix
42
+ // and suffix, but you can supply something better, or possibly keep
43
+ // track of patches as they come from your editor)
28
44
  //
29
- // [DEPRECATED] generate_local_diff_update: (prev_state) => {...}
30
- // this is to generate outgoing changes,
31
- // and if there are changes, returns { patches, new_state }
45
+ // get_state: () => current_state
46
+ // returns the current state (e.g., textarea.value)
32
47
  //
33
48
  // content_type: used for Accept and Content-Type headers
34
49
  //
35
- // returns { changed }
36
- // call changed whenever there is a local change,
50
+ // returns { changed, abort }
51
+ // call changed() whenever there is a local change,
37
52
  // and the system will call get_patches when it needs to.
53
+ // call abort() to abort the subscription.
54
+ //
55
+ // --- Retry and Reconnection Behavior ---
56
+ //
57
+ // Simpleton relies on braid_fetch for retry/reconnection:
58
+ //
59
+ // Subscription (GET):
60
+ // retry: () => true — always reconnect on any error (network failure,
61
+ // HTTP error, etc.). Reconnection uses exponential backoff:
62
+ // delay = Math.min(retry_count + 1, 3) * 1000 ms
63
+ // i.e., 1s, 2s, 3s, 3s, 3s, ...
64
+ // On reconnect, sends Parents via the parents callback to resume
65
+ // from where the client left off.
66
+ //
67
+ // PUT requests:
68
+ // retry: (res) => res.status !== 550 — retry all errors EXCEPT
69
+ // HTTP 550 (Repr-Digest mismatch, meaning client is out of sync).
70
+ // This means:
71
+ // - Connection failure: retried with backoff
72
+ // - HTTP 401, 403, 408, 429, 500, 502, 503, 504, etc.: retried
73
+ // - HTTP 550: out of sync — stop retrying, throw error. The
74
+ // client must be torn down and restarted from scratch.
75
+ //
76
+ // --- Local Edit Absorption ---
77
+ //
78
+ // When on_patches is provided, after applying server patches via
79
+ // on_patches(), client_state is set to get_state(). If the UI has
80
+ // un-flushed local edits (typed but changed() not yet called), those
81
+ // edits are silently absorbed into client_state and will never be sent
82
+ // 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.
85
+ //
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.
38
89
  //
39
90
  function simpleton_client(url, {
40
91
  on_patches,
41
92
  on_state,
42
93
  get_patches,
43
94
  get_state,
44
- apply_remote_update, // DEPRECATED
45
- generate_local_diff_update, // DEPRECATED
46
95
  content_type,
47
96
 
48
97
  on_error,
@@ -52,34 +101,45 @@ function simpleton_client(url, {
52
101
  send_digests
53
102
  }) {
54
103
  var peer = Math.random().toString(36).substr(2)
55
- var current_version = []
56
- var prev_state = ""
57
- var char_counter = -1
58
- var outstanding_changes = 0
59
- var max_outstanding_changes = 10
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
60
109
  var throttled = false
61
110
  var throttled_update = null
62
111
  var ac = new AbortController()
63
112
 
64
- // temporary: our old code uses this deprecated api,
65
- // and our old code wants to send digests..
66
- if (apply_remote_update) send_digests = true
67
-
113
+ // ── Subscription (GET) ──────────────────────────────────────────────
114
+ //
115
+ // Opens a long-lived GET subscription with retry: () => true, meaning
116
+ // any disconnection (network error, HTTP error) triggers automatic
117
+ // reconnection with exponential backoff.
118
+ //
119
+ // The parents callback sends client_version on reconnect, so the
120
+ // server knows where we left off and can send patches from there.
121
+ //
122
+ // IMPORTANT: No changed() / flush is called on reconnect. The
123
+ // subscription simply resumes. Any queued PUTs are retried by
124
+ // braid_fetch independently.
68
125
  braid_fetch(url, {
69
126
  headers: { "Merge-Type": "simpleton",
70
127
  ...(content_type ? {Accept: content_type} : {}) },
71
128
  subscribe: true,
72
129
  retry: () => true,
73
130
  onSubscriptionStatus: (status) => { if (on_online) on_online(status) },
74
- parents: () => current_version.length ? current_version : null,
131
+ parents: () => client_version.length ? client_version : null,
75
132
  peer,
76
133
  signal: ac.signal
77
134
  }).then(res => {
78
135
  if (on_res) on_res(res)
79
136
  res.subscribe(async update => {
80
- // Only accept the update if its parents == our current version
137
+ // ── Parent check ────────────────────────────────────────
138
+ // Core simpleton invariant: only accept updates whose
139
+ // parents match our client_version exactly. This ensures
140
+ // we stay on a single line of time.
81
141
  update.parents.sort()
82
- if (v_eq(current_version, update.parents)) {
142
+ if (v_eq(client_version, update.parents)) {
83
143
  if (throttled) throttled_update = update
84
144
  else await apply_update(update)
85
145
  }
@@ -87,79 +147,116 @@ function simpleton_client(url, {
87
147
  }).catch(on_error)
88
148
 
89
149
  async function apply_update(update) {
90
- current_version = update.version
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
91
155
  update.state = update.body_text
92
156
 
157
+ // ── Parse and convert patches ───────────────────────
93
158
  if (update.patches) {
94
- for (let p of update.patches) {
95
- p.range = p.range.match(/\d+/g).map((x) => 1 * x)
96
- p.content = p.content_text
159
+ for (let patch of update.patches) {
160
+ patch.range = patch.range.match(/\d+/g).map((x) => 1 * x)
161
+ patch.content = patch.content_text
97
162
  }
98
163
  update.patches.sort((a, b) => a.range[0] - b.range[0])
99
164
 
100
- // convert from code-points to js-indicies
101
- let c = 0
102
- let i = 0
103
- for (let p of update.patches) {
104
- while (c < p.range[0]) {
105
- i += get_char_size(prev_state, i)
106
- c++
165
+ // ── JS-SPECIFIC: Convert code-points to UTF-16 indices ──
166
+ // 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.
171
+ //
172
+ // OTHER LANGUAGES: Skip this conversion if your strings
173
+ // are natively indexed by code points (e.g., Emacs Lisp,
174
+ // 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++
107
181
  }
108
- p.range[0] = i
182
+ patch.range[0] = utf16_index
109
183
 
110
- while (c < p.range[1]) {
111
- i += get_char_size(prev_state, i)
112
- c++
184
+ while (codepoint_index < patch.range[1]) {
185
+ utf16_index += get_char_size(client_state, utf16_index)
186
+ codepoint_index++
113
187
  }
114
- p.range[1] = i
188
+ patch.range[1] = utf16_index
115
189
  }
116
190
  }
117
191
 
118
- if (apply_remote_update) {
119
- // DEPRECATED
120
- prev_state = apply_remote_update(update)
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}]
198
+ 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.
202
+ on_patches(patches)
203
+ client_state = get_state()
121
204
  } else {
122
- var patches = update.patches ||
123
- [{range: [0, 0], content: update.state}]
124
- if (on_patches) {
125
- on_patches(patches)
126
- prev_state = get_state()
127
- } else prev_state = apply_patches(prev_state, patches)
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.
209
+ client_state = apply_patches(client_state, patches)
128
210
  }
129
211
 
130
- // if the server gave us a digest,
131
- // go ahead and check it against our new state..
212
+ // ── Digest verification ─────────────────────────────
213
+ // If the server sent a repr-digest, verify our state
214
+ // matches. On mismatch, THROW — this halts the
215
+ // subscription handler. The document is corrupted and
216
+ // continuing would compound the problem.
132
217
  if (update.extra_headers &&
133
218
  update.extra_headers["repr-digest"] &&
134
219
  update.extra_headers["repr-digest"].startsWith('sha-256=') &&
135
- update.extra_headers["repr-digest"] !== await get_digest(prev_state)) {
220
+ update.extra_headers["repr-digest"] !== await get_digest(client_state)) {
136
221
  console.log('repr-digest mismatch!')
137
222
  console.log('repr-digest: ' + update.extra_headers["repr-digest"])
138
- console.log('state: ' + prev_state)
223
+ console.log('state: ' + client_state)
139
224
  throw new Error('repr-digest mismatch')
140
225
  }
141
226
 
142
- if (on_state) on_state(prev_state)
227
+ // ── Notify listener ─────────────────────────────────
228
+ // IMPORTANT: No changed() / flush is called here.
229
+ // The JS does NOT send edits after receiving a server
230
+ // update. The PUT response handler's async accumulation
231
+ // loop handles flushing accumulated edits.
232
+ if (on_state) on_state(client_state)
143
233
  }
144
234
 
235
+ // ── Public interface ────────────────────────────────────────────────
145
236
  return {
146
- stop: async () => {
237
+ abort: async () => {
147
238
  ac.abort()
148
239
  },
240
+
241
+ // ── changed() — call when local edits occur ───────────────────
242
+ // This is the entry point for sending local edits. It:
243
+ // 1. Diffs client_state vs current state
244
+ // 2. Checks the throttle (outstanding_changes >= max)
245
+ // 3. Sends a PUT with the diff
246
+ // 4. After the PUT completes, loops to check for MORE accumulated
247
+ // edits (the async accumulation loop), sending them too.
248
+ //
249
+ // The async accumulation loop (while(true) {...}) is equivalent
250
+ // to a callback-driven flush: after each PUT ACK, re-diff and
251
+ // send again if changed. This ensures edits that accumulate
252
+ // during a PUT round-trip are eventually sent.
149
253
  changed: () => {
150
254
  function get_change() {
151
- if (generate_local_diff_update) {
152
- // DEPRECATED
153
- var update = generate_local_diff_update(prev_state)
154
- if (!update) return null
155
- return update
156
- } else {
157
- var new_state = get_state()
158
- if (new_state === prev_state) return null
159
- var patches = get_patches ? get_patches(prev_state) :
160
- [simple_diff(prev_state, new_state)]
161
- return {patches, new_state}
162
- }
255
+ var new_state = get_state()
256
+ if (new_state === client_state) return null
257
+ var patches = get_patches ? get_patches(client_state) :
258
+ [simple_diff(client_state, new_state)]
259
+ return {patches, new_state}
163
260
  }
164
261
 
165
262
  var change = get_change()
@@ -167,7 +264,7 @@ function simpleton_client(url, {
167
264
  if (throttled) {
168
265
  throttled = false
169
266
  if (throttled_update &&
170
- v_eq(current_version, throttled_update.parents))
267
+ v_eq(client_version, throttled_update.parents))
171
268
  apply_update(throttled_update).catch(on_error)
172
269
  throttled_update = null
173
270
  }
@@ -186,41 +283,56 @@ function simpleton_client(url, {
186
283
 
187
284
  ;(async () => {
188
285
  while (true) {
189
- // convert from js-indicies to code-points
190
- let c = 0
191
- let i = 0
192
- for (let p of patches) {
193
- while (i < p.range[0]) {
194
- i += get_char_size(prev_state, i)
195
- c++
286
+ // ── JS-SPECIFIC: Convert JS UTF-16 indices to code-points ──
287
+ // The wire protocol uses code-point offsets. See the
288
+ // inverse conversion in the receive path above.
289
+ //
290
+ // OTHER LANGUAGES: Skip this if your strings are
291
+ // natively code-point indexed.
292
+ let codepoint_index = 0
293
+ let utf16_index = 0
294
+ 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++
196
298
  }
197
- p.range[0] = c
299
+ patch.range[0] = codepoint_index
198
300
 
199
- while (i < p.range[1]) {
200
- i += get_char_size(prev_state, i)
201
- c++
301
+ while (utf16_index < patch.range[1]) {
302
+ utf16_index += get_char_size(client_state, utf16_index)
303
+ codepoint_index++
202
304
  }
203
- p.range[1] = c
305
+ patch.range[1] = codepoint_index
204
306
 
205
- char_counter += p.range[1] - p.range[0]
206
- char_counter += count_code_points(p.content)
307
+ // ── Update char_counter ──────────────────────
308
+ // Increment by deleted chars + inserted chars
309
+ char_counter += patch.range[1] - patch.range[0]
310
+ char_counter += count_code_points(patch.content)
207
311
 
208
- p.unit = "text"
209
- p.range = `[${p.range[0]}:${p.range[1]}]`
312
+ patch.unit = "text"
313
+ patch.range = `[${patch.range[0]}:${patch.range[1]}]`
210
314
  }
211
315
 
316
+ // ── Compute version and advance optimistically ──
212
317
  var version = [peer + "-" + char_counter]
213
318
 
214
- var parents = current_version
215
- current_version = version
216
- prev_state = new_state
217
-
319
+ var parents = client_version
320
+ client_version = version // optimistic advance
321
+ client_state = new_state // update client_state
322
+
323
+ // ── Send PUT ────────────────────────────────────
324
+ // Uses braid_fetch with retry: (res) => res.status !== 550
325
+ // This means:
326
+ // - Network failures: retried with backoff
327
+ // - HTTP 401, 403, 408, 429, 500, 502, 503, 504: retried
328
+ // - HTTP 550 (Repr-Digest mismatch / out of sync):
329
+ // give up, throw — client must be re-created
218
330
  outstanding_changes++
219
331
  try {
220
332
  var r = await braid_fetch(url, {
221
333
  headers: {
222
334
  "Merge-Type": "simpleton",
223
- ...send_digests && {"Repr-Digest": await get_digest(prev_state)},
335
+ ...send_digests && {"Repr-Digest": await get_digest(client_state)},
224
336
  ...content_type && {"Content-Type": content_type}
225
337
  },
226
338
  method: "PUT",
@@ -228,8 +340,11 @@ function simpleton_client(url, {
228
340
  version, parents, patches,
229
341
  peer
230
342
  })
231
- if (!r.ok) throw new Error(`bad http status: ${r.status}${(r.status === 401 || r.status === 403) ? ` (access denied)` : ''}`)
343
+ if (!r.ok) throw new Error(`bad http status: ${r.status}`)
232
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.
233
348
  on_error(e)
234
349
  throw e
235
350
  }
@@ -238,7 +353,9 @@ function simpleton_client(url, {
238
353
 
239
354
  throttled = false
240
355
 
241
- // Check for more changes that accumulated while we were sending
356
+ // ── Check for accumulated edits ─────────────────
357
+ // While the PUT was in flight, more local edits may
358
+ // have occurred. Diff again and loop if changed.
242
359
  var more = get_change()
243
360
  if (!more) return
244
361
  ;({patches, new_state} = more)
@@ -253,9 +370,18 @@ function simpleton_client(url, {
253
370
  return a.length === b.length && a.every((v, i) => v === b[i])
254
371
  }
255
372
 
256
- function get_char_size(s, i) {
257
- const charCode = s.charCodeAt(i)
258
- return (charCode >= 0xd800 && charCode <= 0xdbff) ? 2 : 1
373
+ // ── JS-SPECIFIC: UTF-16 helpers ─────────────────────────────────────
374
+ // These handle surrogate pairs in UTF-16 JS strings. Characters
375
+ // outside the Basic Multilingual Plane (BMP) are encoded as two
376
+ // 16-bit code units (a surrogate pair: high 0xD800-0xDBFF, low
377
+ // 0xDC00-0xDFFF). Such a pair represents one Unicode code point.
378
+ //
379
+ // OTHER LANGUAGES: You don't need these if your string type is
380
+ // natively indexed by code points.
381
+
382
+ function get_char_size(str, utf16_index) {
383
+ const char_code = str.charCodeAt(utf16_index)
384
+ return (char_code >= 0xd800 && char_code <= 0xdbff) ? 2 : 1
259
385
  }
260
386
 
261
387
  function count_code_points(str) {
@@ -267,32 +393,53 @@ function simpleton_client(url, {
267
393
  return code_points
268
394
  }
269
395
 
270
- function simple_diff(a, b) {
271
- // Find common prefix
272
- var p = 0
273
- var len = Math.min(a.length, b.length)
274
- while (p < len && a[p] === b[p]) p++
275
-
276
- // Find common suffix (from what remains after prefix)
277
- var s = 0
278
- len -= p
279
- while (s < len && a[a.length - s - 1] === b[b.length - s - 1]) s++
280
-
281
- return {range: [p, a.length - s], content: b.slice(p, b.length - s)}
396
+ // ── simple_diff ─────────────────────────────────────────────────────
397
+ // Finds the longest common prefix and suffix between two strings,
398
+ // returning the minimal edit that transforms `old_str` into `new_str`.
399
+ //
400
+ // Returns: { range: [prefix_len, old_str.length - suffix_len],
401
+ // content: new_str.slice(prefix_len, new_str.length - suffix_len) }
402
+ //
403
+ // This produces a single contiguous edit. For multi-cursor or
404
+ // multi-region edits, supply a custom get_patches function instead.
405
+ function simple_diff(old_str, new_str) {
406
+ // Find common prefix length
407
+ var prefix_len = 0
408
+ var min_len = Math.min(old_str.length, new_str.length)
409
+ while (prefix_len < min_len && old_str[prefix_len] === new_str[prefix_len]) prefix_len++
410
+
411
+ // Find common suffix length (from what remains after prefix)
412
+ var suffix_len = 0
413
+ min_len -= prefix_len
414
+ while (suffix_len < min_len && old_str[old_str.length - suffix_len - 1] === new_str[new_str.length - suffix_len - 1]) suffix_len++
415
+
416
+ return {range: [prefix_len, old_str.length - suffix_len], content: new_str.slice(prefix_len, new_str.length - suffix_len)}
282
417
  }
283
418
 
419
+ // ── apply_patches ───────────────────────────────────────────────────
420
+ // 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.
423
+ //
424
+ // Patches must have absolute coordinates (relative to the original
425
+ // string, not to the string after previous patches). The offset
426
+ // variable tracks the cumulative shift from previous patches.
284
427
  function apply_patches(state, patches) {
285
428
  var offset = 0
286
- for (var p of patches) {
287
- state = state.substring(0, p.range[0] + offset) + p.content +
288
- state.substring(p.range[1] + offset)
289
- offset += p.content.length - (p.range[1] - p.range[0])
429
+ for (var patch of patches) {
430
+ state = state.substring(0, patch.range[0] + offset) + patch.content +
431
+ state.substring(patch.range[1] + offset)
432
+ offset += patch.content.length - (patch.range[1] - patch.range[0])
290
433
  }
291
434
  return state
292
435
  }
293
436
 
294
- async function get_digest(s) {
295
- var bytes = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(s))
437
+ // ── get_digest ──────────────────────────────────────────────────────
438
+ // Computes SHA-256 of the UTF-8 encoding of the state string,
439
+ // formatted as the Repr-Digest header value:
440
+ // sha-256=:<base64-encoded-hash>:
441
+ async function get_digest(str) {
442
+ var bytes = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(str))
296
443
  return `sha-256=:${btoa(String.fromCharCode(...new Uint8Array(bytes)))}:`
297
444
  }
298
445
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "braid-text",
3
- "version": "0.3.7",
3
+ "version": "0.3.9",
4
4
  "description": "Library for collaborative text over http using braid.",
5
5
  "author": "Braid Working Group",
6
6
  "repository": "braid-org/braid-text",
package/server.js CHANGED
@@ -2160,7 +2160,8 @@ function create_braid_text() {
2160
2160
  }
2161
2161
  )
2162
2162
  }
2163
- return relative_to_absolute_patches(patches)
2163
+ var result = relative_to_absolute_patches(patches)
2164
+ return result
2164
2165
  }
2165
2166
 
2166
2167
  function relative_to_absolute_patches(patches) {