@brass-build/client 0.3.0 → 0.4.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.
Files changed (128) hide show
  1. package/AGENTS.md +50 -7
  2. package/CHANGELOG.md +63 -2
  3. package/README.md +233 -77
  4. package/dist/acting-organization.d.ts +131 -0
  5. package/dist/acting-organization.d.ts.map +1 -0
  6. package/dist/acting-organization.js +273 -0
  7. package/dist/acting-organization.js.map +1 -0
  8. package/dist/api-headers.js +7 -7
  9. package/dist/api-headers.js.map +1 -1
  10. package/dist/call-service.d.ts +74 -0
  11. package/dist/call-service.d.ts.map +1 -0
  12. package/dist/call-service.js +253 -0
  13. package/dist/call-service.js.map +1 -0
  14. package/dist/client.d.ts +31 -0
  15. package/dist/client.d.ts.map +1 -1
  16. package/dist/client.js +611 -12
  17. package/dist/client.js.map +1 -1
  18. package/dist/document-contents.d.ts +15 -0
  19. package/dist/document-contents.d.ts.map +1 -0
  20. package/dist/document-contents.js +88 -0
  21. package/dist/document-contents.js.map +1 -0
  22. package/dist/document-streams.d.ts +4 -2
  23. package/dist/document-streams.d.ts.map +1 -1
  24. package/dist/document-streams.js +6 -0
  25. package/dist/document-streams.js.map +1 -1
  26. package/dist/documents.d.ts +45 -1
  27. package/dist/documents.d.ts.map +1 -1
  28. package/dist/documents.js +305 -22
  29. package/dist/documents.js.map +1 -1
  30. package/dist/export-guest.d.ts.map +1 -1
  31. package/dist/export-guest.js +5 -0
  32. package/dist/export-guest.js.map +1 -1
  33. package/dist/handle-types.d.ts +23 -0
  34. package/dist/handle-types.d.ts.map +1 -1
  35. package/dist/handles.d.ts +1 -1
  36. package/dist/handles.d.ts.map +1 -1
  37. package/dist/handles.js +1 -1
  38. package/dist/handles.js.map +1 -1
  39. package/dist/hosted-ui.d.ts +10 -0
  40. package/dist/hosted-ui.d.ts.map +1 -1
  41. package/dist/hosted-ui.js +115 -30
  42. package/dist/hosted-ui.js.map +1 -1
  43. package/dist/importer.d.ts.map +1 -1
  44. package/dist/importer.js +17 -6
  45. package/dist/importer.js.map +1 -1
  46. package/dist/index.d.ts +8 -3
  47. package/dist/index.d.ts.map +1 -1
  48. package/dist/index.js +25 -1
  49. package/dist/index.js.map +1 -1
  50. package/dist/internal.d.ts +1 -0
  51. package/dist/internal.d.ts.map +1 -1
  52. package/dist/internal.js +1 -0
  53. package/dist/internal.js.map +1 -1
  54. package/dist/mutate-batching.js +2 -2
  55. package/dist/mutate-batching.js.map +1 -1
  56. package/dist/open-document.d.ts +1 -1
  57. package/dist/open-document.d.ts.map +1 -1
  58. package/dist/open-document.js +69 -34
  59. package/dist/open-document.js.map +1 -1
  60. package/dist/presence.d.ts +10 -0
  61. package/dist/presence.d.ts.map +1 -1
  62. package/dist/presence.js.map +1 -1
  63. package/dist/session-toolbar.d.ts +9 -0
  64. package/dist/session-toolbar.d.ts.map +1 -1
  65. package/dist/session-toolbar.js +60 -12
  66. package/dist/session-toolbar.js.map +1 -1
  67. package/dist/tail-ops.js +2 -2
  68. package/dist/tail-ops.js.map +1 -1
  69. package/dist/types.d.ts +0 -42
  70. package/dist/types.d.ts.map +1 -1
  71. package/dist/undo.d.ts +1 -1
  72. package/dist/undo.d.ts.map +1 -1
  73. package/dist/undo.js +1 -1
  74. package/dist/undo.js.map +1 -1
  75. package/dist/version-bar.d.ts.map +1 -1
  76. package/dist/version-bar.js +52 -2
  77. package/dist/version-bar.js.map +1 -1
  78. package/dist/version.d.ts +2 -2
  79. package/dist/version.js +1 -1
  80. package/dist/wasm-ready-gate.d.ts +13 -0
  81. package/dist/wasm-ready-gate.d.ts.map +1 -0
  82. package/dist/wasm-ready-gate.js +28 -0
  83. package/dist/wasm-ready-gate.js.map +1 -0
  84. package/dist/wasm-runtime.browser.d.ts +11 -0
  85. package/dist/wasm-runtime.browser.d.ts.map +1 -0
  86. package/dist/wasm-runtime.browser.js +44 -0
  87. package/dist/wasm-runtime.browser.js.map +1 -0
  88. package/dist/wasm-runtime.d.ts +6 -0
  89. package/dist/wasm-runtime.d.ts.map +1 -0
  90. package/dist/wasm-runtime.js +20 -0
  91. package/dist/wasm-runtime.js.map +1 -0
  92. package/docs/app-manifest.md +335 -5
  93. package/docs/custom-chrome.md +3 -0
  94. package/docs/presence.md +12 -0
  95. package/docs/services.md +265 -0
  96. package/docs/text-fields.md +191 -0
  97. package/package.json +4 -5
  98. package/src/acting-organization.ts +390 -0
  99. package/src/api-headers.ts +7 -7
  100. package/src/call-service.ts +354 -0
  101. package/src/client.ts +680 -14
  102. package/src/document-contents.ts +104 -0
  103. package/src/document-streams.ts +15 -1
  104. package/src/documents.ts +340 -20
  105. package/src/export-guest.ts +5 -0
  106. package/src/handle-types.ts +35 -0
  107. package/src/handles.ts +1 -1
  108. package/src/hosted-ui.ts +155 -29
  109. package/src/importer.ts +17 -6
  110. package/src/index.ts +43 -4
  111. package/src/internal.ts +4 -0
  112. package/src/mutate-batching.ts +2 -2
  113. package/src/open-document.ts +79 -38
  114. package/src/presence.ts +6 -0
  115. package/src/session-toolbar.ts +80 -12
  116. package/src/tail-ops.ts +2 -2
  117. package/src/types.ts +6 -4
  118. package/src/undo.ts +1 -1
  119. package/src/version-bar.ts +53 -2
  120. package/src/version.ts +1 -1
  121. package/src/wasm-ready-gate.ts +30 -0
  122. package/src/wasm-runtime.browser.ts +48 -0
  123. package/src/wasm-runtime.ts +21 -0
  124. package/dist/vite.d.ts +0 -31
  125. package/dist/vite.d.ts.map +0 -1
  126. package/dist/vite.js +0 -161
  127. package/dist/vite.js.map +0 -1
  128. package/src/vite.ts +0 -214
@@ -0,0 +1,265 @@
1
+ # Calling an outside service
2
+
3
+ The README's [Calling an outside service](../README.md#calling-an-outside-service)
4
+ covers how to make a call. This page covers what each refusal code means,
5
+ how to read values only the user knows, and which host to pass for a service
6
+ split across several addresses. [`app-manifest.md`](./app-manifest.md) covers
7
+ declaring a host and its authentication method.
8
+
9
+ ## What comes back
10
+
11
+ `callService` returns the service's response unchanged, within these limits:
12
+
13
+ - Text only, up to 5 MB, within 25 seconds.
14
+ - No streaming. A streaming endpoint's events all arrive together at the end.
15
+ - For binary data, call an endpoint that returns a URL or base64.
16
+ - Brass does not follow redirects. A `3xx` is returned as
17
+ `response_redirected`, with the service's `location` in the body.
18
+
19
+ `options.method` defaults to `'POST'`.
20
+
21
+ ## Refusal codes
22
+
23
+ When Brass refuses a call rather than sending it, the response body has a
24
+ `code`:
25
+
26
+ ```js
27
+ const res = await Brass.callService(host, path);
28
+ if (!res.ok) {
29
+ const { code } = await res.json();
30
+ if (code === 'credential_required') await Brass.connectService(host);
31
+ }
32
+ ```
33
+
34
+ Each code always comes with the same HTTP status, so branching on the code
35
+ is enough. Two answers carry no code, and both mean the hop to the service
36
+ itself did not complete: a `502` where Brass could not reach the service, and
37
+ a `504` where the service did not answer in time. Other `502`s do carry a
38
+ code, so read the body before treating one as unreachable.
39
+
40
+ **Somebody using your app can supply what is missing.** Call
41
+ `Brass.connectService(host)`. The dialog asks whoever opened it for whatever
42
+ the host needs, and tells anyone who cannot answer who to ask.
43
+
44
+ | `code` | What it means |
45
+ | --------------------- | --------------------------------------------------------------------------------------------------------- |
46
+ | `credential_required` | No credential is connected for this host, or the one connected is not approved for your app, no longer covers what your app sends, or carries no value under a name your app reads back. One code, because the dialog is the answer to all of them. The body's sentence says which, for a person to read. |
47
+
48
+ **Your own key is what is missing.** These are yours to settle on your app's
49
+ settings page, so there is nothing to raise on the page: report the feature as
50
+ unavailable.
51
+
52
+ | `code` | What it means |
53
+ | ---------------------------- | --------------------------------------------------------------------------------------------------------- |
54
+ | `app_credential_not_set` | You have not entered a key for this service yet. This is the refusal you get by default, until you enter one or hand the service over for whoever runs your app to connect. |
55
+ | `app_credential_incomplete` | Your own key carries no value under a name your manifest asks for. |
56
+
57
+ **A stored credential is there and does not work.**
58
+
59
+ | `code` | What it means |
60
+ | ---------------------------- | --------------------------------------------------------------------------------------------------------- |
61
+ | `credential_rejected` | The service rejected the stored credential. |
62
+ | `credential_scheme_unusable` | The host's credential is not usable as configured. |
63
+ | `credential_exchange_failed` | Brass could not reach the service for a token, or got back something that was not one. |
64
+ | `credential_unreadable` | Brass cannot read the credential stored here. Enter it again. |
65
+
66
+ **The call itself is not allowed.** Fix these in your app.
67
+
68
+ | `code` | What it means |
69
+ | ------------------------- | --------------------------------------------------------------------------------------------------------- |
70
+ | `service_not_declared` | Your manifest does not declare that host, or your latest publish has not registered it. |
71
+ | `path_invalid` | The path names its own host, escapes the service with `..`, or cannot be decoded. |
72
+ | `service_headers_invalid` | A header in `options.headers` is one HTTP itself controls (`content-length`, `host`, `connection` and the like), or its name or value cannot go on a header line. |
73
+ | `host_not_public` | The host does not resolve to a public IP address. |
74
+
75
+ **The call is allowed but cannot go through right now.** Some of these clear
76
+ on their own.
77
+
78
+ | `code` | What it means |
79
+ | -------------------- | -------------------------------------------------------------------------------------- |
80
+ | `cap_exceeded` | A cap has been reached. Try again later. |
81
+ | `rate_limited` | Over Brass's own rate limit. Slow down. |
82
+ | `calls_paused` | Whoever owns the account being spent set its cap to zero. Waiting will not clear it. |
83
+ | `vault_unavailable` | Brass could not read the stored key just now. Nothing is wrong with it; make the same call again. |
84
+
85
+ **Brass could not return the response.**
86
+
87
+ | `code` | What it means |
88
+ | -------------------- | --------------------------------------------------------------------- |
89
+ | `response_too_large` | The service returned more than 5 MB. |
90
+ | `response_not_text` | The service returned bytes rather than text. Call an endpoint that returns a URL or base64. |
91
+ | `response_unreadable`| Brass could not read the service's response. |
92
+ | `response_redirected`| The service answered with a redirect. The body carries its `location`. |
93
+ | `service_timeout` | The service did not answer in time. |
94
+ | `service_unreachable`| Brass could not reach the service. |
95
+
96
+ **The app is not the one that may call.** These are about your app's standing
97
+ rather than the credential.
98
+
99
+ | `code` | What it means |
100
+ | -------------------- | --------------------------------------------------------------------- |
101
+ | `app_not_installed` | The organization has not installed your app. `requestMissingCredentials: true` raises the install dialog; otherwise the call returns `403`. |
102
+ | `no_acting_app` | The caller is not acting as an app. |
103
+
104
+ A call made before the app has an organization returns `409 destination_required`.
105
+
106
+ ## Asking for a missing credential
107
+
108
+ `Brass.listServices()` returns each declared host, with `ready: true` where
109
+ Brass has a credential for it. It shows no dialog, so you can call it while
110
+ the page loads. It returns `null` while the app is acting nowhere: nobody is
111
+ signed in, the app has no organization, or it is running in one that has not
112
+ installed it.
113
+
114
+ Each entry also carries `connectable`, which says whether the dialog has
115
+ anything for the person on the page to do at that host. Together the two are
116
+ the whole branch:
117
+
118
+ ```js
119
+ const services = await Brass.listServices();
120
+ const ai = services?.find((s) => s.host === 'api.anthropic.com');
121
+ if (ai?.ready) showSummariseButton();
122
+ else if (ai?.connectable) showConnectCard();
123
+ else showUnavailableCard();
124
+ ```
125
+
126
+ `Brass.connectService(host)` opens the dialog and resolves true once that
127
+ host works. Whoever can supply the credential does it there; anyone else is
128
+ told who to ask. Put it on a button, so the user is asked when they reach for
129
+ the feature:
130
+
131
+ ```js
132
+ if (await Brass.connectService('api.anthropic.com')) showSummariseButton();
133
+ ```
134
+
135
+ Passing `requestMissingCredentials: true` to `callService` opens the same
136
+ dialog from the call, which then goes through once the user answers. It covers
137
+ every refusal the person using your app can answer: `credential_required`, plus
138
+ `credential_rejected` and `credential_scheme_unusable` when the account that
139
+ failed is one they own.
140
+
141
+ A host still waiting for the key you enter as its author
142
+ (`app_credential_not_set`, `app_credential_incomplete`) is yours to settle on
143
+ the app's own settings page, so the call returns that refusal for you to
144
+ handle:
145
+
146
+ ```js
147
+ const res = await Brass.callService(host, path, { requestMissingCredentials: true });
148
+ ```
149
+
150
+ ## Who supplies the credential
151
+
152
+ You choose on your app's settings page whether a host runs on a key of yours
153
+ or on an account whoever runs your app connects, and they can change that
154
+ under you. A host you have just declared starts on a key of yours, so
155
+ `connectable` is false there until you change it. Either way your call is the
156
+ same one: you name a host, and Brass attaches whichever credential is in
157
+ force.
158
+
159
+ Your app reads `connectable` rather than the supplier, because that is the
160
+ split it can act on. Where it is true, the person using your app can
161
+ settle it, so a Connect button leads somewhere; where it is false, the host is
162
+ waiting on you, so there is nothing to raise. Leave the wording about who to
163
+ ask to the dialog, which knows both the supplier and who is reading, where
164
+ your app is told neither.
165
+
166
+ ## Values the app reads
167
+
168
+ Your app sometimes has to include a value in the request it sends, such as
169
+ an account id. Mark that field `readable` in the manifest and Brass returns
170
+ it to your app with the service.
171
+
172
+ Brass collects the value when somebody connects an account, and offers your app
173
+ only the accounts carrying it, so it is there whenever a call reaches your code.
174
+
175
+ The field's name is the key your app reads the value back under.
176
+
177
+ ```json
178
+ {
179
+ "services": [
180
+ {
181
+ "host": "api.cloudflare.com",
182
+ "headers": { "authorization": "Bearer {token}" },
183
+ "fields": [{ "name": "account_id", "readable": true, "label": "Account ID" }]
184
+ }
185
+ ]
186
+ }
187
+ ```
188
+
189
+ ```js
190
+ const services = await Brass.listServices();
191
+ const cf = services?.find((s) => s.host === 'api.cloudflare.com');
192
+ const account = cf?.values?.account_id;
193
+ if (cf?.ready && account) {
194
+ const res = await Brass.callService(
195
+ 'api.cloudflare.com',
196
+ `client/v4/accounts/${account}/tokens/verify`,
197
+ { method: 'GET' },
198
+ );
199
+ }
200
+ ```
201
+
202
+ `values` is present on a `ready` service.
203
+
204
+ ## Providing credential setup instructions
205
+
206
+ Brass shows your setup instructions on the form that collects a credential,
207
+ and you are strongly encouraged to provide them. Without them the form shows
208
+ your field labels alone, and the person filling it in has to work out what
209
+ to create at the vendor and where.
210
+
211
+ Declare a `setup` beside the host:
212
+
213
+ ```json
214
+ {
215
+ "services": [
216
+ {
217
+ "host": "api.example.com",
218
+ "headers": { "authorization": "Bearer {secret}" },
219
+ "setup": {
220
+ "credentialName": "API key",
221
+ "consoleUrl": "https://example.com/settings/keys",
222
+ "docsUrl": "https://example.com/docs/api",
223
+ "steps": [
224
+ "Open Settings, then API keys.",
225
+ "Create a key with read and write access.",
226
+ "Copy the key before closing the dialog."
227
+ ]
228
+ }
229
+ }
230
+ ]
231
+ }
232
+ ```
233
+
234
+ - **`credentialName`** is what the vendor calls the credential, so it
235
+ matches what they see there.
236
+ - **`consoleUrl`** is the page where they create it. Link to that page
237
+ rather than the vendor's home page.
238
+ - **`docsUrl`** is where the vendor documents this method.
239
+ - **`steps`** are step-by-step instructions for creating the credential.
240
+ Up to six, shown in order.
241
+
242
+ ## A service split across addresses
243
+
244
+ A wildcard host (`*.myshopify.com`) covers every address under it;
245
+ [`app-manifest.md`](./app-manifest.md) covers declaring one. Which `host` you
246
+ pass to `callService` depends on `credentialPerHost`.
247
+
248
+ Without it, one credential covers every address under the suffix, and
249
+ `listServices` returns a single entry under the declared name. Match on the
250
+ wildcard, not on the address you are about to call.
251
+
252
+ With it, each secret is entered against a specific address, and
253
+ `listServices` returns one entry per connected address, each with
254
+ `declared_as: '*.myshopify.com'`. Pass the `host` from one of those entries.
255
+ An address you build from a shop name points at a shop nobody has connected.
256
+ Before any address is connected there is one entry for the wildcard itself,
257
+ and `Brass.connectService` takes that `host`:
258
+
259
+ ```js
260
+ const shops = ((await Brass.listServices()) ?? []).filter(
261
+ (s) => s.declared_as === '*.myshopify.com',
262
+ );
263
+ const connected = shops.filter((s) => s.ready);
264
+ if (connected.length === 0) await Brass.connectService('*.myshopify.com');
265
+ ```
@@ -0,0 +1,191 @@
1
+ # Text fields
2
+
3
+ `doc.text(key)` is the container for prose several people edit at once. The
4
+ [README](../README.md#prose-several-people-edit) covers what it is and how
5
+ to wire a field to it. This page covers what a real editor needs on top:
6
+ painting a peer's edit without throwing away the caret, the IME window,
7
+ what a keystroke costs, and where a text container belongs in a document.
8
+
9
+ ## Paint a peer's edit without losing the caret
10
+
11
+ Assigning `field.value` moves the caret to the end, so an editor that
12
+ assigns unconditionally throws away the reader's place on every peer
13
+ keystroke, and one that skips a focused field never shows a peer's typing
14
+ to anyone who is typing themselves. Find the span the incoming value
15
+ replaced, and move the caret across it:
16
+
17
+ ```ts
18
+ const report = doc.text('report');
19
+ let painted = report.read();
20
+ field.value = painted;
21
+
22
+ report.subscribe((value, origin) => {
23
+ if (origin?.source === 'local') { painted = value; return; }
24
+ if (value === painted) return;
25
+
26
+ // The one span that differs, found by trimming the common prefix and
27
+ // the common suffix. A keystroke, a paste, and a peer's edit each look
28
+ // like this.
29
+ const max = Math.min(painted.length, value.length);
30
+ let start = 0;
31
+ while (start < max && painted.charCodeAt(start) === value.charCodeAt(start)) start += 1;
32
+ let tail = 0;
33
+ while (tail < max - start
34
+ && painted.charCodeAt(painted.length - 1 - tail)
35
+ === value.charCodeAt(value.length - 1 - tail)) tail += 1;
36
+ const removed = painted.length - tail - start;
37
+ const inserted = value.length - tail - start;
38
+
39
+ // The caret binds to the text on its right, so a peer typing where it
40
+ // sits pushes it past what they wrote and leaves it in front of the same
41
+ // words. One before the span does not move, one after it shifts by the
42
+ // span's change in length.
43
+ const carry = (caret: number): number =>
44
+ caret < start ? caret
45
+ : caret >= start + removed ? caret + inserted - removed
46
+ : start + Math.min(caret - start, inserted);
47
+
48
+ // A reader with text selected has two offsets, and `selectionDirection`
49
+ // says which of them their next keystroke extends from.
50
+ const focused = document.activeElement === field;
51
+ const anchor = carry(field.selectionStart ?? 0);
52
+ const head = carry(field.selectionEnd ?? field.selectionStart ?? 0);
53
+ const direction = field.selectionDirection;
54
+ // Assigning `value` resets the scroll, which throws a reader who is
55
+ // partway down a long field back to the top on every peer keystroke.
56
+ const scroll = field.scrollTop;
57
+
58
+ field.value = value;
59
+ painted = value;
60
+ field.scrollTop = scroll;
61
+ if (focused) {
62
+ field.setSelectionRange(nudgeOffPair(value, anchor), nudgeOffPair(value, head), direction);
63
+ }
64
+ });
65
+ ```
66
+
67
+ Offsets count UTF-16 code units, which is what a text field's
68
+ `selectionStart` reports, so the two agree with no conversion. The same
69
+ units are why an offset needs one more check before it is used: a character
70
+ outside the Basic Multilingual Plane, an emoji above all, is two code units,
71
+ and the `charCodeAt` walk above stops between them whenever the edit sits
72
+ next to one. `nudgeOffPair` moves such an offset off the seam:
73
+
74
+ ```ts
75
+ function nudgeOffPair(text: string, at: number): number {
76
+ const bounded = Math.max(0, Math.min(at, text.length));
77
+ if (bounded <= 0 || bounded >= text.length) return bounded;
78
+ const before = text.charCodeAt(bounded - 1);
79
+ const after = text.charCodeAt(bounded);
80
+ const splits = before >= 0xd800 && before <= 0xdbff && after >= 0xdc00 && after <= 0xdfff;
81
+ return splits ? bounded - 1 : bounded;
82
+ }
83
+ ```
84
+
85
+ Restoring the selection only while the field is focused keeps the paint
86
+ from setting a selection nobody asked for, and from scrolling an offscreen
87
+ field into view.
88
+
89
+ `origin` carries who wrote the change, the way a map subscriber's does:
90
+ `local`, `remote`, `undo`, or `redo`, and undefined on a fire describing no
91
+ authored transition (registration, and the catch-up a version checkout
92
+ produces). Splitting on `local` is what keeps this work off the typing
93
+ path, where it would otherwise run on every keystroke.
94
+
95
+ ## Defer the paint, not the write, while an IME is open
96
+
97
+ Assigning to the field while an IME is open discards what it is holding,
98
+ which costs a reader typing Japanese, Chinese, or Korean the word they were
99
+ in the middle of. So track `compositionstart` / `compositionend` and skip
100
+ the paint in between.
101
+
102
+ Keep writing through `update` the whole time. `input` fires during a
103
+ composition, the write touches no DOM, and the container merges it with
104
+ whatever a peer sends meanwhile:
105
+
106
+ ```ts
107
+ let composing = false;
108
+ let missed = false;
109
+
110
+ field.addEventListener('compositionstart', () => { composing = true; });
111
+ field.addEventListener('compositionend', () => {
112
+ composing = false;
113
+ painted = field.value;
114
+ report.update(painted);
115
+ // What arrived while the composition was open is older than what the
116
+ // reader just committed, so paint the container's own value, which
117
+ // carries both.
118
+ if (missed) {
119
+ missed = false;
120
+ const latest = report.read();
121
+ if (latest !== painted) paintPeerEdit(latest);
122
+ }
123
+ });
124
+ ```
125
+
126
+ Holding the incoming value and applying it at `compositionend` is the
127
+ version that looks right and loses data: that value predates the
128
+ composition, so painting it discards the word the reader just committed.
129
+ Deferring the write instead has the same shape of bug one layer down, since
130
+ the field's value then has no record of the peer's edit and `update` diffs
131
+ it back out.
132
+
133
+ ## Publish the caret too
134
+
135
+ `state.textCaret` in [`presence.md`](./presence.md) is
136
+ `{ key, anchor, head }`: the container the caret sits in, and its two
137
+ offsets in the same UTF-16 code units this page uses. Publishing it on
138
+ every selection change lets peers draw where you are working, and the 10 Hz
139
+ presence debounce already paces it.
140
+
141
+ ## What a keystroke costs
142
+
143
+ `update` compares against the whole stored string to find the smallest
144
+ edit, so its cost scales with the field's own length. Measured through the
145
+ handle on a 2.1 GHz core, after 20,000 edits, typing at a realistic
146
+ revision pattern:
147
+
148
+ | field | ms per keystroke |
149
+ | --- | --- |
150
+ | 20 KB | 0.72 |
151
+ | 100 KB | 1.72 |
152
+ | 300 KB | 4.09 |
153
+
154
+ One paragraph, one description, or one report is far inside a frame at
155
+ typing speed. A field holding a book is not, and an app whose document
156
+ grows that large wants its prose split across containers, one per section,
157
+ so an edit compares against a section rather than the book.
158
+
159
+ A second, smaller term grows with the document's accumulated edit history,
160
+ because materializing a text container costs more as its rope fragments.
161
+ Composing sentences barely fragments it (20,000 keystrokes on a 20 KB field
162
+ stay at 0.46 ms and leave a 2 KB snapshot); revising in short runs across
163
+ the field reaches 0.64 ms. The history term is permanent rather than
164
+ per-session, since a snapshot carries the history.
165
+
166
+ ## One root per document-shaped thing, not per row
167
+
168
+ A text container is a top-level root, and no nested-text surface exists, so
169
+ per-element prose that merges costs one root per element. Loro prices a
170
+ container against how many the document holds, so that does not stay flat
171
+ the way the record store does, where paging holds container count down past
172
+ `UNPAGED_MAX_RECORDS`. Creating one text root per element against the same
173
+ elements written as records:
174
+
175
+ | elements | a root each | as records | roots heap | records heap |
176
+ | --- | --- | --- | --- | --- |
177
+ | 5,000 | 599 ms | 403 ms | 27 MB | 3 MB |
178
+ | 10,000 | 1,460 ms | 182 ms | 52 MB | 2 MB |
179
+ | 20,000 | 9,318 ms | 404 ms | 104 MB | 3 MB |
180
+ | 40,000 | 26,419 ms | 1,206 ms | 194 MB | 10 MB |
181
+
182
+ Registering a subscriber costs the same per root, so an editor that shows
183
+ every row's note pays that cost again on every open.
184
+
185
+ So prose that belongs to each row of a repeated collection stays in the
186
+ row's record, where the row is the merge unit. Two people typing in one
187
+ row's note then keep one of the two, which is the trade a repeated
188
+ collection makes. Where one particular row is the thing people write
189
+ together, give that row's prose a root of its own and leave the rest in the
190
+ store. Roots in the hundreds are cheap; the curve is what decides, not a
191
+ fixed cap.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brass-build/client",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
4
4
  "description": "Client SDK for Brass",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -11,16 +11,15 @@
11
11
  "types": "./dist/index.d.ts",
12
12
  "import": "./dist/index.js"
13
13
  },
14
- "./vite": {
15
- "types": "./dist/vite.d.ts",
16
- "import": "./dist/vite.js"
17
- },
18
14
  "./internal": {
19
15
  "types": "./dist/internal.d.ts",
20
16
  "import": "./dist/internal.js"
21
17
  },
22
18
  "./package.json": "./package.json"
23
19
  },
20
+ "browser": {
21
+ "./dist/wasm-runtime.js": "./dist/wasm-runtime.browser.js"
22
+ },
24
23
  "files": [
25
24
  "dist",
26
25
  "src",