@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.
- package/AGENTS.md +50 -7
- package/CHANGELOG.md +63 -2
- package/README.md +233 -77
- package/dist/acting-organization.d.ts +131 -0
- package/dist/acting-organization.d.ts.map +1 -0
- package/dist/acting-organization.js +273 -0
- package/dist/acting-organization.js.map +1 -0
- package/dist/api-headers.js +7 -7
- package/dist/api-headers.js.map +1 -1
- package/dist/call-service.d.ts +74 -0
- package/dist/call-service.d.ts.map +1 -0
- package/dist/call-service.js +253 -0
- package/dist/call-service.js.map +1 -0
- package/dist/client.d.ts +31 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +611 -12
- package/dist/client.js.map +1 -1
- package/dist/document-contents.d.ts +15 -0
- package/dist/document-contents.d.ts.map +1 -0
- package/dist/document-contents.js +88 -0
- package/dist/document-contents.js.map +1 -0
- package/dist/document-streams.d.ts +4 -2
- package/dist/document-streams.d.ts.map +1 -1
- package/dist/document-streams.js +6 -0
- package/dist/document-streams.js.map +1 -1
- package/dist/documents.d.ts +45 -1
- package/dist/documents.d.ts.map +1 -1
- package/dist/documents.js +305 -22
- package/dist/documents.js.map +1 -1
- package/dist/export-guest.d.ts.map +1 -1
- package/dist/export-guest.js +5 -0
- package/dist/export-guest.js.map +1 -1
- package/dist/handle-types.d.ts +23 -0
- package/dist/handle-types.d.ts.map +1 -1
- package/dist/handles.d.ts +1 -1
- package/dist/handles.d.ts.map +1 -1
- package/dist/handles.js +1 -1
- package/dist/handles.js.map +1 -1
- package/dist/hosted-ui.d.ts +10 -0
- package/dist/hosted-ui.d.ts.map +1 -1
- package/dist/hosted-ui.js +115 -30
- package/dist/hosted-ui.js.map +1 -1
- package/dist/importer.d.ts.map +1 -1
- package/dist/importer.js +17 -6
- package/dist/importer.js.map +1 -1
- package/dist/index.d.ts +8 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +25 -1
- package/dist/index.js.map +1 -1
- package/dist/internal.d.ts +1 -0
- package/dist/internal.d.ts.map +1 -1
- package/dist/internal.js +1 -0
- package/dist/internal.js.map +1 -1
- package/dist/mutate-batching.js +2 -2
- package/dist/mutate-batching.js.map +1 -1
- package/dist/open-document.d.ts +1 -1
- package/dist/open-document.d.ts.map +1 -1
- package/dist/open-document.js +69 -34
- package/dist/open-document.js.map +1 -1
- package/dist/presence.d.ts +10 -0
- package/dist/presence.d.ts.map +1 -1
- package/dist/presence.js.map +1 -1
- package/dist/session-toolbar.d.ts +9 -0
- package/dist/session-toolbar.d.ts.map +1 -1
- package/dist/session-toolbar.js +60 -12
- package/dist/session-toolbar.js.map +1 -1
- package/dist/tail-ops.js +2 -2
- package/dist/tail-ops.js.map +1 -1
- package/dist/types.d.ts +0 -42
- package/dist/types.d.ts.map +1 -1
- package/dist/undo.d.ts +1 -1
- package/dist/undo.d.ts.map +1 -1
- package/dist/undo.js +1 -1
- package/dist/undo.js.map +1 -1
- package/dist/version-bar.d.ts.map +1 -1
- package/dist/version-bar.js +52 -2
- package/dist/version-bar.js.map +1 -1
- package/dist/version.d.ts +2 -2
- package/dist/version.js +1 -1
- package/dist/wasm-ready-gate.d.ts +13 -0
- package/dist/wasm-ready-gate.d.ts.map +1 -0
- package/dist/wasm-ready-gate.js +28 -0
- package/dist/wasm-ready-gate.js.map +1 -0
- package/dist/wasm-runtime.browser.d.ts +11 -0
- package/dist/wasm-runtime.browser.d.ts.map +1 -0
- package/dist/wasm-runtime.browser.js +44 -0
- package/dist/wasm-runtime.browser.js.map +1 -0
- package/dist/wasm-runtime.d.ts +6 -0
- package/dist/wasm-runtime.d.ts.map +1 -0
- package/dist/wasm-runtime.js +20 -0
- package/dist/wasm-runtime.js.map +1 -0
- package/docs/app-manifest.md +335 -5
- package/docs/custom-chrome.md +3 -0
- package/docs/presence.md +12 -0
- package/docs/services.md +265 -0
- package/docs/text-fields.md +191 -0
- package/package.json +4 -5
- package/src/acting-organization.ts +390 -0
- package/src/api-headers.ts +7 -7
- package/src/call-service.ts +354 -0
- package/src/client.ts +680 -14
- package/src/document-contents.ts +104 -0
- package/src/document-streams.ts +15 -1
- package/src/documents.ts +340 -20
- package/src/export-guest.ts +5 -0
- package/src/handle-types.ts +35 -0
- package/src/handles.ts +1 -1
- package/src/hosted-ui.ts +155 -29
- package/src/importer.ts +17 -6
- package/src/index.ts +43 -4
- package/src/internal.ts +4 -0
- package/src/mutate-batching.ts +2 -2
- package/src/open-document.ts +79 -38
- package/src/presence.ts +6 -0
- package/src/session-toolbar.ts +80 -12
- package/src/tail-ops.ts +2 -2
- package/src/types.ts +6 -4
- package/src/undo.ts +1 -1
- package/src/version-bar.ts +53 -2
- package/src/version.ts +1 -1
- package/src/wasm-ready-gate.ts +30 -0
- package/src/wasm-runtime.browser.ts +48 -0
- package/src/wasm-runtime.ts +21 -0
- package/dist/vite.d.ts +0 -31
- package/dist/vite.d.ts.map +0 -1
- package/dist/vite.js +0 -161
- package/dist/vite.js.map +0 -1
- package/src/vite.ts +0 -214
package/docs/services.md
ADDED
|
@@ -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
|
+
"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",
|