braid-text 0.0.1

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 ADDED
@@ -0,0 +1,122 @@
1
+ # Collaborative text over Braid-HTTP
2
+
3
+ This library provides a simple http route handler, along with client code, enabling fast text synchronization over a standard protocol.
4
+
5
+ - Supports [Braid-HTTP](https://github.com/braid-org/braid-spec/blob/master/draft-toomim-httpbis-braid-http-04.txt) protocol
6
+ - Supports [Simpleton](https://braid.org/meeting-76/simpleton) merge-type
7
+ - Enables light clients
8
+ - As little as 50 lines of code!
9
+ - With zero history overhead on client
10
+ - Supports backpressure to run smoothly on constrained servers
11
+ - Supports [Diamond Types](https://github.com/josephg/diamond-types) merge-type
12
+ - Fast / Robust / Extensively fuzz-tested
13
+ - Developed in [braid.org](https://braid.org)
14
+
15
+ ## Use the Library on the Server
16
+
17
+ Install it in your project:
18
+ ```shell
19
+ npm install braid-text
20
+ ```
21
+
22
+ Import the request handler into your code, and use it to handle HTTP requests wherever you want:
23
+
24
+ ```javascript
25
+ var braid_text = require("braid-text")
26
+
27
+ server.on("request", (req, res) => {
28
+ // Your server logic...
29
+
30
+ // Whenever desired, serve braid text for this request/response:
31
+ braid_text.serve(req, res)
32
+ })
33
+ ```
34
+
35
+ ## Run the Demo
36
+
37
+ This will run a collaboratively-editable wiki:
38
+
39
+ ```shell
40
+ npm install
41
+ node server-demo.js
42
+ ```
43
+
44
+ Now open these URLs in your browser:
45
+ - http://localhost:8888/demo (to see the demo text)
46
+ - http://localhost:8888/demo?editor (to edit the text)
47
+ - http://localhost:8888/demo?markdown-editor (to edit it as markdown)
48
+
49
+ Or try opening the URL in [Braid-Chrome](https://github.com/braid-org/braid-chrome), or another Braid client, to edit it directly!
50
+
51
+ Check out the `server-demo.js` file to see examples for how to add access control, and a `/pages` endpoint to show all the edited pages.
52
+
53
+ ## Full Server Library API
54
+
55
+ `braid_text.db_folder = './braid-text-db' // <-- this is the default`
56
+ - This is where the Diamond-Types history files will be stored for each resource.
57
+ - This folder will be created if it doesn't exist.
58
+ - The files for a resource will all be prefixed with a url-encoding of `key` within this folder.
59
+
60
+ `braid_text.server(req, res, options)`
61
+ - `req`: The incoming HTTP request object.
62
+ - `res`: The HTTP response object to send the response.
63
+ - `options`: <small style="color:lightgrey">[optional]</small> An object containing additional options:
64
+ - `key`: <small style="color:lightgrey">[optional]</small> ID of text resource to sync with. Defaults to `req.url`.
65
+ - This is the main method of this library, and does all the work to handle Braid-HTTP `GET` and `PUT` requests concerned with a specific text resource.
66
+
67
+ `await braid_text.get(key)`
68
+ - `key`: ID of text resource.
69
+ - Returns the text of the resource as a string.
70
+
71
+ `await braid_text.get(key, options)`
72
+ - `key`: ID of text resource.
73
+ - `options`: An object containing additional options, like http headers:
74
+ - `version`: <small style="color:lightgrey">[optional]</small> The version to get.
75
+ - `subscribe: cb`: <small style="color:lightgrey">[optional]</small> Transforms `get` into a subscription that calls `cb` with each update. The function `cb` is called with the argument `{version, parents, body, patches}` with each update to the text.
76
+ - `parents`: <small style="color:lightgrey">[optional]</small> Array of parents — the subscription will only send newer updates than these.
77
+ - `merge_type`: <small style="color:lightgrey">[optional]</small> When subscribing, identifies the synchronization protocol. Defaults to `simpleton`, but can be set to `dt`.
78
+ - `peer`: <small style="color:lightgrey">[optional]</small> When subscribing, identifies this peer. Mutations will not be echoed back to the same peer that puts them, if that put also sets the same `peer` header.
79
+
80
+ - If we are NOT subscribing, returns `{version, body}`, with the `version` being returned, and the text as `body`. If we are subscribing, this returns nothing.
81
+
82
+ `await braid_text.put(key, options)`
83
+ - `key`: ID of text resource.
84
+ - `options`: An object containing additional options, like http headers:
85
+ - `version`: <small style="color:lightgrey">[optional]</small> The version being supplied. Will be randomly generated if not supplied.
86
+ - `parents`: <small style="color:lightgrey">[optional]</small> Array of versions this update depends on. Defaults to the server’s current version.
87
+ - `body`: <small style="color:lightgrey">[optional]</small> Use this to completely replace the existing text with this new text.
88
+ - `patches`: <small style="color:lightgrey">[optional]</small> Array of patches, each of the form `{unit: 'text', range: '[1:3]', content: 'hi'}`, which would replace the second and third unicode code-points in the text with `hi`.
89
+ - `peer`: <small style="color:lightgrey">[optional]</small> Identifies this peer. This mutation will not be echoed back to `get` subscriptions that use this same `peer` header.
90
+
91
+ ## Use the Library on the Client
92
+
93
+ <script src="https://unpkg.com/braid-text/client.js"></script>
94
+
95
+ ...
96
+ // connect to the server
97
+ let braid_text = braid_text_client(SERVER_URL, {
98
+ apply_remote_update: ({ state, patches }) => {
99
+ // apply incoming state / return new state
100
+ },
101
+ generate_local_diff_update: (prev_state) => {
102
+ // report changes between prev_state and the current state
103
+ },
104
+ });
105
+
106
+ ...
107
+
108
+ // when changes are made, let braid-text know
109
+ braid_text.changed()
110
+
111
+ See [editor.html](https://raw.githubusercontent.com/braid-org/braid-text/master/editor.html) for a simple working example.
112
+
113
+ ## Full Client Library API
114
+
115
+ braid_text = braid_text_client(url, {
116
+ apply_remote_update,
117
+ generate_local_diff_update}
118
+
119
+ - `url`: The url of the resource to synchronize with.
120
+ - `apply_remote_update`: This function will be called whenever an update is received from the server. The function should look like `({state, patches}) => {...}`. Only one of `state` or `patches` will be set. If it is `state`, then this is the new value of the text. If it is `patches`, then patches is an array of values like `{range: [1, 3], content: "Hi"}`. Each such value represents a string-replace operation; the `range` specifies a start and end position — these characters will be deleted — and `content` says what text to put in its place. Note that these patches will always be in order, but that the range positions of each patch always reference the original string, e.g., the second patch's range values do not take into account applying the first patch. Finally, this function returns the new state, after the application of the `state` or `patches`.
121
+ - `generate_local_diff_update`: This function will often be called whenever an update happens locally, but the system may delay calling it if the network is congested. The function should look like `(prev_state) => {...}`. The function should basically do a diff between `prev_state` and the current state, and express this diff as an array of patches similar to the ones discussed above. Finally, if there is an actual difference detected, this function should return an object `{state, patches}`, otherwise it should return nothing.
122
+ - `braid_text.changed()`: Call this to report local updates whenever they occur, e.g., in the `oninput` handler of a textarea being synchronized.
package/client.js ADDED
@@ -0,0 +1,173 @@
1
+ // requires braid-http@0.3.14
2
+ //
3
+ // url: resource endpoint
4
+ //
5
+ // apply_remote_update: ({patches, state}) => {...}
6
+ // this is for incoming changes;
7
+ // one of these will be non-null,
8
+ // and can be applied to the current state.
9
+ //
10
+ // generate_local_diff_update: (prev_state) => {...}
11
+ // this is to generate outgoing changes,
12
+ // and if there are changes, returns { patches, state }
13
+ //
14
+ // content_type: overrides the Accept and Content-Type headers
15
+ //
16
+ // returns { changed(): (diff_function) => {...} }
17
+ // this is for outgoing changes;
18
+ // diff_function = () => ({patches, new_version}).
19
+ //
20
+ function braid_text_client(url, { apply_remote_update, generate_local_diff_update, content_type }) {
21
+ var peer = Math.random().toString(36).substr(2)
22
+ var current_version = []
23
+ var prev_state = ""
24
+ var char_counter = -1
25
+ var outstanding_changes = 0
26
+ var max_outstanding_changes = 10
27
+
28
+ braid_fetch_wrapper(url, {
29
+ headers: { "Merge-Type": "simpleton",
30
+ ...(content_type ? {Accept: content_type} : {}) },
31
+ subscribe: true,
32
+ retry: true,
33
+ parents: () => current_version.length ? current_version : null,
34
+ peer
35
+ }).then(res =>
36
+ res.subscribe(update => {
37
+ // Only accept the update if its parents == our current version
38
+ update.parents.sort()
39
+ if (current_version.length === update.parents.length
40
+ && current_version.every((v, i) => v === update.parents[i])) {
41
+ current_version = update.version.sort()
42
+ update.state = update.body
43
+
44
+ if (update.patches) {
45
+ for (let p of update.patches) p.range = p.range.match(/\d+/g).map((x) => 1 * x)
46
+ update.patches.sort((a, b) => a.range[0] - b.range[0])
47
+
48
+ // convert from code-points to js-indicies
49
+ let c = 0
50
+ let i = 0
51
+ for (let p of update.patches) {
52
+ while (c < p.range[0]) {
53
+ i += get_char_size(prev_state, i)
54
+ c++
55
+ }
56
+ p.range[0] = i
57
+
58
+ while (c < p.range[1]) {
59
+ i += get_char_size(prev_state, i)
60
+ c++
61
+ }
62
+ p.range[1] = i
63
+ }
64
+ }
65
+
66
+ prev_state = apply_remote_update(update)
67
+ }
68
+ })
69
+ )
70
+
71
+ return {
72
+ changed: async () => {
73
+ if (outstanding_changes >= max_outstanding_changes) return
74
+ while (true) {
75
+ var update = generate_local_diff_update(prev_state)
76
+ if (!update) return // Stop if there wasn't a change!
77
+ var {patches, state} = update
78
+
79
+ // convert from js-indicies to code-points
80
+ let c = 0
81
+ let i = 0
82
+ for (let p of patches) {
83
+ while (i < p.range[0]) {
84
+ i += get_char_size(prev_state, i)
85
+ c++
86
+ }
87
+ p.range[0] = c
88
+
89
+ while (i < p.range[1]) {
90
+ i += get_char_size(prev_state, i)
91
+ c++
92
+ }
93
+ p.range[1] = c
94
+
95
+ char_counter += p.range[1] - p.range[0]
96
+ char_counter += count_code_points(p.content)
97
+
98
+ p.unit = "text"
99
+ p.range = `[${p.range[0]}:${p.range[1]}]`
100
+ }
101
+
102
+ var version = [peer + "-" + char_counter]
103
+
104
+ var parents = current_version
105
+ current_version = version
106
+ prev_state = state
107
+
108
+ outstanding_changes++
109
+ await braid_fetch_wrapper(url, {
110
+ headers: { "Merge-Type": "simpleton",
111
+ ...(content_type ? {"Content-Type": content_type} : {}) },
112
+ method: "PUT",
113
+ retry: true,
114
+ version, parents, patches,
115
+ peer
116
+ })
117
+ outstanding_changes--
118
+ }
119
+ }
120
+ }
121
+ }
122
+
123
+ function get_char_size(s, i) {
124
+ const charCode = s.charCodeAt(i)
125
+ return (charCode >= 0xd800 && charCode <= 0xdbff) ? 2 : 1
126
+ }
127
+
128
+ function count_code_points(str) {
129
+ let code_points = 0
130
+ for (let i = 0; i < str.length; i++) {
131
+ if (str.charCodeAt(i) >= 0xd800 && str.charCodeAt(i) <= 0xdbff) i++
132
+ code_points++
133
+ }
134
+ return code_points
135
+ }
136
+
137
+ async function braid_fetch_wrapper(url, params) {
138
+ if (!params.retry) throw "wtf"
139
+ var waitTime = 10
140
+ if (params.subscribe) {
141
+ var subscribe_handler = null
142
+ connect()
143
+ async function connect() {
144
+ try {
145
+ var c = await braid_fetch(url, { ...params, parents: params.parents?.() })
146
+ c.subscribe((...args) => subscribe_handler?.(...args), on_error)
147
+ waitTime = 10
148
+ } catch (e) {
149
+ on_error(e)
150
+ }
151
+ }
152
+ function on_error(e) {
153
+ console.log('eee = ' + e.stack)
154
+ setTimeout(connect, waitTime)
155
+ waitTime = Math.min(waitTime * 2, 3000)
156
+ }
157
+ return {subscribe: handler => { subscribe_handler = handler }}
158
+ } else {
159
+ return new Promise((done) => {
160
+ send()
161
+ async function send() {
162
+ try {
163
+ var res = await braid_fetch(url, params)
164
+ if (res.status !== 200) throw "status not 200: " + res.status
165
+ done(res)
166
+ } catch (e) {
167
+ setTimeout(send, waitTime)
168
+ waitTime = Math.min(waitTime * 2, 3000)
169
+ }
170
+ }
171
+ })
172
+ }
173
+ }
package/editor.html ADDED
@@ -0,0 +1,79 @@
1
+ <body style="background: auto; margin: 0px; padding: 0px">
2
+ <textarea
3
+ id="texty"
4
+ style="width: 100%; height: 100%; box-sizing: border-box"
5
+ ></textarea>
6
+ </body>
7
+ <script src="https://braid.org/code/myers-diff1.js"></script>
8
+ <script src="https://unpkg.com/braid-http@~0.3/braid-http-client.js"></script>
9
+ <script src="https://unpkg.com/braid-text/client.js"></script>
10
+ <script>
11
+ let braid_text = braid_text_client(location.pathname, {
12
+ apply_remote_update: ({ state, patches }) => {
13
+ if (state !== undefined) texty.value = state;
14
+ else apply_patches_and_update_selection(texty, patches);
15
+ return texty.value;
16
+ },
17
+ generate_local_diff_update: (prev_state) => {
18
+ var patches = diff(prev_state, texty.value);
19
+ if (patches.length === 0) return null;
20
+ return { patches, state: texty.value };
21
+ },
22
+ });
23
+
24
+ texty.value = "";
25
+ texty.oninput = (e) => braid_text.changed();
26
+
27
+ function diff(before, after) {
28
+ let diff = diff_main(before, after);
29
+ let patches = [];
30
+ let offset = 0;
31
+ for (let d of diff) {
32
+ let p = null;
33
+ if (d[0] == 1) p = { range: [offset, offset], content: d[1] };
34
+ else if (d[0] == -1) {
35
+ p = { range: [offset, offset + d[1].length], content: "" };
36
+ offset += d[1].length;
37
+ } else offset += d[1].length;
38
+ if (p) {
39
+ p.unit = "text";
40
+ patches.push(p);
41
+ }
42
+ }
43
+ return patches;
44
+ }
45
+
46
+ function apply_patches_and_update_selection(textarea, patches) {
47
+ let offset = 0;
48
+ for (let p of patches) {
49
+ p.range[0] += offset;
50
+ p.range[1] += offset;
51
+ offset -= p.range[1] - p.range[0];
52
+ offset += p.content.length;
53
+ }
54
+
55
+ let original = textarea.value;
56
+ let sel = [textarea.selectionStart, textarea.selectionEnd];
57
+
58
+ for (var p of patches) {
59
+ let range = p.range;
60
+
61
+ for (let i = 0; i < sel.length; i++)
62
+ if (sel[i] > range[0])
63
+ if (sel[i] > range[1]) sel[i] -= range[1] - range[0];
64
+ else sel[i] = range[0];
65
+
66
+ for (let i = 0; i < sel.length; i++)
67
+ if (sel[i] > range[0]) sel[i] += p.content.length;
68
+
69
+ original =
70
+ original.substring(0, range[0]) +
71
+ p.content +
72
+ original.substring(range[1]);
73
+ }
74
+
75
+ textarea.value = original;
76
+ textarea.selectionStart = sel[0];
77
+ textarea.selectionEnd = sel[1];
78
+ }
79
+ </script>