@brass-build/client 0.3.0 → 0.4.0
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 +26 -5
- package/CHANGELOG.md +50 -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 +278 -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 +28 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +555 -11
- 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 +396 -0
- package/src/api-headers.ts +7 -7
- package/src/call-service.ts +354 -0
- package/src/client.ts +618 -13
- 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/AGENTS.md
CHANGED
|
@@ -49,7 +49,13 @@ With an example in hand: the user adds it at their Brass dashboard
|
|
|
49
49
|
(`https://app.brass.build`) and sends you back the document's link,
|
|
50
50
|
and you read what that document holds. A resolved schema is your
|
|
51
51
|
record contract: pull it and adopt it verbatim
|
|
52
|
-
(`npx @brass-build/cli schema pull --doc <docId>`).
|
|
52
|
+
(`npx @brass-build/cli schema pull --doc <docId>`). Read the records too,
|
|
53
|
+
before you write code against them
|
|
54
|
+
(`npx @brass-build/cli contents --doc <docId>` writes the whole document as
|
|
55
|
+
JSON): the schema names the fields and the data tells you what is in them.
|
|
56
|
+
Read all of it rather than the first few records, since a file's records are
|
|
57
|
+
grouped by type and its opening records carry a couple of the types it holds
|
|
58
|
+
and none of the rest. A stored file with
|
|
53
59
|
no schema means your app is its first importer: register your parse
|
|
54
60
|
half with `Brass.registerImporter`, and declare the schema of the
|
|
55
61
|
records you write in `brass-app.json` so the next app can adopt it.
|
|
@@ -225,13 +231,16 @@ It inspects the credential and app state for this environment and ends
|
|
|
225
231
|
in a `Next:` line naming the one command to run. Follow that line, then
|
|
226
232
|
run `brass status` again, for the rest of this section.
|
|
227
233
|
|
|
228
|
-
|
|
229
|
-
|
|
234
|
+
These steps need the human's Brass account, and you cannot stand in for
|
|
235
|
+
them. Drive each to completion rather than noting it and stopping:
|
|
230
236
|
|
|
231
237
|
- **Getting an app id.** `Brass.init` needs the `app_id` of a
|
|
232
238
|
registered app. Run the first `brass publish` (below): it creates the
|
|
233
|
-
app and
|
|
234
|
-
|
|
239
|
+
app and reports the id, which `--json` gives you as `app_id` for your
|
|
240
|
+
own parsing, and you pass that to `Brass.init('brass_app_...')`. The
|
|
241
|
+
publish also writes `.brass/project.json`, which is the CLI's own
|
|
242
|
+
state: commit it so re-runs target the same app, and read the id from
|
|
243
|
+
the publish result rather than out of that file. A new app already allows sign-in
|
|
235
244
|
from `localhost`, so once you have the id, `Brass.signIn()` works in
|
|
236
245
|
local development with no further setup. (Or the human creates the app
|
|
237
246
|
on the dashboard and gives you the id.)
|
|
@@ -241,6 +250,15 @@ it. Drive both to completion rather than noting them and stopping:
|
|
|
241
250
|
box it prints a URL and short code the human approves from any device.
|
|
242
251
|
Running it and getting the human through approval is your step. For CI
|
|
243
252
|
the human supplies `BRASS_SERVICE_TOKEN` instead.
|
|
253
|
+
- **A key for every service you declared.** An app that declares
|
|
254
|
+
`services` in `brass-app.json` calls those hosts through Brass, which
|
|
255
|
+
holds the credential, and the credential comes from the provider's own
|
|
256
|
+
console. `brass publish` names each host still waiting and the page
|
|
257
|
+
that takes its key, in the log and in `warnings` under `--json`. Give
|
|
258
|
+
the human that line as it stands: it carries the host and a link
|
|
259
|
+
straight to the page. Until they paste it, `Brass.callService` answers
|
|
260
|
+
`409` with `code: 'app_credential_not_set'` for that host and the app
|
|
261
|
+
cannot reach it.
|
|
244
262
|
|
|
245
263
|
Once `brass status` reports a credential, publish:
|
|
246
264
|
|
|
@@ -257,6 +275,9 @@ done:
|
|
|
257
275
|
- **signed in**: `brass status` confirms a credential.
|
|
258
276
|
- **published**: `brass publish` reported a URL.
|
|
259
277
|
- **opened**: you loaded that URL and confirmed sign-in works there.
|
|
278
|
+
- **keyed**: `brass publish` named no host as still waiting for a key. A
|
|
279
|
+
host it named is an open item like any other, so offer the link and
|
|
280
|
+
wait rather than reporting the integration done around it.
|
|
260
281
|
|
|
261
282
|
An open item is the next thing to offer. When publishing is waiting only
|
|
262
283
|
on the human's sign-in, say so and offer to walk them through
|
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,55 @@ and the package follows [semantic versioning](https://semver.org/spec/v2.0.0.htm
|
|
|
6
6
|
While the version is below `1.0.0`, minor releases may introduce
|
|
7
7
|
breaking changes.
|
|
8
8
|
|
|
9
|
+
## 0.4.0
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- Call a third-party API without your app ever holding its credential.
|
|
14
|
+
Declare the host under `services` in `brass-app.json`, and
|
|
15
|
+
`Brass.callService(host, path, options)` adds the credential and returns the
|
|
16
|
+
service's response unchanged. `Brass.listServices()` reports which hosts
|
|
17
|
+
have a credential (`ready`) and which of the rest the person using your app
|
|
18
|
+
can connect (`connectable`), so you can show, offer or hide a feature before
|
|
19
|
+
anyone clicks it, and `Brass.connectService(host)` opens the dialog that
|
|
20
|
+
collects a missing one. See [`docs/services.md`](./docs/services.md).
|
|
21
|
+
- `doc.text(key)` stores text that several people edit at once, keeping every
|
|
22
|
+
edit rather than the last one written. Its subscriber gets the same `origin`
|
|
23
|
+
argument a map subscriber gets, and `PresenceState.textCaret` reports where
|
|
24
|
+
each person's caret is. See
|
|
25
|
+
[`docs/text-fields.md`](./docs/text-fields.md).
|
|
26
|
+
- `Brass.init` accepts `undefined` as the app id, so one bundle works both on
|
|
27
|
+
a Brass-hosted origin, which supplies the id, and on your own, which does
|
|
28
|
+
not: `Brass.init(import.meta.env.VITE_BRASS_APP_ID)`.
|
|
29
|
+
|
|
30
|
+
### Removed
|
|
31
|
+
|
|
32
|
+
- The `@brass-build/client/vite` subpath and its `brassVite()` plugin. The SDK
|
|
33
|
+
loads its WebAssembly engine with no build-time configuration, so `vite
|
|
34
|
+
build` and webpack need nothing. Vite's dev server needs
|
|
35
|
+
`optimizeDeps: { exclude: ['@brass-build/client'] }` in its config. See the
|
|
36
|
+
README's "Modules and assets".
|
|
37
|
+
|
|
38
|
+
### Fixed
|
|
39
|
+
|
|
40
|
+
- A document that cannot reach Brass reports the failure instead of accepting
|
|
41
|
+
edits it cannot save. The first write calls `Brass.onError` and sets
|
|
42
|
+
`doc.readOnly`, and a later `mutate` throws. Disable your editor on
|
|
43
|
+
`doc.readOnly`.
|
|
44
|
+
- Reopening a document whose import did not finish runs the import again.
|
|
45
|
+
- A `doc.stream(name)` open that fails closes its connection, instead of
|
|
46
|
+
leaving it open for the life of the page.
|
|
47
|
+
- A stream that reaches the platform's size ceiling goes read-only and reports
|
|
48
|
+
`stream.atCapacity`, rather than alerting the reader and reloading the whole
|
|
49
|
+
document a stream is only part of. The rest of the document keeps taking
|
|
50
|
+
writes; disable the stream's own bulk work on `stream.atCapacity`.
|
|
51
|
+
- Escape closes only the topmost hosted dialog. A toolbar command opened over
|
|
52
|
+
a picker used to report a dismissal to both.
|
|
53
|
+
- A hosted dialog on a slow connection no longer navigates the page away to
|
|
54
|
+
the standalone picker.
|
|
55
|
+
- A page whose signed-in user changes forgets the previous user's last
|
|
56
|
+
selection, instead of opening their document for the new one.
|
|
57
|
+
|
|
9
58
|
## 0.3.0
|
|
10
59
|
|
|
11
60
|
### Added
|
|
@@ -108,5 +157,4 @@ breaking changes.
|
|
|
108
157
|
Initial public release. The SDK turns a document into a live,
|
|
109
158
|
multi-user, persistent workspace: sign-in, the hosted file toolbar, and
|
|
110
159
|
a subscribe-render-mutate loop per open document. Ships as an ESM-only
|
|
111
|
-
package
|
|
112
|
-
bundler needs.
|
|
160
|
+
package.
|
package/README.md
CHANGED
|
@@ -30,6 +30,7 @@ npm install @brass-build/client
|
|
|
30
30
|
- [Presence](#presence-live-cursors-and-whos-here)
|
|
31
31
|
- [Streams](#streams-partitioning-large-documents)
|
|
32
32
|
- [Imported files](#imported-files)
|
|
33
|
+
- [Calling an outside service](#calling-an-outside-service)
|
|
33
34
|
- [Reference](#reference)
|
|
34
35
|
- [Guides](#guides)
|
|
35
36
|
|
|
@@ -45,37 +46,36 @@ integration.
|
|
|
45
46
|
Build a shared checklist on @brass-build/client (NPM).
|
|
46
47
|
```
|
|
47
48
|
|
|
48
|
-
**Or wire it by hand.**
|
|
49
|
+
**Or wire it by hand.** Six calls, and your own code around them:
|
|
49
50
|
|
|
50
51
|
```ts
|
|
51
52
|
import { Brass } from '@brass-build/client';
|
|
52
|
-
import type {
|
|
53
|
+
import type { Document } from '@brass-build/client';
|
|
53
54
|
|
|
54
|
-
// Register
|
|
55
|
+
// Register before init, so a bad app id reaches your handler.
|
|
55
56
|
Brass.onError((err) => console.error('[Brass]', err.message));
|
|
56
57
|
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
});
|
|
63
|
-
document.getElementById('sign-in')!.onclick = () => Brass.signIn();
|
|
58
|
+
// Fires when init resolves, then on every sign-in and sign-out. Show
|
|
59
|
+
// your app unless `Brass.authStatus` is 'signed-out': someone opening a
|
|
60
|
+
// share link has no user and still gets the document, read-only.
|
|
61
|
+
Brass.onUserChange(renderAuthState);
|
|
62
|
+
signInButton.onclick = () => Brass.signIn();
|
|
64
63
|
|
|
65
64
|
// The whole file menu in one call: document title, undo / redo,
|
|
66
65
|
// account menu, presence roster.
|
|
67
|
-
Brass.mountToolbar(
|
|
66
|
+
Brass.mountToolbar(appBar);
|
|
68
67
|
|
|
69
|
-
//
|
|
70
|
-
//
|
|
68
|
+
// Rebind your editor to whichever document the session opens. Compare
|
|
69
|
+
// the instance, not the id: a version preview reuses the id.
|
|
71
70
|
let bound: Document | null = null;
|
|
72
71
|
Brass.session.subscribe((session) => {
|
|
73
|
-
if (session.document
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
}
|
|
72
|
+
if (session.document === bound) return;
|
|
73
|
+
bound = session.document;
|
|
74
|
+
if (bound) bindEditor(bound); // see Editing documents
|
|
77
75
|
});
|
|
78
76
|
|
|
77
|
+
// Reported by your first `brass publish` (as `app_id` under --json), or
|
|
78
|
+
// on your app's page in the dashboard.
|
|
79
79
|
await Brass.init('brass_app_...');
|
|
80
80
|
```
|
|
81
81
|
|
|
@@ -85,8 +85,7 @@ to paint your list, and call `.mutate` from a click.
|
|
|
85
85
|
[Document lifecycle](#document-lifecycle) and
|
|
86
86
|
[Editing documents](#editing-documents) show the loop in full.
|
|
87
87
|
|
|
88
|
-
|
|
89
|
-
([Bundler setup](#bundler-setup)), then build and publish:
|
|
88
|
+
Then build and publish:
|
|
90
89
|
|
|
91
90
|
```sh
|
|
92
91
|
npm run build
|
|
@@ -95,27 +94,34 @@ npx @brass-build/cli publish ./dist
|
|
|
95
94
|
```
|
|
96
95
|
|
|
97
96
|
The first publish creates the app, hosts it at
|
|
98
|
-
`https://<slug>.onbrass.app` where your users sign in, and
|
|
99
|
-
app id (the `brass_app_...` above)
|
|
97
|
+
`https://<slug>.onbrass.app` where your users sign in, and reports the
|
|
98
|
+
app id (the `brass_app_...` above).
|
|
100
99
|
|
|
101
100
|
## Setup
|
|
102
101
|
|
|
103
102
|
To take the [quickstart](#quickstart) from source to a
|
|
104
103
|
signed-in, hosted app:
|
|
105
104
|
|
|
106
|
-
1. **
|
|
107
|
-
bundler needs to know how to load it. See
|
|
108
|
-
[Bundler setup](#bundler-setup).
|
|
109
|
-
2. **Ship it.** `npx @brass-build/cli publish ./dist` creates the app,
|
|
105
|
+
1. **Ship it.** `npx @brass-build/cli publish ./dist` creates the app,
|
|
110
106
|
hosts it, and returns its sign-in URL. See [Ship it](#ship-it).
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
`Brass.init()` with no argument. Pass the id everywhere else, in
|
|
114
|
-
|
|
115
|
-
`
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
107
|
+
2. **Pass an app id unless Brass hosts your bundle.** A published bundle
|
|
108
|
+
resolves its id from the slot it is served on, so it calls
|
|
109
|
+
`Brass.init()` with no argument. Pass the id everywhere else, in local
|
|
110
|
+
development and on your own origin: `Brass.init('brass_app_...')`.
|
|
111
|
+
`brass publish --json` reports it as `app_id`, or copy it from your
|
|
112
|
+
app's page in the dashboard.
|
|
113
|
+
|
|
114
|
+
One bundle covers both origins, because the id argument accepts
|
|
115
|
+
`undefined`. Pass whatever your build resolved to
|
|
116
|
+
(`Brass.init(import.meta.env.VITE_BRASS_APP_ID)`) and Brass falls back
|
|
117
|
+
to the serving slot. Set the variable for local development as well:
|
|
118
|
+
`localhost` has no slot to fall back to, and an absent id fails init.
|
|
119
|
+
3. **Allow your development origin.** An app you create in the dashboard
|
|
120
|
+
already allows sign-in from `localhost` / `127.0.0.1` on any port. One
|
|
121
|
+
a first `brass publish` created for you carries only the origin it is
|
|
122
|
+
served from, so add `http://localhost:5173/*` on your app's page before
|
|
123
|
+
signing in locally. A missing entry answers that the origin is not on
|
|
124
|
+
the app's allowlist.
|
|
119
125
|
|
|
120
126
|
**CSP.** If (and only if) your app sends a `Content-Security-Policy`,
|
|
121
127
|
extend it: `script-src` needs `'wasm-unsafe-eval'` for the SDK's
|
|
@@ -124,35 +130,41 @@ picker and share dialog, and `img-src` needs
|
|
|
124
130
|
`https://*.s3.<region>.amazonaws.com` if you render document thumbnails
|
|
125
131
|
(they are presigned S3 URLs).
|
|
126
132
|
|
|
127
|
-
###
|
|
133
|
+
### Modules and assets
|
|
128
134
|
|
|
129
135
|
`@brass-build/client` ships as ES modules, loaded by a bundler (Vite,
|
|
130
|
-
webpack) or Node's ESM loader with `import`.
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
136
|
+
webpack 5) or Node's ESM loader with `import`.
|
|
137
|
+
|
|
138
|
+
The SDK runs a WebAssembly engine, which your bundler emits as its own
|
|
139
|
+
asset of about 3 MB, an order of magnitude larger than your JavaScript.
|
|
140
|
+
Serve it as `application/wasm`: browsers compile it while it streams,
|
|
141
|
+
and fall back to a slower path when the content type is something else.
|
|
142
|
+
|
|
143
|
+
Boot also requests `/.well-known/brass-runtime.json`. Brass serves that
|
|
144
|
+
file from its own hosting to tell a bundle which app and environment it
|
|
145
|
+
is being served as. Your own origin has no reason to, so it answers
|
|
146
|
+
either a 404 (which the console logs in red) or, if it falls back to
|
|
147
|
+
`index.html` the way a dev server does, that page. Both are expected,
|
|
148
|
+
and the SDK carries on with the app id and origins you gave it.
|
|
149
|
+
|
|
150
|
+
### Vite dev server
|
|
151
|
+
|
|
152
|
+
Vite pre-bundles your dependencies for its dev server, which copies this
|
|
153
|
+
package into `node_modules/.vite/deps/` without moving the engine's binary
|
|
154
|
+
alongside it. The request for the binary then resolves inside that
|
|
155
|
+
directory, finds nothing, and the dev server answers with `index.html`, so
|
|
156
|
+
the engine is handed a page to compile and the app fails when it first
|
|
157
|
+
opens a document. Keep this package out of that step:
|
|
158
|
+
|
|
159
|
+
```js
|
|
160
|
+
// vite.config.js
|
|
161
|
+
export default {
|
|
162
|
+
optimizeDeps: { exclude: ['@brass-build/client'] },
|
|
163
|
+
};
|
|
150
164
|
```
|
|
151
165
|
|
|
152
|
-
`
|
|
153
|
-
|
|
154
|
-
dependencies for you, so your config keeps working as they change.
|
|
155
|
-
Webpack 5 loads them with no extra configuration.
|
|
166
|
+
`vite build` emits the binary as an asset and needs nothing, so this
|
|
167
|
+
setting is only for the dev server.
|
|
156
168
|
|
|
157
169
|
### Init
|
|
158
170
|
|
|
@@ -169,10 +181,12 @@ covers when you need one). The second is a typed options object,
|
|
|
169
181
|
| `syncDocToTitle` | `true` (set the page title to `Doc name · App name` while a document is open) |
|
|
170
182
|
| `openIncompatibleDocuments` | `false` (Brass refuses to open a document your schema cannot read, and asks the person whether to go ahead) |
|
|
171
183
|
|
|
172
|
-
`Brass.init` resolves
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
184
|
+
`Brass.init` resolves once it knows who the reader is, which takes one
|
|
185
|
+
auth round-trip for someone who has signed in to your app before and no
|
|
186
|
+
request at all for a first-time visitor. Register `onError` and
|
|
187
|
+
`onUserChange` BEFORE calling `init`, so that validation failures fire
|
|
188
|
+
through your listener instead of dying as unhandled rejections. A bad
|
|
189
|
+
`app_id` still resolves, with `Brass.user === null`.
|
|
176
190
|
|
|
177
191
|
### Ship it
|
|
178
192
|
|
|
@@ -217,11 +231,10 @@ changes. It fires once when `init` resolves (with the real user or
|
|
|
217
231
|
`null`), then on every sign-in and sign-out. A listener registered
|
|
218
232
|
before `init` does not fire on registration. One registered after does.
|
|
219
233
|
|
|
220
|
-
The
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
three states explicitly:
|
|
234
|
+
The [quickstart](#quickstart) sample gates on `'signed-out'` for exactly
|
|
235
|
+
this reason. A framework app (React, Vue, Svelte) paints its initial
|
|
236
|
+
state synchronously, before the first `onUserChange` fire, so it renders
|
|
237
|
+
the three states explicitly:
|
|
225
238
|
|
|
226
239
|
```ts
|
|
227
240
|
// Vue
|
|
@@ -349,7 +362,20 @@ document to its drive, and does other work while the document is open.
|
|
|
349
362
|
`doc.onStatus(cb)` fires on every phase. Render `status.message`
|
|
350
363
|
verbatim and read `status.pending` for whether the work is still
|
|
351
364
|
running. The message is prose written for the reader, so show it rather
|
|
352
|
-
than parsing it or inferring a phase from it.
|
|
365
|
+
than parsing it or inferring a phase from it. Both are readable
|
|
366
|
+
synchronously, so a fresh bind renders the current state without waiting
|
|
367
|
+
for a fire.
|
|
368
|
+
|
|
369
|
+
`doc.savingCount` is the count of this app's own writes that have not yet
|
|
370
|
+
been acknowledged, which is what a "Saving..." indicator reads. It ticks
|
|
371
|
+
through `doc.subscribe`, alongside the rest of what is not tied to one
|
|
372
|
+
container:
|
|
373
|
+
|
|
374
|
+
```ts
|
|
375
|
+
doc.subscribe(() => {
|
|
376
|
+
saving.textContent = doc.savingCount > 0 ? `Saving ${doc.savingCount}...` : 'Saved';
|
|
377
|
+
});
|
|
378
|
+
```
|
|
353
379
|
|
|
354
380
|
**Where new documents land.** The `new` command (and
|
|
355
381
|
`session.create()`) places the document for you: a sibling of the open
|
|
@@ -360,6 +386,12 @@ document, or a platform-chosen home from the empty state.
|
|
|
360
386
|
await Brass.session.create({ name: 'Scheme B' });
|
|
361
387
|
```
|
|
362
388
|
|
|
389
|
+
`session.pick()` is the same call for opening an existing document: it
|
|
390
|
+
raises the hosted picker and binds what the reader chooses, resolving
|
|
391
|
+
`null` if they cancel. Both back the toolbar's own New and Open, so reach
|
|
392
|
+
for them when your app renders those buttons itself (an empty state
|
|
393
|
+
offering somewhere to start, a shortcut).
|
|
394
|
+
|
|
363
395
|
**Keyboard shortcuts** route through `session.run`, which drives the
|
|
364
396
|
same undo / redo commands the toolbar buttons use. `session.commands`
|
|
365
397
|
carries every command with its id, and
|
|
@@ -427,6 +459,10 @@ that container. A large document stays cheap because your editor
|
|
|
427
459
|
touches the slice it cares about instead of walking the whole
|
|
428
460
|
document.
|
|
429
461
|
|
|
462
|
+
The SDK exports each accessor's return type, for a signature or a field
|
|
463
|
+
that has to hold one: `MapHandle<T>`, `OrderedMapHandle<T>`,
|
|
464
|
+
`ListHandle<T>` and `TextHandle`.
|
|
465
|
+
|
|
430
466
|
**Make an item the smallest thing two people edit independently.** Two
|
|
431
467
|
people editing different items always keep both edits. Two editing the
|
|
432
468
|
same item keep one of the two, not a blend, so a grid where people edit
|
|
@@ -440,12 +476,18 @@ type Wall = { type: 'wall'; x: number; z: number };
|
|
|
440
476
|
const walls = doc.map<Wall>('walls');
|
|
441
477
|
|
|
442
478
|
walls.mutate((m) => { m[id] = { type: 'wall', x: 0, z: 0 }; }); // add
|
|
443
|
-
walls.mutate((m) => { m[id].x = 42; });
|
|
444
|
-
walls.mutate((m) => { delete m[id]; });
|
|
479
|
+
walls.mutate((m) => { const w = m[id]; if (w) w.x = 42; }); // field edit
|
|
480
|
+
walls.mutate((m) => { delete m[id]; }); // remove
|
|
445
481
|
|
|
446
482
|
const one = walls.get(id); // Wall | undefined
|
|
447
483
|
```
|
|
448
484
|
|
|
485
|
+
The draft indexes as `Record<string, T>`, so a read is `T | undefined`
|
|
486
|
+
and the field edit checks it: under `noUncheckedIndexedAccess` that is
|
|
487
|
+
the only form that compiles, and either way a peer may have deleted the
|
|
488
|
+
record between your render and your click. A record you hold that way is
|
|
489
|
+
live, so writing through it writes to the document.
|
|
490
|
+
|
|
449
491
|
Handles are cached per key (`doc.map('walls') === doc.map('walls')`),
|
|
450
492
|
so they compare stable across renders. `doc.list<T>(key)` is the same
|
|
451
493
|
surface for a root list; its mutate draft is a plain array proxy, and
|
|
@@ -476,7 +518,7 @@ Read the collection in order with `ordered()` or `keys()`.
|
|
|
476
518
|
```ts
|
|
477
519
|
const notes = doc.orderedMap<Note>('notes', 'note_order');
|
|
478
520
|
notes.mutate((m) => { m[id] = { text: '' }; }); // appends to the order
|
|
479
|
-
notes.mutate((m) => { m[id].text = 'hi'; });
|
|
521
|
+
notes.mutate((m) => { const n = m[id]; if (n) n.text = 'hi'; }); // order untouched
|
|
480
522
|
for (const { key, value } of notes.ordered()) renderRow(key, value);
|
|
481
523
|
```
|
|
482
524
|
|
|
@@ -529,6 +571,54 @@ first undoable edit. Don't seed a baseline from the client: a faked
|
|
|
529
571
|
user sees content vanish they never wrote. Persist what the user asked
|
|
530
572
|
for, and nothing else.
|
|
531
573
|
|
|
574
|
+
### Prose several people edit
|
|
575
|
+
|
|
576
|
+
For prose several people edit at once (a description, a report, a comment
|
|
577
|
+
body), use `doc.text(key)` rather than storing the string in a map. A map
|
|
578
|
+
record is the merge unit, so two people editing one string keep one of the
|
|
579
|
+
two and the other's typing is gone. A text container merges inside the
|
|
580
|
+
string: `update(next)` diffs against what is stored and applies the
|
|
581
|
+
smallest edit that reaches it, so a peer's edit elsewhere in the same
|
|
582
|
+
paragraph survives.
|
|
583
|
+
|
|
584
|
+
Pass the whole new string every time. Diffing it is the handle's job, and
|
|
585
|
+
that is what makes a full-string assignment safe here where the same
|
|
586
|
+
assignment into a map is not.
|
|
587
|
+
|
|
588
|
+
```ts
|
|
589
|
+
const report = doc.text('report');
|
|
590
|
+
let painted = report.read();
|
|
591
|
+
field.value = painted;
|
|
592
|
+
|
|
593
|
+
field.addEventListener('input', () => {
|
|
594
|
+
painted = field.value;
|
|
595
|
+
report.update(painted);
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
report.subscribe((value, origin) => {
|
|
599
|
+
if (origin?.source === 'local') { painted = value; return; }
|
|
600
|
+
paintPeerEdit(value); // see docs/text-fields.md
|
|
601
|
+
});
|
|
602
|
+
```
|
|
603
|
+
|
|
604
|
+
`origin` says who wrote the change, the way a map subscriber's does. The
|
|
605
|
+
app's own write comes back through the subscriber too, and it is the one
|
|
606
|
+
fire that must not touch the field: the reader is already looking at what
|
|
607
|
+
they typed. Every other fire has to paint without throwing away the caret
|
|
608
|
+
of whoever is typing, which is what `paintPeerEdit` above stands for.
|
|
609
|
+
|
|
610
|
+
**A text container is a top-level root of the document**, so take one per
|
|
611
|
+
document-shaped thing: a body, a section, a summary. Prose that belongs to
|
|
612
|
+
each row of a repeated collection stays in the row's record, where the row
|
|
613
|
+
is the merge unit and the record store's paging holds the cost flat as the
|
|
614
|
+
collection grows. A root per row opts out of that paging, and loro prices a
|
|
615
|
+
container against how many the document holds, so the cost climbs steeply
|
|
616
|
+
with the collection.
|
|
617
|
+
|
|
618
|
+
[`docs/text-fields.md`](./docs/text-fields.md) carries the caret-preserving
|
|
619
|
+
paint, the IME window, what a keystroke costs against the field's length,
|
|
620
|
+
and the measurements behind the root-per-row rule.
|
|
621
|
+
|
|
532
622
|
### Subscribing
|
|
533
623
|
|
|
534
624
|
`handle.subscribe(cb)` is one channel for every change, whatever
|
|
@@ -596,7 +686,7 @@ item owns anything.** Rebuilding is correct and costs whatever the item
|
|
|
596
686
|
holds: a DOM node loses its focus, its selection and its scroll
|
|
597
687
|
position, and a view built on it starts over. As you apply a splice you
|
|
598
688
|
know which of your own items it removed, so match them against what the
|
|
599
|
-
same change inserts and move the
|
|
689
|
+
same change inserts and move the existing item rather than rebuilding it.
|
|
600
690
|
|
|
601
691
|
```ts
|
|
602
692
|
const stop = steps.subscribe((change) => {
|
|
@@ -653,12 +743,12 @@ edits stay untouched. Outside the session, call `doc.undo()` /
|
|
|
653
743
|
|
|
654
744
|
A **checkpoint** (`doc.createCheckpoint()`) is both the Ctrl-Z
|
|
655
745
|
boundary and a marker on the document's version-history timeline, so
|
|
656
|
-
checkpoint timing sets both granularities.
|
|
657
|
-
after each burst of local edits settles. Tune that to your app's shape:
|
|
746
|
+
checkpoint timing sets both granularities. Automatic checkpoints land 60
|
|
747
|
+
seconds after each burst of local edits settles. Tune that to your app's shape:
|
|
658
748
|
|
|
659
749
|
| Your app shape | What to do | Result |
|
|
660
750
|
| --------------------------------------- | ------------------------------------------------ | ------------------------------------------ |
|
|
661
|
-
| Default / no opinion | Nothing. |
|
|
751
|
+
| Default / no opinion | Nothing. | A checkpoint every 60s of idle. |
|
|
662
752
|
| Drag / brush / tool-stroke (3D, canvas) | `void doc.createCheckpoint()` on `pointerup`. | One drag = one Ctrl-Z. |
|
|
663
753
|
| Text editor / fast bursts | `doc.setAutoCheckpointIntervalMs(2000)` at boot. | Each typing pause becomes a boundary. |
|
|
664
754
|
| Explicit-only | `doc.setAutoCheckpointIntervalMs(0)` | Only deliberate `createCheckpoint` calls. |
|
|
@@ -666,8 +756,8 @@ after each burst of local edits settles. Tune that to your app's shape:
|
|
|
666
756
|
`createCheckpoint()` waits for the edits your app has just made to save
|
|
667
757
|
before it marks the point, so the marker describes the state you asked to
|
|
668
758
|
save rather than one those edits are missing from. It rejects on a rate
|
|
669
|
-
limit, and when those edits do not save. A missed
|
|
670
|
-
call it fire-and-forget.
|
|
759
|
+
limit, and when those edits do not save. A missed checkpoint is harmless,
|
|
760
|
+
so call it fire-and-forget.
|
|
671
761
|
|
|
672
762
|
The SDK renders version preview itself: while an earlier version is
|
|
673
763
|
showing, `mountToolbar` / `mountHeader` show the "viewing an earlier
|
|
@@ -866,6 +956,67 @@ goes, what each key does, and how to confirm your build carries it.
|
|
|
866
956
|
record store an imported file becomes, and
|
|
867
957
|
[`docs/schemas.md`](./docs/schemas.md) covers declaring its schema.
|
|
868
958
|
|
|
959
|
+
## Calling an outside service
|
|
960
|
+
|
|
961
|
+
Your app can call a paid or authenticated third-party API without ever
|
|
962
|
+
holding its credential. Declare the host in `brass-app.json`, and Brass adds
|
|
963
|
+
the key to each request:
|
|
964
|
+
|
|
965
|
+
```json
|
|
966
|
+
{
|
|
967
|
+
"services": [{ "host": "api.anthropic.com", "headers": { "x-api-key": "{secret}" } }]
|
|
968
|
+
}
|
|
969
|
+
```
|
|
970
|
+
|
|
971
|
+
Whoever owns the account enters the secret in Brass, either as one key of
|
|
972
|
+
yours that serves every install, or as a request for whoever runs your app to
|
|
973
|
+
connect their own account. `Brass.callService` returns the service's response unchanged:
|
|
974
|
+
|
|
975
|
+
```js
|
|
976
|
+
const res = await Brass.callService('api.anthropic.com', 'v1/messages', {
|
|
977
|
+
body: JSON.stringify({
|
|
978
|
+
model: 'claude-sonnet-5',
|
|
979
|
+
max_tokens: 1024,
|
|
980
|
+
messages: [{ role: 'user', content: prompt }],
|
|
981
|
+
}),
|
|
982
|
+
headers: {
|
|
983
|
+
'content-type': 'application/json',
|
|
984
|
+
'anthropic-version': '2023-06-01',
|
|
985
|
+
},
|
|
986
|
+
});
|
|
987
|
+
const message = await res.json();
|
|
988
|
+
```
|
|
989
|
+
|
|
990
|
+
`options.method` defaults to `'POST'`. The response must be text, under 5 MB,
|
|
991
|
+
and arrive within 25 seconds. Brass does not follow redirects: a `3xx` is
|
|
992
|
+
returned as `response_redirected`, with the service's `location` in the body.
|
|
993
|
+
|
|
994
|
+
`Brass.listServices()` tells you which hosts have a credential, so you can
|
|
995
|
+
show or hide a feature before anyone clicks it.
|
|
996
|
+
`Brass.connectService(host)` opens the dialog that collects a missing one:
|
|
997
|
+
|
|
998
|
+
```js
|
|
999
|
+
const services = await Brass.listServices();
|
|
1000
|
+
const ai = services?.find((s) => s.host === 'api.anthropic.com');
|
|
1001
|
+
if (ai?.ready) showSummariseButton();
|
|
1002
|
+
else upgradeButton.onclick = () => Brass.connectService('api.anthropic.com');
|
|
1003
|
+
```
|
|
1004
|
+
|
|
1005
|
+
When Brass refuses a call rather than sending it, the response body has a
|
|
1006
|
+
`code` for what to do about it:
|
|
1007
|
+
|
|
1008
|
+
```js
|
|
1009
|
+
if (!res.ok) {
|
|
1010
|
+
const { code } = await res.json();
|
|
1011
|
+
if (code === 'credential_required') await Brass.connectService('api.anthropic.com');
|
|
1012
|
+
}
|
|
1013
|
+
```
|
|
1014
|
+
|
|
1015
|
+
[`docs/services.md`](./docs/services.md) lists every code, and covers values
|
|
1016
|
+
only the user knows and services split across several addresses.
|
|
1017
|
+
[`docs/app-manifest.md`](./docs/app-manifest.md) covers declaring a host,
|
|
1018
|
+
including services that sign a request or exchange a credential for a token.
|
|
1019
|
+
|
|
869
1020
|
## Reference
|
|
870
1021
|
|
|
871
1022
|
`Brass` is the top-level namespace your app calls. The package's
|
|
@@ -886,6 +1037,9 @@ above show them in use.
|
|
|
886
1037
|
| `Brass.onUserChange(cb)` | `() => void` (unsubscribe) |
|
|
887
1038
|
| `Brass.onError(cb)` | `() => void` (unsubscribe) |
|
|
888
1039
|
| `Brass.getAccessToken()` | `Promise<string \| null>` |
|
|
1040
|
+
| `Brass.callService(host, path, options?)` | `Promise<Response>` |
|
|
1041
|
+
| `Brass.listServices()` | `Promise<AppServiceState[] \| null>` |
|
|
1042
|
+
| `Brass.connectService(host)` | `Promise<boolean>` |
|
|
889
1043
|
| `Brass.session` | `DocumentSession` (see above) |
|
|
890
1044
|
| `Brass.mountToolbar(el, options?)` | `SessionToolbarHandle` (`{ tools, destroy }`) |
|
|
891
1045
|
| `Brass.createDocument(options?)` | `Promise<OpenedDocument>` (`session.create` drives this) |
|
|
@@ -922,5 +1076,7 @@ with `.status === 401` before it reaches the network.
|
|
|
922
1076
|
- [`docs/schemas.md`](./docs/schemas.md): declaring a schema, creating a stream, what lets an app open a document, and what breaks that.
|
|
923
1077
|
- [`docs/streams.md`](./docs/streams.md): partitioning a document for lazy loading, per-stream undo, and reading a document spread across streams.
|
|
924
1078
|
- [`docs/custom-chrome.md`](./docs/custom-chrome.md): composable header pieces, and rendering `session.commands` yourself for a fully custom shell.
|
|
1079
|
+
- [`docs/text-fields.md`](./docs/text-fields.md): binding a field to `doc.text(key)`, painting a peer's edit without losing the caret, the IME window, what a keystroke costs, and where a text container belongs in a document.
|
|
1080
|
+
- [`docs/services.md`](./docs/services.md): calling a declared outside service, every refusal code, values only the user knows, and a service split across addresses.
|
|
925
1081
|
- [`docs/presence.md`](./docs/presence.md): remote cursors, roster rendering, coordinate conventions, full types.
|
|
926
1082
|
- [`docs/hosted-ui-advanced.md`](./docs/hosted-ui-advanced.md): the hosted launchers (`Brass.selectDocument`, `Brass.shareDocument`), their redirect behavior in browsers that block the iframe, theming, CSP.
|