braid-text 0.2.49 → 0.2.51

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/README.md CHANGED
@@ -102,7 +102,7 @@ Here's a basic running example to start:
102
102
  <!-- 1. Your textarea -->
103
103
  <textarea id="my_textarea"></textarea>
104
104
 
105
- <!-- 2. Include the library -->
105
+ <!-- 2. Include the libraries -->
106
106
  <script src="https://unpkg.com/braid-http@~1.3/braid-http-client.js"></script>
107
107
  <script src="https://unpkg.com/braid-text/simpleton-client.js"></script>
108
108
 
@@ -119,88 +119,113 @@ Here's a basic running example to start:
119
119
  </script>
120
120
  ```
121
121
 
122
- You should see something if you run this (though the server in that example will likely ignore your changes).
122
+ You should see something if you run this (though the server in this example will likely ignore your changes).
123
123
 
124
- The basic idea is that you create a simpleton_client and tell it to connect to a url. Any incoming changes trigger the `on_state` callback. For outgoing changes, you'd think we would tell simpleton the new state when we get the `oninput` event — however, the network may be backed up, and simpleton wants to hold off on sending more updates. So instead, we just inform simpleton that a change has occurred and let it handle when to actually send the change to the server.
124
+ ### How It Works
125
125
 
126
- This decoupling between `simpleton.changed` and `get_state` is especially helpful when disconnecting from the server for extended periods (like getting on a plane for a couple hours). Without it, simpleton would have queued up a message for every change made during those two hours and try to send them all upon reconnection. With the decoupling, simpleton can wait until reconnection and then request a single diff to the new final state.
126
+ The client uses a **decoupled update mechanism** for efficiency:
127
127
 
128
- ### Finer-Grained Integration
128
+ 1. When users type, you call `simpleton.changed()` to notify the client that something changed
129
+ 2. The client decides *when* to actually fetch and send updates based on network conditions
130
+ 3. When ready, it calls your `get_state` function to get the current text
129
131
 
130
- There are a couple ways to do a more fine-grained integration:
132
+ This design prevents network congestion and handles disconnections gracefully. For example, if you edit offline for hours, the client will send just one efficient diff when reconnecting, rather than thousands of individual keystrokes.
131
133
 
132
- **1. Receiving patches instead of full state**
134
+ ### Advanced Integration
133
135
 
134
- Rather than receiving a completely new state in `on_state`, you can receive just changes to the current state in `on_patches`, as an array of patches. This can be more efficient, especially if you're integrating with an editor that has an API for making edits (rather than needing to set the whole string, as with an HTML textarea). Even with a textarea though, getting patches can be helpful for updating the cursor or selection position.
136
+ For better performance and control, you can work with patches instead of full text:
135
137
 
136
- **2. Supplying custom patch generation**
138
+ #### Receiving Patches
137
139
 
138
- Rather than only supplying `get_state`, you can also supply `get_patches` — a function that accepts a string representing the document at some point in the past and returns an array of patches to get from there to the current state.
140
+ Instead of receiving complete text updates, you can process individual changes:
139
141
 
140
- Simpleton has a default simple way of computing this (scanning for a common prefix and suffix), but you may have a better function that handles complex cases. For example, when a user pastes in what seems like a large change that replaces the entire document, proper diff analysis might reveal it's just a few edits here and there. Also, with some editors, the analogous `oninput` event may provide patches for free, making `get_patches` more efficient.
142
+ ```javascript
143
+ var simpleton = simpleton_client(url, {
144
+ on_patches: (patches) => {
145
+ // Apply each patch to your editor..
146
+ },
147
+ get_state: () => editor.getValue()
148
+ })
149
+ ```
141
150
 
142
- You can see an example of both these finer-grained integration techniques here: [editor.html](https://raw.githubusercontent.com/braid-org/braid-text/master/editor.html).
151
+ This is more efficient for large documents and helps preserve cursor position.
143
152
 
144
- ## Client API
153
+ #### Custom Patch Generation
154
+
155
+ You can provide your own diff algorithm or use patches from your editor's API:
145
156
 
146
157
  ```javascript
147
- simpleton = simpleton_client(url, options)
158
+ var simpleton = simpleton_client(url, {
159
+ on_state: state => editor.setValue(state),
160
+ get_state: () => editor.getValue(),
161
+ get_patches: (prev_state) => {
162
+ // Use your own diff algorithm or editor's change tracking
163
+ return compute_patches(prev_state, editor.getValue())
164
+ }
165
+ })
148
166
  ```
149
167
 
150
- - `url`: The URL of the resource to synchronize with.
151
- - `options`: An object containing the following properties:
168
+ See [editor.html](https://github.com/braid-org/braid-text/blob/master/editor.html) for a complete example with CodeMirror integration.
152
169
 
153
- ### Incoming Updates
154
-
155
- - `on_patches`: <small style="color:lightgrey">[optional]</small> A function called when patches are received from the server:
156
-
157
- ```javascript
158
- (patches) => {...}
159
- ```
170
+ ## Client API
160
171
 
161
- - `patches`: An array of patch objects, each representing a string-replace operation. Each patch object has:
162
- - `range`: An array of two numbers, `[start, end]`, specifying the start and end positions of the characters to be deleted.
163
- - `content`: The text to be inserted in place of the deleted characters.
172
+ ### Constructor
164
173
 
165
- Note that patches will always be in order, but the range positions of each patch reference the original string, i.e., the second patch's range values do not take into account the application of the first patch.
174
+ ```javascript
175
+ simpleton = simpleton_client(url, options)
176
+ ```
166
177
 
167
- - `on_state`: <small style="color:lightgrey">[optional]</small> A function called when a complete state update is received from the server:
178
+ Creates a new Simpleton client that synchronizes with a Braid-Text server.
168
179
 
169
- ```javascript
170
- (state) => {...}
171
- ```
180
+ **Parameters:**
181
+ - `url`: The URL of the resource to synchronize with
182
+ - `options`: Configuration object with the following properties:
172
183
 
173
- - `state`: The new complete value of the text.
184
+ #### Required Options
174
185
 
175
- ### Local State Management
186
+ - `get_state`: **[required]** Function that returns the current text state
187
+ ```javascript
188
+ () => current_text_string
189
+ ```
176
190
 
177
- - `get_state`: **[required]** A function that returns the current state of the text:
191
+ #### Incoming Updates (choose one)
178
192
 
179
- ```javascript
180
- () => current_state
181
- ```
193
+ - `on_state`: <small style="color:lightgrey">[optional]</small> Callback for receiving complete state updates
194
+ ```javascript
195
+ (state) => { /* update your UI with new text */ }
196
+ ```
182
197
 
183
- - `get_patches`: <small style="color:lightgrey">[optional]</small> A function that generates patches representing changes between a previous state and the current state:
198
+ - `on_patches`: <small style="color:lightgrey">[optional]</small> Callback for receiving incremental changes
199
+ ```javascript
200
+ (patches) => { /* apply patches to your editor */ }
201
+ ```
202
+ Each patch has:
203
+ - `range`: `[start, end]` - positions to delete (in original text coordinates)
204
+ - `content`: Text to insert at that position
205
+
206
+ **Note:** All patches reference positions in the original text before any patches are applied.
184
207
 
185
- ```javascript
186
- (prev_state) => patches
187
- ```
208
+ #### Outgoing Updates
188
209
 
189
- Returns an array of patch objects representing the changes. The default implementation finds a common prefix and suffix for a simple diff, but you can provide a more sophisticated implementation or track patches directly from your editor.
210
+ - `get_patches`: <small style="color:lightgrey">[optional]</small> Custom function to generate patches
211
+ ```javascript
212
+ (previous_state) => array_of_patches
213
+ ```
214
+ If not provided, uses a simple prefix/suffix diff algorithm.
190
215
 
191
- ### Other Options
216
+ #### Additional Options
192
217
 
193
- - `content_type`: <small style="color:lightgrey">[optional]</small> If set, this value will be sent in the `Accept` and `Content-Type` headers to the server.
218
+ - `content_type`: <small style="color:lightgrey">[optional]</small> MIME type for `Accept` and `Content-Type` headers
194
219
 
195
220
  ### Methods
196
221
 
197
- - `simpleton.changed()`: Call this function to report local updates whenever they occur, e.g., in the `oninput` event handler of a textarea being synchronized. The system will call `get_patches` when it needs to send updates to the server.
222
+ - `simpleton.changed()`: Notify the client that local changes have occurred. Call this in your editor's change event handler. The client will call `get_patches` and `get_state` when it's ready to send updates.
198
223
 
199
224
  ### Deprecated Options
200
225
 
201
226
  The following options are deprecated and should be replaced with the new API:
202
227
 
203
- - ~~`apply_remote_update`~~ → Use `on_patches` and `on_state` instead
228
+ - ~~`apply_remote_update`~~ → Use `on_patches` or `on_state` instead
204
229
  - ~~`generate_local_diff_update`~~ → Use `get_patches` and `get_state` instead
205
230
 
206
231
  ## Testing
package/index.js CHANGED
@@ -70,8 +70,7 @@ braid_text.serve = async (req, res, options = {}) => {
70
70
  }
71
71
 
72
72
  var get_current_version = () => ascii_ify(
73
- resource.doc.getRemoteVersion().map(x => x.join("-")).sort()
74
- .map(x => JSON.stringify(x)).join(", "))
73
+ resource.version.map(x => JSON.stringify(x)).join(", "))
75
74
 
76
75
  if (req.method == "GET" || req.method == "HEAD") {
77
76
  // make sure we have the necessary version and parents
@@ -133,7 +132,17 @@ braid_text.serve = async (req, res, options = {}) => {
133
132
  accept_encoding:
134
133
  req.headers['x-accept-encoding'] ??
135
134
  req.headers['accept-encoding'],
136
- subscribe: x => res.sendVersion(x),
135
+ subscribe: x => {
136
+
137
+ // this is a sanity/rhobustness check..
138
+ // ..this digest is checked on the client..
139
+ // ..it is not strictly necessary
140
+ if (x.version && v_eq(x.version, resource.version)) {
141
+ x["Repr-Digest"] = `sha-256=:${require('crypto').createHash('sha256').update(Buffer.from(resource.val, "utf8")).digest('base64')}:`
142
+ }
143
+
144
+ res.sendVersion(x)
145
+ },
137
146
  write: (x) => res.write(x)
138
147
  }
139
148
 
@@ -233,7 +242,7 @@ braid_text.get = async (key, options) => {
233
242
  if (options.parents) validate_version_array(options.parents)
234
243
 
235
244
  let resource = (typeof key == 'string') ? await get_resource(key) : key
236
- var version = resource.doc.getRemoteVersion().map((x) => x.join("-")).sort()
245
+ var version = resource.version
237
246
 
238
247
  if (!options.subscribe) {
239
248
  if (options.transfer_encoding === 'dt') {
@@ -390,7 +399,7 @@ braid_text.put = async (key, options) => {
390
399
  }
391
400
  }
392
401
 
393
- let parents = resource.doc.getRemoteVersion().map((x) => x.join("-")).sort()
402
+ let parents = resource.version
394
403
  let og_parents = options_parents || parents
395
404
 
396
405
  let max_pos = resource.length_cache.get('' + og_parents) ??
@@ -526,6 +535,7 @@ braid_text.put = async (key, options) => {
526
535
 
527
536
  for (let b of bytes) resource.doc.mergeBytes(b)
528
537
  resource.val = resource.doc.get()
538
+ resource.version = resource.doc.getRemoteVersion().map(x => x.join("-")).sort()
529
539
 
530
540
  var post_commit_updates = []
531
541
 
@@ -533,7 +543,7 @@ braid_text.put = async (key, options) => {
533
543
  patches = get_xf_patches(resource.doc, v_before)
534
544
  if (braid_text.verbose) console.log(JSON.stringify({ patches }))
535
545
 
536
- let version = resource.doc.getRemoteVersion().map((x) => x.join("-")).sort()
546
+ let version = resource.version
537
547
 
538
548
  for (let client of resource.simpleton_clients) {
539
549
  if (peer && client.peer === peer) {
@@ -546,7 +556,7 @@ braid_text.put = async (key, options) => {
546
556
  // if the doc has been freed, exit early
547
557
  if (resource.doc.__wbg_ptr === 0) return
548
558
 
549
- let version = resource.doc.getRemoteVersion().map((x) => x.join("-")).sort()
559
+ let version = resource.version
550
560
  let x = { version }
551
561
  x.parents = client.my_last_seen_version
552
562
 
@@ -609,7 +619,7 @@ braid_text.put = async (key, options) => {
609
619
  }
610
620
  } else {
611
621
  if (resource.simpleton_clients.size) {
612
- let version = resource.doc.getRemoteVersion().map((x) => x.join("-")).sort()
622
+ let version = resource.version
613
623
  patches = get_xf_patches(resource.doc, v_before)
614
624
  let x = { version, parents, patches }
615
625
  if (braid_text.verbose) console.log(`sending: ${JSON.stringify(x)}`)
@@ -681,6 +691,7 @@ async function get_resource(key) {
681
691
  })
682
692
 
683
693
  resource.val = resource.doc.get()
694
+ resource.version = resource.doc.getRemoteVersion().map(x => x.join("-")).sort()
684
695
 
685
696
  resource.length_cache = createSimpleCache(braid_text.length_cache_size)
686
697
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "braid-text",
3
- "version": "0.2.49",
3
+ "version": "0.2.51",
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/test/test.html CHANGED
@@ -96,6 +96,90 @@ async function runTest(testName, testFunction, expectedResult) {
96
96
  }
97
97
  }
98
98
 
99
+ runTest(
100
+ "test subscribing and verifying digests [simpleton]",
101
+ async () => {
102
+ let key = 'test-' + Math.random().toString(36).slice(2)
103
+
104
+ let r = await braid_fetch(`/${key}`, {
105
+ method: 'PUT',
106
+ version: ['hi-1'],
107
+ parents: [],
108
+ body: 'xx'
109
+ })
110
+ if (!r.ok) throw 'got: ' + r.statusCode
111
+
112
+ let r2 = await braid_fetch(`/${key}`, {
113
+ version: ['hi-0'],
114
+ subscribe: true
115
+ })
116
+ var parts = []
117
+ var p = new Promise(async (done, fail) => {
118
+ r2.subscribe(update => {
119
+ parts.push(update.extra_headers['repr-digest'])
120
+ if (parts.length > 1) done()
121
+ }, fail)
122
+ })
123
+
124
+ await new Promise(done => setTimeout(done, 300))
125
+ let rr = await braid_fetch(`/${key}`, {
126
+ method: 'PUT',
127
+ version: ['hi-2'],
128
+ parents: ['hi-1'],
129
+ patches: [{unit: "text", range: "[1:1]", content: "Y"}]
130
+ })
131
+ if (!rr.ok) throw 'got: ' + rr.statusCode
132
+
133
+ await p
134
+ return JSON.stringify(parts)
135
+ },
136
+ '["sha-256=:Xd6JaIf2dUybFb/jpEGuSAbfL96UABMR4IvxEGIuC74=:","sha-256=:77cl3INcGEtczN0zK3eOgW/YWYAOm8ub73LkVcF2/rA=:"]'
137
+ )
138
+
139
+ runTest(
140
+ "test subscribing and verifying digests [dt]",
141
+ async () => {
142
+ let key = 'test-' + Math.random().toString(36).slice(2)
143
+
144
+ let r = await braid_fetch(`/${key}`, {
145
+ method: 'PUT',
146
+ version: ['hi-1'],
147
+ parents: [],
148
+ body: 'xx'
149
+ })
150
+ if (!r.ok) throw 'got: ' + r.statusCode
151
+
152
+ let r2 = await braid_fetch(`/${key}`, {
153
+ version: ['hi-0'],
154
+ headers: { 'merge-type': 'dt' },
155
+ subscribe: true
156
+ })
157
+ var parts = []
158
+ var p = new Promise(async (done, fail) => {
159
+ r2.subscribe(update => {
160
+
161
+ console.log(`update: ${JSON.stringify(update, null, 4)}`)
162
+
163
+ parts.push(update.extra_headers['repr-digest'])
164
+ if (parts.length > 1) done()
165
+ }, fail)
166
+ })
167
+
168
+ await new Promise(done => setTimeout(done, 300))
169
+ let rr = await braid_fetch(`/${key}`, {
170
+ method: 'PUT',
171
+ version: ['hi-2'],
172
+ parents: ['hi-1'],
173
+ patches: [{unit: "text", range: "[2:2]", content: "Y"}]
174
+ })
175
+ if (!rr.ok) throw 'got: ' + rr.statusCode
176
+
177
+ await p
178
+ return JSON.stringify(parts)
179
+ },
180
+ '["sha-256=:Xd6JaIf2dUybFb/jpEGuSAbfL96UABMR4IvxEGIuC74=:","sha-256=:QknHazou37wCCwv3JXnCoAvXcKszP6xBTxLIiUAETgI=:"]'
181
+ )
182
+
99
183
  runTest(
100
184
  "test PUTing a version that the server already has",
101
185
  async () => {