@brass-build/client 0.1.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 +255 -0
- package/CHANGELOG.md +15 -0
- package/LICENSE +21 -0
- package/README.md +836 -0
- package/dist/api-fetch.d.ts +16 -0
- package/dist/api-fetch.d.ts.map +1 -0
- package/dist/api-fetch.js +97 -0
- package/dist/api-fetch.js.map +1 -0
- package/dist/api-headers.d.ts +3 -0
- package/dist/api-headers.d.ts.map +1 -0
- package/dist/api-headers.js +25 -0
- package/dist/api-headers.js.map +1 -0
- package/dist/auth-flow.d.ts +23 -0
- package/dist/auth-flow.d.ts.map +1 -0
- package/dist/auth-flow.js +76 -0
- package/dist/auth-flow.js.map +1 -0
- package/dist/client.d.ts +106 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +1278 -0
- package/dist/client.js.map +1 -0
- package/dist/doc-url-sync.d.ts +19 -0
- package/dist/doc-url-sync.d.ts.map +1 -0
- package/dist/doc-url-sync.js +181 -0
- package/dist/doc-url-sync.js.map +1 -0
- package/dist/document-branches.d.ts +32 -0
- package/dist/document-branches.d.ts.map +1 -0
- package/dist/document-branches.js +89 -0
- package/dist/document-branches.js.map +1 -0
- package/dist/document-checkpoints.d.ts +24 -0
- package/dist/document-checkpoints.d.ts.map +1 -0
- package/dist/document-checkpoints.js +115 -0
- package/dist/document-checkpoints.js.map +1 -0
- package/dist/document-commands.d.ts +93 -0
- package/dist/document-commands.d.ts.map +1 -0
- package/dist/document-commands.js +423 -0
- package/dist/document-commands.js.map +1 -0
- package/dist/document-signals.d.ts +53 -0
- package/dist/document-signals.d.ts.map +1 -0
- package/dist/document-signals.js +114 -0
- package/dist/document-signals.js.map +1 -0
- package/dist/document-streams.d.ts +22 -0
- package/dist/document-streams.d.ts.map +1 -0
- package/dist/document-streams.js +98 -0
- package/dist/document-streams.js.map +1 -0
- package/dist/document-thumbnail-capture.d.ts +27 -0
- package/dist/document-thumbnail-capture.d.ts.map +1 -0
- package/dist/document-thumbnail-capture.js +160 -0
- package/dist/document-thumbnail-capture.js.map +1 -0
- package/dist/documents-namespace.d.ts +134 -0
- package/dist/documents-namespace.d.ts.map +1 -0
- package/dist/documents-namespace.js +190 -0
- package/dist/documents-namespace.js.map +1 -0
- package/dist/documents.d.ts +146 -0
- package/dist/documents.d.ts.map +1 -0
- package/dist/documents.js +2045 -0
- package/dist/documents.js.map +1 -0
- package/dist/emitter.d.ts +15 -0
- package/dist/emitter.d.ts.map +1 -0
- package/dist/emitter.js +90 -0
- package/dist/emitter.js.map +1 -0
- package/dist/errors.d.ts +18 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +22 -0
- package/dist/errors.js.map +1 -0
- package/dist/export-guest.d.ts +9 -0
- package/dist/export-guest.d.ts.map +1 -0
- package/dist/export-guest.js +225 -0
- package/dist/export-guest.js.map +1 -0
- package/dist/fetch-retry.d.ts +16 -0
- package/dist/fetch-retry.d.ts.map +1 -0
- package/dist/fetch-retry.js +90 -0
- package/dist/fetch-retry.js.map +1 -0
- package/dist/guest-link.d.ts +3 -0
- package/dist/guest-link.d.ts.map +1 -0
- package/dist/guest-link.js +13 -0
- package/dist/guest-link.js.map +1 -0
- package/dist/handles.d.ts +57 -0
- package/dist/handles.d.ts.map +1 -0
- package/dist/handles.js +1375 -0
- package/dist/handles.js.map +1 -0
- package/dist/hosted-runtime.d.ts +33 -0
- package/dist/hosted-runtime.d.ts.map +1 -0
- package/dist/hosted-runtime.js +99 -0
- package/dist/hosted-runtime.js.map +1 -0
- package/dist/hosted-ui.d.ts +84 -0
- package/dist/hosted-ui.d.ts.map +1 -0
- package/dist/hosted-ui.js +673 -0
- package/dist/hosted-ui.js.map +1 -0
- package/dist/importer.d.ts +22 -0
- package/dist/importer.d.ts.map +1 -0
- package/dist/importer.js +54 -0
- package/dist/importer.js.map +1 -0
- package/dist/index.d.ts +54 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +132 -0
- package/dist/index.js.map +1 -0
- package/dist/init-keys.d.ts +3 -0
- package/dist/init-keys.d.ts.map +1 -0
- package/dist/init-keys.js +13 -0
- package/dist/init-keys.js.map +1 -0
- package/dist/internal.d.ts +6 -0
- package/dist/internal.d.ts.map +1 -0
- package/dist/internal.js +17 -0
- package/dist/internal.js.map +1 -0
- package/dist/jwt.d.ts +3 -0
- package/dist/jwt.d.ts.map +1 -0
- package/dist/jwt.js +22 -0
- package/dist/jwt.js.map +1 -0
- package/dist/launch-context.d.ts +4 -0
- package/dist/launch-context.d.ts.map +1 -0
- package/dist/launch-context.js +44 -0
- package/dist/launch-context.js.map +1 -0
- package/dist/login-hint.d.ts +4 -0
- package/dist/login-hint.d.ts.map +1 -0
- package/dist/login-hint.js +63 -0
- package/dist/login-hint.js.map +1 -0
- package/dist/mutate-batching.d.ts +14 -0
- package/dist/mutate-batching.d.ts.map +1 -0
- package/dist/mutate-batching.js +65 -0
- package/dist/mutate-batching.js.map +1 -0
- package/dist/open-document.d.ts +127 -0
- package/dist/open-document.d.ts.map +1 -0
- package/dist/open-document.js +2121 -0
- package/dist/open-document.js.map +1 -0
- package/dist/page-title.d.ts +3 -0
- package/dist/page-title.d.ts.map +1 -0
- package/dist/page-title.js +32 -0
- package/dist/page-title.js.map +1 -0
- package/dist/perf.d.ts +9 -0
- package/dist/perf.d.ts.map +1 -0
- package/dist/perf.js +39 -0
- package/dist/perf.js.map +1 -0
- package/dist/presence.d.ts +62 -0
- package/dist/presence.d.ts.map +1 -0
- package/dist/presence.js +331 -0
- package/dist/presence.js.map +1 -0
- package/dist/record-paging.d.ts +99 -0
- package/dist/record-paging.d.ts.map +1 -0
- package/dist/record-paging.js +220 -0
- package/dist/record-paging.js.map +1 -0
- package/dist/schema-conflict-dialog.d.ts +10 -0
- package/dist/schema-conflict-dialog.d.ts.map +1 -0
- package/dist/schema-conflict-dialog.js +164 -0
- package/dist/schema-conflict-dialog.js.map +1 -0
- package/dist/selection-persistence.d.ts +5 -0
- package/dist/selection-persistence.d.ts.map +1 -0
- package/dist/selection-persistence.js +90 -0
- package/dist/selection-persistence.js.map +1 -0
- package/dist/serializer.d.ts +3 -0
- package/dist/serializer.d.ts.map +1 -0
- package/dist/serializer.js +2 -0
- package/dist/serializer.js.map +1 -0
- package/dist/session-persistence.d.ts +4 -0
- package/dist/session-persistence.d.ts.map +1 -0
- package/dist/session-persistence.js +68 -0
- package/dist/session-persistence.js.map +1 -0
- package/dist/session-toolbar.d.ts +62 -0
- package/dist/session-toolbar.d.ts.map +1 -0
- package/dist/session-toolbar.js +1205 -0
- package/dist/session-toolbar.js.map +1 -0
- package/dist/session.d.ts +69 -0
- package/dist/session.d.ts.map +1 -0
- package/dist/session.js +926 -0
- package/dist/session.js.map +1 -0
- package/dist/tail-ops.d.ts +28 -0
- package/dist/tail-ops.d.ts.map +1 -0
- package/dist/tail-ops.js +46 -0
- package/dist/tail-ops.js.map +1 -0
- package/dist/topic-lifecycle.d.ts +41 -0
- package/dist/topic-lifecycle.d.ts.map +1 -0
- package/dist/topic-lifecycle.js +169 -0
- package/dist/topic-lifecycle.js.map +1 -0
- package/dist/types.d.ts +22 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/undo.d.ts +18 -0
- package/dist/undo.d.ts.map +1 -0
- package/dist/undo.js +110 -0
- package/dist/undo.js.map +1 -0
- package/dist/version-bar.d.ts +22 -0
- package/dist/version-bar.d.ts.map +1 -0
- package/dist/version-bar.js +169 -0
- package/dist/version-bar.js.map +1 -0
- package/dist/version.d.ts +3 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +8 -0
- package/dist/version.js.map +1 -0
- package/dist/vite.d.ts +15 -0
- package/dist/vite.d.ts.map +1 -0
- package/dist/vite.js +36 -0
- package/dist/vite.js.map +1 -0
- package/dist/ws-client.d.ts +53 -0
- package/dist/ws-client.d.ts.map +1 -0
- package/dist/ws-client.js +604 -0
- package/dist/ws-client.js.map +1 -0
- package/dist/ws-frame-limit.d.ts +21 -0
- package/dist/ws-frame-limit.d.ts.map +1 -0
- package/dist/ws-frame-limit.js +40 -0
- package/dist/ws-frame-limit.js.map +1 -0
- package/docs/app-manifest.md +65 -0
- package/docs/custom-chrome.md +147 -0
- package/docs/hosted-ui-advanced.md +77 -0
- package/docs/imported-documents.md +261 -0
- package/docs/presence.md +129 -0
- package/docs/schemas.md +135 -0
- package/docs/streams.md +248 -0
- package/package.json +60 -0
- package/scripts/prepare.mjs +50 -0
- package/src/api-fetch.ts +127 -0
- package/src/api-headers.ts +28 -0
- package/src/auth-flow.ts +125 -0
- package/src/client.ts +1457 -0
- package/src/doc-url-sync.ts +183 -0
- package/src/document-branches.ts +124 -0
- package/src/document-checkpoints.ts +135 -0
- package/src/document-commands.ts +651 -0
- package/src/document-signals.ts +234 -0
- package/src/document-streams.ts +132 -0
- package/src/document-thumbnail-capture.ts +191 -0
- package/src/documents-namespace.ts +516 -0
- package/src/documents.ts +2426 -0
- package/src/emitter.ts +129 -0
- package/src/errors.ts +45 -0
- package/src/export-guest.ts +282 -0
- package/src/fetch-retry.ts +119 -0
- package/src/guest-link.ts +14 -0
- package/src/handles.ts +1731 -0
- package/src/hosted-runtime.ts +109 -0
- package/src/hosted-ui.ts +1020 -0
- package/src/importer.ts +89 -0
- package/src/index.ts +267 -0
- package/src/init-keys.ts +15 -0
- package/src/internal.ts +23 -0
- package/src/jwt.ts +18 -0
- package/src/launch-context.ts +43 -0
- package/src/login-hint.ts +60 -0
- package/src/mutate-batching.ts +63 -0
- package/src/open-document.ts +2869 -0
- package/src/page-title.ts +33 -0
- package/src/perf.ts +46 -0
- package/src/presence.ts +471 -0
- package/src/record-paging.ts +270 -0
- package/src/schema-conflict-dialog.ts +176 -0
- package/src/selection-persistence.ts +98 -0
- package/src/serializer.ts +15 -0
- package/src/session-persistence.ts +69 -0
- package/src/session-toolbar.ts +1457 -0
- package/src/session.ts +1262 -0
- package/src/tail-ops.ts +63 -0
- package/src/topic-lifecycle.ts +217 -0
- package/src/types.ts +75 -0
- package/src/undo.ts +125 -0
- package/src/version-bar.ts +221 -0
- package/src/version.ts +8 -0
- package/src/vite.ts +49 -0
- package/src/ws-client.ts +678 -0
- package/src/ws-frame-limit.ts +52 -0
package/README.md
ADDED
|
@@ -0,0 +1,836 @@
|
|
|
1
|
+
# @brass-build/client
|
|
2
|
+
|
|
3
|
+
Client SDK for [Brass](https://brass.build), a platform for building
|
|
4
|
+
and distributing enterprise-ready software. Brass closes the gap
|
|
5
|
+
between prototypes and production by ensuring that arbitrary, often
|
|
6
|
+
agent-coded software meets expectations for security, scalability,
|
|
7
|
+
robustness, and capability. Applications built on Brass inherit
|
|
8
|
+
foundational capabilities such as authentication, access control, and a
|
|
9
|
+
durable, collaborative data layer rather than reimplementing them.
|
|
10
|
+
|
|
11
|
+
Brass is fundamentally unopinionated, supporting any product
|
|
12
|
+
manifestation, data model, or sibling technologies. However, it is
|
|
13
|
+
particularly well-suited for software in the Architecture, Engineering,
|
|
14
|
+
and Construction (AEC) space, given its support for collaboration on
|
|
15
|
+
large geometric models and out-of-the-box integration into popular AEC
|
|
16
|
+
products.
|
|
17
|
+
|
|
18
|
+
```sh
|
|
19
|
+
npm install @brass-build/client
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Contents
|
|
23
|
+
|
|
24
|
+
- [Quickstart](#quickstart)
|
|
25
|
+
- [Setup](#setup)
|
|
26
|
+
- [Rendering auth state](#rendering-auth-state)
|
|
27
|
+
- [Default UI](#default-ui)
|
|
28
|
+
- [Document lifecycle](#document-lifecycle)
|
|
29
|
+
- [Editing documents](#editing-documents)
|
|
30
|
+
- [Presence](#presence-live-cursors-and-whos-here)
|
|
31
|
+
- [Streams](#streams-partitioning-large-documents)
|
|
32
|
+
- [Imported files](#imported-files)
|
|
33
|
+
- [Reference](#reference)
|
|
34
|
+
- [Guides](#guides)
|
|
35
|
+
|
|
36
|
+
## Quickstart
|
|
37
|
+
|
|
38
|
+
`@brass-build/client` turns a document into a live, multi-user,
|
|
39
|
+
persistent workspace. Your app supplies the UI; Brass supplies sign-in,
|
|
40
|
+
the file toolbar, and one subscribe-render-mutate loop per open
|
|
41
|
+
document. A working app is mostly your own rendering and event handling
|
|
42
|
+
wrapped around a handful of Brass calls.
|
|
43
|
+
|
|
44
|
+
**Describe it to a coding agent.** An app's dashboard page shows a
|
|
45
|
+
ready-to-paste prompt with its id already filled in. Paste it into
|
|
46
|
+
Claude, Cursor, or another assistant; the assistant reads this README
|
|
47
|
+
and [`AGENTS.md`](./AGENTS.md), then wires, verifies, and ships the
|
|
48
|
+
integration. The prompt is one line naming what you want and the SDK:
|
|
49
|
+
|
|
50
|
+
```
|
|
51
|
+
Build a shared checklist on @brass-build/client (NPM).
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
**Or wire it by hand.** The Brass surface of an app is small:
|
|
55
|
+
|
|
56
|
+
```ts
|
|
57
|
+
import { Brass } from '@brass-build/client';
|
|
58
|
+
import type { BrassUser, Document } from '@brass-build/client';
|
|
59
|
+
|
|
60
|
+
// Register listeners before init so validation failures reach onError.
|
|
61
|
+
Brass.onError((err) => console.error('[Brass]', err.message));
|
|
62
|
+
|
|
63
|
+
// Swap your two shells as auth settles. Both start hidden, so nothing
|
|
64
|
+
// paints before the first onUserChange fire.
|
|
65
|
+
Brass.onUserChange((user: BrassUser | null) => {
|
|
66
|
+
document.getElementById('signed-out')!.hidden = !!user;
|
|
67
|
+
document.getElementById('signed-in')!.hidden = !user;
|
|
68
|
+
});
|
|
69
|
+
document.getElementById('sign-in')!.onclick = () => Brass.signIn();
|
|
70
|
+
|
|
71
|
+
// The whole file menu in one call: document title, undo / redo,
|
|
72
|
+
// account menu, presence roster.
|
|
73
|
+
Brass.mountToolbar(document.getElementById('app-bar')!);
|
|
74
|
+
|
|
75
|
+
// Attach your editor to each document the session opens. Compare the
|
|
76
|
+
// Document instance, never the id (a version preview reuses the id).
|
|
77
|
+
let bound: Document | null = null;
|
|
78
|
+
Brass.session.subscribe((session) => {
|
|
79
|
+
if (session.document !== bound) {
|
|
80
|
+
bound = session.document;
|
|
81
|
+
if (bound) bindEditor(bound); // see Editing documents
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
await Brass.init('brass_app_...');
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
`bindEditor` is your code: take a handle for the data you render
|
|
89
|
+
(`doc.orderedMap('items', 'item_order')` for a checklist), `subscribe`
|
|
90
|
+
to paint your list, and call `.mutate` from a click. The rendering,
|
|
91
|
+
styling, and event handling around it are ordinary app code.
|
|
92
|
+
[Document lifecycle](#document-lifecycle) and
|
|
93
|
+
[Editing documents](#editing-documents) show the loop in full.
|
|
94
|
+
|
|
95
|
+
Set up your bundler for the SDK's WebAssembly module
|
|
96
|
+
([Bundler setup](#bundler-setup)), then build and publish:
|
|
97
|
+
|
|
98
|
+
```sh
|
|
99
|
+
npm run build
|
|
100
|
+
npx @brass-build/cli login # opens your browser to sign in
|
|
101
|
+
npx @brass-build/cli publish ./dist
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
The first publish creates the app, hosts it at
|
|
105
|
+
`https://<slug>.onbrass.app`, and writes its id to `.brass/project.json`
|
|
106
|
+
(the `brass_app_...` above). Your users sign in at that URL.
|
|
107
|
+
|
|
108
|
+
## Setup
|
|
109
|
+
|
|
110
|
+
To take the [quickstart](#quickstart) from source to a
|
|
111
|
+
signed-in, hosted app:
|
|
112
|
+
|
|
113
|
+
1. **Set up the bundler.** The SDK ships a WebAssembly module, and your
|
|
114
|
+
bundler needs to know how to load it; see
|
|
115
|
+
[Bundler setup](#bundler-setup).
|
|
116
|
+
2. **Ship it.** `npx @brass-build/cli publish ./dist` creates the app,
|
|
117
|
+
hosts it, and returns a URL your users sign in at. A hosted bundle
|
|
118
|
+
needs no app id; it inherits one from its serving origin. See
|
|
119
|
+
[Ship it](#ship-it).
|
|
120
|
+
3. **Local dev and self-hosting need an app id.** Pass it to
|
|
121
|
+
`Brass.init('brass_app_...')`. The first publish writes the id to
|
|
122
|
+
`.brass/project.json`, or copy it from the app's dashboard page. A
|
|
123
|
+
new app already allows sign-in from `localhost` / `127.0.0.1` on any
|
|
124
|
+
port with no allowlist step; add a self-hosted origin on the app's
|
|
125
|
+
detail page.
|
|
126
|
+
|
|
127
|
+
**CSP.** If (and only if) your app sends a `Content-Security-Policy`,
|
|
128
|
+
extend it: `script-src` needs `'wasm-unsafe-eval'` for the SDK's
|
|
129
|
+
WebAssembly, `frame-src` needs `https://app.brass.build` for the hosted
|
|
130
|
+
picker and share dialog, and `img-src` needs
|
|
131
|
+
`https://*.s3.<region>.amazonaws.com` if you render document thumbnails
|
|
132
|
+
(they are presigned S3 URLs).
|
|
133
|
+
|
|
134
|
+
### Bundler setup
|
|
135
|
+
|
|
136
|
+
`@brass-build/client` ships as ES modules, loaded by a bundler (Vite,
|
|
137
|
+
webpack, esbuild) or Node's ESM loader with `import`.
|
|
138
|
+
|
|
139
|
+
Without a WASM loader, `npm run build` fails with
|
|
140
|
+
`[vite:wasm-fallback] ... "ESM integration proposal for Wasm" is not
|
|
141
|
+
supported`. For Vite, install two plugins and add the SDK's own helper:
|
|
142
|
+
|
|
143
|
+
```sh
|
|
144
|
+
npm install --save-dev vite-plugin-wasm vite-plugin-top-level-await
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
```ts
|
|
148
|
+
// vite.config.ts
|
|
149
|
+
import { defineConfig } from 'vite';
|
|
150
|
+
import wasm from 'vite-plugin-wasm';
|
|
151
|
+
import topLevelAwait from 'vite-plugin-top-level-await';
|
|
152
|
+
import { brassVite } from '@brass-build/client/vite';
|
|
153
|
+
|
|
154
|
+
export default defineConfig({
|
|
155
|
+
plugins: [wasm(), topLevelAwait(), brassVite()],
|
|
156
|
+
build: { target: 'es2022' },
|
|
157
|
+
});
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
`brassVite()` configures Vite's dep-prebundling step so the SDK's WASM
|
|
161
|
+
runtime loads on first `vite dev`. Vite 5/6 need
|
|
162
|
+
`vite-plugin-top-level-await`; Vite 8's rolldown bundler supports
|
|
163
|
+
top-level await natively. Webpack 5 needs `experiments.asyncWebAssembly: true` plus a
|
|
164
|
+
matching top-level-await plugin. The WASM bundle is ~3 MB uncompressed
|
|
165
|
+
(~600 KB gzipped).
|
|
166
|
+
|
|
167
|
+
### Init
|
|
168
|
+
|
|
169
|
+
The first argument to `Brass.init` is your `app_id` (a hosted bundle
|
|
170
|
+
omits it and inherits the id from its serving origin). The second is a
|
|
171
|
+
typed options object
|
|
172
|
+
(`BrassInitOptions`); the defaults are right for most apps, so you
|
|
173
|
+
usually pass nothing:
|
|
174
|
+
|
|
175
|
+
| Init option | Default |
|
|
176
|
+
| ----------- | ------- |
|
|
177
|
+
| `hostedUiTheme` | `'light'` (also `'dark'`, `'auto'`) |
|
|
178
|
+
| `syncDocToUrl` | `true` (sync the active selection to the URL + browser history) |
|
|
179
|
+
| `docUrlParam` | `'doc'` (the query param for the active doc id) |
|
|
180
|
+
| `branchUrlParam` | `'branch'` (the query param for a non-default branch) |
|
|
181
|
+
| `syncDocToTitle` | `true` (set the page title to `Doc name · App name` while a document is open) |
|
|
182
|
+
| `openIncompatibleDocuments` | `false` (Brass refuses to open a document your schema cannot read, and asks the person whether to go ahead) |
|
|
183
|
+
|
|
184
|
+
`Brass.init` resolves after one auth round-trip. Register `onError`
|
|
185
|
+
and `onUserChange` BEFORE calling `init`, so that validation failures
|
|
186
|
+
fire through your listener instead of dying as unhandled rejections; a
|
|
187
|
+
bad `app_id` still resolves, with `Brass.user === null`.
|
|
188
|
+
|
|
189
|
+
### Ship it
|
|
190
|
+
|
|
191
|
+
Publishing deploys your built bundle to Brass hosting at
|
|
192
|
+
`https://<slug>.onbrass.app` (or whichever domain you've configured), a
|
|
193
|
+
content origin Brass serves and adds to the app's sign-in allowlist for
|
|
194
|
+
you. Run it with the `brass` CLI:
|
|
195
|
+
|
|
196
|
+
```sh
|
|
197
|
+
npm run build
|
|
198
|
+
npx @brass-build/cli publish ./dist
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
The first run creates the app, enables hosting, uploads the build, and
|
|
202
|
+
writes the app id to `.brass/project.json`. Commit that file so later
|
|
203
|
+
publishes and your teammates target the same app.
|
|
204
|
+
|
|
205
|
+
Run `brass login` once first to authenticate (it opens your browser),
|
|
206
|
+
or set `BRASS_SERVICE_TOKEN` in a pipeline. The CLI also pulls a
|
|
207
|
+
document's schema into your `brass-app.json`. See
|
|
208
|
+
[`@brass-build/cli`](https://www.npmjs.com/package/@brass-build/cli).
|
|
209
|
+
|
|
210
|
+
## Rendering auth state
|
|
211
|
+
|
|
212
|
+
Auth has **four** states: `Brass.authStatus` is
|
|
213
|
+
`'unknown' | 'signed-in' | 'signed-out' | 'guest'`, where `'unknown'`
|
|
214
|
+
is the bootstrap window before `init` settles. The distinction matters
|
|
215
|
+
because a page reload always passes through `'unknown'`, and during it
|
|
216
|
+
`Brass.user` is still `null` even for a signed-in user. Never gate a
|
|
217
|
+
sign-in CTA on `!Brass.user`: that flashes the sign-in screen at
|
|
218
|
+
signed-in users on every reload. Render the CTA only on
|
|
219
|
+
`'signed-out'`, and render your working surface for **both**
|
|
220
|
+
`'signed-in'` and `'guest'`.
|
|
221
|
+
|
|
222
|
+
`'guest'` is the "anyone with the link" case: someone opened a public
|
|
223
|
+
view link, so there is no signed-in user but `Brass.session` still
|
|
224
|
+
opens the shared document **read-only** for them (`Brass.isGuest` is
|
|
225
|
+
`true`). When the page URL carries the link token, the session boots
|
|
226
|
+
the read-only document automatically; render your working surface for
|
|
227
|
+
`'guest'` too (the document is read-only, so edits are already
|
|
228
|
+
disabled).
|
|
229
|
+
|
|
230
|
+
`Brass.onUserChange` is the single source for reacting to auth
|
|
231
|
+
changes. It fires once when `init` resolves (with the real user or
|
|
232
|
+
`null`), then on every sign-in and sign-out. A listener registered
|
|
233
|
+
before `init` does not fire on registration; one registered after
|
|
234
|
+
does.
|
|
235
|
+
|
|
236
|
+
The plain-DOM sample in the [quickstart](#quickstart) gets this
|
|
237
|
+
right with no extra code, because both shells start `hidden` and
|
|
238
|
+
nothing shows until the first `onUserChange`. A framework app (React, Vue, Svelte) paints its
|
|
239
|
+
initial state synchronously, before that first fire, so it renders the
|
|
240
|
+
three states explicitly:
|
|
241
|
+
|
|
242
|
+
```ts
|
|
243
|
+
// Vue
|
|
244
|
+
const authStatus = ref(Brass.authStatus);
|
|
245
|
+
Brass.onUserChange(() => { authStatus.value = Brass.authStatus; });
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
```html
|
|
249
|
+
<SplashOrNothing v-if="authStatus === 'unknown'" />
|
|
250
|
+
<SignInCard v-else-if="authStatus === 'signed-out'" />
|
|
251
|
+
<AppShell v-else /> <!-- signed-in or guest (read-only) -->
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
## Default UI
|
|
255
|
+
|
|
256
|
+
`Brass.mountToolbar(container)` fills a container with the full
|
|
257
|
+
document toolbar, wired to the session: creating, opening, renaming,
|
|
258
|
+
sharing, and deleting documents, undo / redo, an account menu, and a
|
|
259
|
+
presence roster. Grow beyond it into your own header only when you need
|
|
260
|
+
one, and even then compose
|
|
261
|
+
the standard pieces (below) rather than rebuilding them. The call
|
|
262
|
+
returns a `tools` slot where your app's own persistent controls (a mode
|
|
263
|
+
palette, a save indicator) sit alongside the standard pieces:
|
|
264
|
+
|
|
265
|
+
```ts
|
|
266
|
+
const bar = Brass.mountToolbar(document.getElementById('app-bar')!);
|
|
267
|
+
bar.tools.append(orbitButton, drawButton);
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
The widget renders its own full-width, mobile-responsive bar, so give
|
|
271
|
+
it a full-width block container and let it own that row. The common
|
|
272
|
+
shell is a column flex with the bar on top and the working surface
|
|
273
|
+
filling the rest:
|
|
274
|
+
|
|
275
|
+
```html
|
|
276
|
+
<div style="display:flex; flex-direction:column; height:100vh">
|
|
277
|
+
<div id="app-bar"></div> <!-- mountToolbar fills this -->
|
|
278
|
+
<div id="stage" style="flex:1"></div>
|
|
279
|
+
</div>
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
**Mount it once when the user signs in, not when a document opens.**
|
|
283
|
+
New and Open live in its file menu, so the bar has to exist in the
|
|
284
|
+
"signed in, nothing open" state; otherwise a user who just signed in
|
|
285
|
+
has no way to open or create a document. It re-renders itself as
|
|
286
|
+
documents open, switch, and close. It takes options `{ presence?, account? }` (both
|
|
287
|
+
default to `true`), and the returned handle's `destroy()` removes it.
|
|
288
|
+
|
|
289
|
+
A multi-pane app that builds its own header can still take the
|
|
290
|
+
standard pieces (file menu, title, undo / redo, presence roster,
|
|
291
|
+
drive-sync badge, account menu) individually instead of
|
|
292
|
+
re-implementing them; see "Composable header pieces" in
|
|
293
|
+
[`docs/custom-chrome.md`](./docs/custom-chrome.md). Include the
|
|
294
|
+
drive-sync badge (`Brass.mountDriveSync(el)`): on a document linked to
|
|
295
|
+
a connected drive it carries the user's pull, publish, and reconcile
|
|
296
|
+
actions, and on any other document it renders nothing, so mount it
|
|
297
|
+
unconditionally.
|
|
298
|
+
|
|
299
|
+
## Document lifecycle
|
|
300
|
+
|
|
301
|
+
`Brass.session` tracks which document is open. It restores the URL's
|
|
302
|
+
selection on boot, opens and closes documents, keeps the URL, browser
|
|
303
|
+
history, and page title in step with the active document (so Back /
|
|
304
|
+
Forward and reload behave as expected), and exposes the file-menu
|
|
305
|
+
commands as data, which `mountToolbar` renders for you. Your app
|
|
306
|
+
consumes all of this through one subscription:
|
|
307
|
+
|
|
308
|
+
```ts
|
|
309
|
+
Brass.session.subscribe((session) => {
|
|
310
|
+
renderStatus(session.status);
|
|
311
|
+
if (session.document !== bound) {
|
|
312
|
+
bound = session.document;
|
|
313
|
+
if (bound) bindEditor(bound);
|
|
314
|
+
}
|
|
315
|
+
});
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
`session.subscribe` fires on lifecycle changes (open, close, switch,
|
|
319
|
+
command state, sign-in / sign-out), and calls your callback with the
|
|
320
|
+
`DocumentSession` synchronously on registration. It does **not** fire
|
|
321
|
+
on document edits; those arrive through the document's own `subscribe`
|
|
322
|
+
(below).
|
|
323
|
+
|
|
324
|
+
`session.status` tells you which of six screens to render:
|
|
325
|
+
|
|
326
|
+
| `status` | Meaning | Render |
|
|
327
|
+
| ------------ | ---------------------------------------- | ------------------------------------ |
|
|
328
|
+
| `unknown` | `init` not settled; auth not yet known. | Nothing, or a splash. |
|
|
329
|
+
| `signed-out` | No signed-in user. | The sign-in CTA. |
|
|
330
|
+
| `empty` | Signed in, no document open. | "No document open. Use New or Open." |
|
|
331
|
+
| `loading` | An open / create is in flight. | A spinner or progress bar (below). |
|
|
332
|
+
| `ready` | A document is open (`session.document`). | Your editor. |
|
|
333
|
+
| `error` | A document couldn't be opened, or the open one was deleted.| `session.error.message` + your recovery actions (below) |
|
|
334
|
+
|
|
335
|
+
Gate edit affordances on `ready`: a canvas that accepts input in the
|
|
336
|
+
`empty` state lets the user produce work with nowhere to save it.
|
|
337
|
+
|
|
338
|
+
**When an open fails.** A link to a document that was deleted, or that
|
|
339
|
+
the user cannot access, fails the open and the session enters `error`.
|
|
340
|
+
Someone else deleting the document while the user is editing lands in
|
|
341
|
+
the same state, so one screen covers both. Render the platform's
|
|
342
|
+
message with your own recovery actions:
|
|
343
|
+
|
|
344
|
+
```ts
|
|
345
|
+
if (session.status === 'error') {
|
|
346
|
+
showMessage(session.error.message); // render verbatim; don't write your own
|
|
347
|
+
// ...alongside your app's own New / Open buttons.
|
|
348
|
+
}
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
Display `session.error.message` verbatim; the platform authors it,
|
|
352
|
+
like `session.loadingMessage` and `doc.status.message`. Supply the
|
|
353
|
+
recovery actions yourself (your New / Open buttons). Use
|
|
354
|
+
`session.error.status` for logging or your own handling. A failed open
|
|
355
|
+
from an already-open document is different: the session keeps the
|
|
356
|
+
document you were editing and the `open()` call rejects, so catch that
|
|
357
|
+
at the call site instead.
|
|
358
|
+
|
|
359
|
+
**Loading progress.** While `status === 'loading'`, the session
|
|
360
|
+
exposes `session.loadingMessage` (a ready-to-render string such as
|
|
361
|
+
`Downloading... 62%`; show it verbatim) and `session.loadingProgress`
|
|
362
|
+
(a `0..1` fraction when the size is known, or `null` for
|
|
363
|
+
indeterminate). The document binds only after its content downloads,
|
|
364
|
+
so the first `ready` render already has the real model to read
|
|
365
|
+
directly.
|
|
366
|
+
|
|
367
|
+
**Where new documents land.** The `new` command (and
|
|
368
|
+
`session.create()`) places the document for you: a sibling of the open
|
|
369
|
+
document, or a platform-chosen home from the empty state.
|
|
370
|
+
`session.create()` resolves `null` if the user cancels placement:
|
|
371
|
+
|
|
372
|
+
```ts
|
|
373
|
+
await Brass.session.create({ name: 'Scheme B' });
|
|
374
|
+
```
|
|
375
|
+
|
|
376
|
+
**Keyboard shortcuts** route through `session.run`, which drives the
|
|
377
|
+
same undo / redo commands the toolbar buttons use. `session.commands`
|
|
378
|
+
carries every command with its id, and
|
|
379
|
+
[`docs/custom-chrome.md`](./docs/custom-chrome.md) tables the built-in
|
|
380
|
+
set if you need to invoke one directly:
|
|
381
|
+
|
|
382
|
+
```ts
|
|
383
|
+
document.addEventListener('keydown', (e) => {
|
|
384
|
+
if (!(e.ctrlKey || e.metaKey)) return;
|
|
385
|
+
const key = e.key.toLowerCase();
|
|
386
|
+
if (key === 'z' && !e.shiftKey) {
|
|
387
|
+
e.preventDefault();
|
|
388
|
+
void Brass.session.run('undo');
|
|
389
|
+
} else if ((key === 'z' && e.shiftKey) || key === 'y') {
|
|
390
|
+
e.preventDefault();
|
|
391
|
+
void Brass.session.run('redo');
|
|
392
|
+
}
|
|
393
|
+
});
|
|
394
|
+
```
|
|
395
|
+
|
|
396
|
+
Code that runs outside the render loop can await the bound document
|
|
397
|
+
with `await Brass.session.whenReady()`; it resolves with the same
|
|
398
|
+
instance as `session.document`, immediately if one is already bound,
|
|
399
|
+
and rejects if the open fails or the user signs out before one binds.
|
|
400
|
+
|
|
401
|
+
The hosted dialogs (`Brass.selectDocument()`, `Brass.shareDocument()`)
|
|
402
|
+
handle browsing, picking, and sharing existing documents; the
|
|
403
|
+
session's Open and Share commands invoke them for you.
|
|
404
|
+
|
|
405
|
+
## Editing documents
|
|
406
|
+
|
|
407
|
+
### Bind the editor to the document
|
|
408
|
+
|
|
409
|
+
When the session opens a document, attach your editor to it: take a
|
|
410
|
+
handle for each container you render, and subscribe. The subscription
|
|
411
|
+
in [Document lifecycle](#document-lifecycle) does this in `bindEditor`,
|
|
412
|
+
called whenever `s.document` changes:
|
|
413
|
+
|
|
414
|
+
```ts
|
|
415
|
+
function bindEditor(doc: Document): void {
|
|
416
|
+
const walls = doc.map<Wall>('walls');
|
|
417
|
+
// One subscribe line is the whole render loop; the first fire
|
|
418
|
+
// carries the current state, so there is no separate initial render.
|
|
419
|
+
const stop = walls.subscribe(() => renderWalls(walls.read()));
|
|
420
|
+
doc.onGone(() => stop());
|
|
421
|
+
}
|
|
422
|
+
```
|
|
423
|
+
|
|
424
|
+
Compare the **`Document` instance**, never `document.id`. The session
|
|
425
|
+
hands you a fresh instance on every open, and a version preview is a
|
|
426
|
+
new instance with the same id, so an id comparison would skip the
|
|
427
|
+
rebind and strand your editor on the previous state.
|
|
428
|
+
|
|
429
|
+
### Read and mutate
|
|
430
|
+
|
|
431
|
+
The primary editing surface is a **typed container handle**:
|
|
432
|
+
`doc.map<T>(key)` returns a scoped reference to one root container,
|
|
433
|
+
and its `read`, `mutate`, and `subscribe` only see (and only pay for)
|
|
434
|
+
that container. A large document stays cheap because your editor
|
|
435
|
+
touches the slice it cares about instead of walking the whole
|
|
436
|
+
document.
|
|
437
|
+
|
|
438
|
+
```ts
|
|
439
|
+
type Wall = { type: 'wall'; x: number; z: number };
|
|
440
|
+
|
|
441
|
+
const walls = doc.map<Wall>('walls');
|
|
442
|
+
|
|
443
|
+
walls.mutate((m) => { m[id] = { type: 'wall', x: 0, z: 0 }; }); // add
|
|
444
|
+
walls.mutate((m) => { m[id].x = 42; }); // field edit
|
|
445
|
+
walls.mutate((m) => { delete m[id]; }); // remove
|
|
446
|
+
|
|
447
|
+
const all = walls.read(); // Record<string, Wall>
|
|
448
|
+
const one = walls.get(id); // Wall | undefined
|
|
449
|
+
```
|
|
450
|
+
|
|
451
|
+
Handles are cached per key (`doc.map('walls') === doc.map('walls')`),
|
|
452
|
+
so they compare stable across renders. `doc.list<T>(key)` is the same
|
|
453
|
+
surface for a root list; its mutate draft is a plain array proxy, and
|
|
454
|
+
indexed access returns a live element, so find an index then edit it in
|
|
455
|
+
place:
|
|
456
|
+
|
|
457
|
+
```ts
|
|
458
|
+
doc.list<Col>('cols').mutate((l) => {
|
|
459
|
+
const i = l.findIndex((c) => c.id === colId);
|
|
460
|
+
l[i].name = name; // or l[i] = { ...l[i], name }, or l.splice(i, 1)
|
|
461
|
+
});
|
|
462
|
+
```
|
|
463
|
+
|
|
464
|
+
For a keyed collection that has to **stay in a stable order** (a list
|
|
465
|
+
of notes, layers, or steps), use `doc.orderedMap<T>(mapKey, orderKey)`
|
|
466
|
+
rather than a plain map with an app-computed sort key. It binds the map
|
|
467
|
+
to a sibling order list and keeps them in lockstep: a new key appends in
|
|
468
|
+
insertion order and a delete prunes from the order, both in the same
|
|
469
|
+
commit as the map edit. There is no counter or index to derive a sort
|
|
470
|
+
key from, which is what makes a hand-rolled key wrong here (a count is
|
|
471
|
+
not stable across deletes, so a later entry can sort before an earlier
|
|
472
|
+
one). Read it in order with `ordered()` or `keys()`.
|
|
473
|
+
|
|
474
|
+
```ts
|
|
475
|
+
const notes = doc.orderedMap<Note>('notes', 'note_order');
|
|
476
|
+
notes.mutate((m) => { m[id] = { text: '' }; }); // appends to the order
|
|
477
|
+
notes.mutate((m) => { m[id].text = 'hi'; }); // field edit; order untouched
|
|
478
|
+
for (const { key, value } of notes.ordered()) renderRow(key, value);
|
|
479
|
+
```
|
|
480
|
+
|
|
481
|
+
A field edit leaves the order alone, so its `subscribe` fire carries
|
|
482
|
+
only `set` / `deleted`; a change that moves the order also carries
|
|
483
|
+
`change.order` (the live keys in order). Re-sequence your rows when
|
|
484
|
+
`change.order` is present and patch in place otherwise, so a field echo
|
|
485
|
+
never disturbs the row a user is editing.
|
|
486
|
+
|
|
487
|
+
The doc-wide `doc.mutate(fn)` remains as a simpler fallback for tiny
|
|
488
|
+
documents and one-liner integrations (a proxy over the default
|
|
489
|
+
`entities` root). It operates on the same state as the handles, so an
|
|
490
|
+
app can mix the two freely.
|
|
491
|
+
|
|
492
|
+
Two mutation rules:
|
|
493
|
+
|
|
494
|
+
- **A nested list read through a parent proxy is a detached
|
|
495
|
+
snapshot**, so `m[id].tags.push(x)` does not persist. Assign a whole
|
|
496
|
+
new array (`m[id].tags = [...m[id].tags, x]`), or take a handle for
|
|
497
|
+
the list itself (`parent.list(key)`). Nested **maps** walked through
|
|
498
|
+
the proxy are live; the snapshot rule applies to lists only.
|
|
499
|
+
- **Don't write `undefined`.** Mutations JSON-serialise, so
|
|
500
|
+
`undefined` inside an array becomes `null` and `undefined` property
|
|
501
|
+
values are dropped. Use `null`, length checks, or `'key' in obj`
|
|
502
|
+
instead.
|
|
503
|
+
|
|
504
|
+
A document starts **empty**, and the user's first action is their
|
|
505
|
+
first undoable edit. Don't seed a baseline from the client: a faked
|
|
506
|
+
"initial state" write is an ordinary edit, which means Ctrl-Z can
|
|
507
|
+
delete it unexpectedly. Content the user asked for (their first add) is
|
|
508
|
+
safe to persist; auto-written content they didn't ask for is not.
|
|
509
|
+
|
|
510
|
+
### Subscribing
|
|
511
|
+
|
|
512
|
+
`handle.subscribe(cb)` is one channel for every change, whatever
|
|
513
|
+
produced it: your own edit, a remote peer's, an undo, a version
|
|
514
|
+
checkout. It fires once on registration with the whole container as
|
|
515
|
+
`change.set`, then on every change, and each fire costs O(changed)
|
|
516
|
+
regardless of document size. Because your own edits arrive here too,
|
|
517
|
+
you render from one place and never patch a second copy at the edit
|
|
518
|
+
site, where a field updates but a header showing the same value does
|
|
519
|
+
not.
|
|
520
|
+
|
|
521
|
+
```ts
|
|
522
|
+
const stop = walls.subscribe((change) => {
|
|
523
|
+
for (const [id, wall] of change.set) reconcileWall(id, wall); // added or updated
|
|
524
|
+
for (const id of change.deleted) removeWall(id); // removed
|
|
525
|
+
});
|
|
526
|
+
```
|
|
527
|
+
|
|
528
|
+
**Reconcile, don't rebuild, and skip the focused input.** The input
|
|
529
|
+
the user is typing in already shows what they typed, and a peer's
|
|
530
|
+
conflicting value should resolve when they blur the field, not by
|
|
531
|
+
yanking the caret mid-keystroke.
|
|
532
|
+
|
|
533
|
+
In a declarative framework, your own edit echoes back through
|
|
534
|
+
`subscribe` and re-renders the field mid-keystroke. Don't bind an editable input's value to a reactive
|
|
535
|
+
expression derived from the document. Bind it to a **local edit
|
|
536
|
+
buffer** (`v-model` on a per-field ref, `useState`), write the buffer
|
|
537
|
+
through to the document on input, and copy the document's value back
|
|
538
|
+
into the buffer only for fields that are not focused.
|
|
539
|
+
|
|
540
|
+
**`subscribe` is deferred; `read` and `get` are not.** A fire lands on
|
|
541
|
+
a microtask after the commit, so for the instant between your own
|
|
542
|
+
`mutate` and its echo a value you cached from the previous fire is
|
|
543
|
+
stale. Rendering never notices, because the echo repaints right after,
|
|
544
|
+
but a **synchronous read-after-write** does: allocating the next id,
|
|
545
|
+
checking a new name against the current set, or otherwise writing and
|
|
546
|
+
then immediately reading the derived state. Read those from `read()` /
|
|
547
|
+
`get()`, which always reflect the edit you just made, rather than from
|
|
548
|
+
a projection you keep in sync only through `subscribe`. The failure
|
|
549
|
+
mode is a mirror that you write through `mutate` and read back
|
|
550
|
+
synchronously in the same turn: the read runs before the subscription
|
|
551
|
+
that would have updated the mirror, so it sees the pre-write picture.
|
|
552
|
+
|
|
553
|
+
A `list` handle has no per-key delta; its subscriber takes no
|
|
554
|
+
arguments and re-reads (`list.subscribe(() => render(list.read()))`).
|
|
555
|
+
An `orderedMap` fire also carries `change.order` (the live ids in
|
|
556
|
+
order) when the order moves. The optional second callback argument
|
|
557
|
+
`origin` (`{ source: 'local' | 'remote' | 'undo' | 'redo' |
|
|
558
|
+
'checkout' }`) exists for attribution, such as highlighting a peer's
|
|
559
|
+
edit; a plain render ignores it.
|
|
560
|
+
|
|
561
|
+
`doc.subscribe(cb)` is the catch-all for events not tied to one
|
|
562
|
+
container (saving-count ticks, branch-list changes). Use it for status
|
|
563
|
+
surfaces, and render the editor from handles rather than from a
|
|
564
|
+
`doc.subscribe(() => renderAll())` that redraws on every tick.
|
|
565
|
+
|
|
566
|
+
### Undo, checkpoints, and version history
|
|
567
|
+
|
|
568
|
+
The `undo` and `redo` commands in the session's command list are fully
|
|
569
|
+
wired: the toolbar renders them, and keyboard shortcuts reach them
|
|
570
|
+
through `session.run`. The undo stack is local-peer-scoped, so
|
|
571
|
+
`undo()` only reverts commits this client made and a collaborator's
|
|
572
|
+
edits stay untouched. Outside the session, call `doc.undo()` /
|
|
573
|
+
`doc.redo()` directly and read the enabled state via
|
|
574
|
+
`doc.subscribeUndoState(cb)`.
|
|
575
|
+
|
|
576
|
+
A **checkpoint** (`doc.createCheckpoint()`) is both the Ctrl-Z
|
|
577
|
+
boundary and a marker on the document's version-history timeline, so
|
|
578
|
+
checkpoint timing sets both granularities. Auto-seals fire 60 seconds after
|
|
579
|
+
each burst of local edits settles, which means an app that does
|
|
580
|
+
nothing still gets usable undo boundaries and a usable history pane.
|
|
581
|
+
Tune it to your app's shape:
|
|
582
|
+
|
|
583
|
+
| Your app shape | What to do | Result |
|
|
584
|
+
| --------------------------------------- | ------------------------------------------------ | ------------------------------------------ |
|
|
585
|
+
| Default / no opinion | Nothing. | Auto-seal every 60s of idle. |
|
|
586
|
+
| Drag / brush / tool-stroke (3D, canvas) | `void doc.createCheckpoint()` on `pointerup`. | One drag = one Ctrl-Z. |
|
|
587
|
+
| Text editor / fast bursts | `doc.setAutoCheckpointIntervalMs(2000)` at boot. | Each typing pause becomes a boundary. |
|
|
588
|
+
| Explicit-only | `doc.setAutoCheckpointIntervalMs(0)` | Only deliberate `createCheckpoint` calls. |
|
|
589
|
+
|
|
590
|
+
`createCheckpoint()` can reject on a rate limit, and a missed seal is
|
|
591
|
+
harmless, so call it fire-and-forget.
|
|
592
|
+
|
|
593
|
+
The SDK renders version preview itself: while an earlier version is
|
|
594
|
+
showing, `mountToolbar` / `mountHeader` show the "viewing an earlier
|
|
595
|
+
version" banner and return-to-live control (mount `mountVersionPreview`
|
|
596
|
+
for a fully custom shell). You handle your own edit controls:
|
|
597
|
+
`doc.readOnly` flips `true` during a preview (and for a view-only share
|
|
598
|
+
link, and while a document is still loading), so gate your custom tools
|
|
599
|
+
on it, and `doc.mutate` throws while read-only.
|
|
600
|
+
|
|
601
|
+
### Reacting to deletes
|
|
602
|
+
|
|
603
|
+
`doc.onGone(cb)` fires once when the server says the document is no
|
|
604
|
+
longer available (someone deleted it through the share dialog, for
|
|
605
|
+
example). The session reacts on its own, returning to the empty state
|
|
606
|
+
on the user's own delete or the `error` state when someone else deletes
|
|
607
|
+
the open document, so this hook exists for your app's own cleanup:
|
|
608
|
+
|
|
609
|
+
```ts
|
|
610
|
+
doc.onGone((reason) => {
|
|
611
|
+
showToast(`"${docName}" was ${reason}.`);
|
|
612
|
+
teardownEditor();
|
|
613
|
+
});
|
|
614
|
+
```
|
|
615
|
+
|
|
616
|
+
### Thumbnails
|
|
617
|
+
|
|
618
|
+
For a canvas app, one `useCanvas` call gives every document a live
|
|
619
|
+
thumbnail on its dashboard card:
|
|
620
|
+
|
|
621
|
+
```ts
|
|
622
|
+
Brass.session.useCanvas(() => document.querySelector('canvas'));
|
|
623
|
+
```
|
|
624
|
+
|
|
625
|
+
The SDK debounces about a second after each local edit, screenshots
|
|
626
|
+
the canvas, downscales, and uploads. **WebGL canvases must set
|
|
627
|
+
`preserveDrawingBuffer: true`** (Three.js:
|
|
628
|
+
`new THREE.WebGLRenderer({ preserveDrawingBuffer: true })`); without
|
|
629
|
+
it the read-back is a blank PNG, and the SDK warns once if the flag is
|
|
630
|
+
missing. Non-canvas apps skip this entirely; a document without a
|
|
631
|
+
thumbnail shows a blank tile and still gets its version-history
|
|
632
|
+
timeline.
|
|
633
|
+
|
|
634
|
+
## Presence (live cursors and who's here)
|
|
635
|
+
|
|
636
|
+
`doc.presence` shows who else is in the document and what they are
|
|
637
|
+
doing: a roster of the other live participants plus a small per-user
|
|
638
|
+
`state` (cursor, camera, selection). It is non-null on any open
|
|
639
|
+
writable document, and it is fully ephemeral; nothing survives a
|
|
640
|
+
disconnect. `Brass.session.presence` is the same client for the
|
|
641
|
+
session's open document, so use whichever handle you have.
|
|
642
|
+
|
|
643
|
+
Presence requires publishing your own state and rendering everyone
|
|
644
|
+
else's. Publish it on every pointer, camera, or selection move
|
|
645
|
+
(throttling is internal, about 10 Hz, so call as often as you like),
|
|
646
|
+
and render theirs in the working surface. A roster of initials in the
|
|
647
|
+
header shows who is present, not where they are working.
|
|
648
|
+
|
|
649
|
+
```ts
|
|
650
|
+
canvas.addEventListener('pointermove', (e) => {
|
|
651
|
+
const p = pointerToWorld(e); // your app's screen-to-world unproject
|
|
652
|
+
Brass.session.presence?.set({ state: { cursor: { x: p.x, y: p.y, z: p.z } } });
|
|
653
|
+
});
|
|
654
|
+
```
|
|
655
|
+
|
|
656
|
+
`cursor` is a 3D point in document world space (meters, right-handed,
|
|
657
|
+
Z-up; a 2D app passes `z: 0`), and an entity or text editor publishes
|
|
658
|
+
`selection` instead. `Brass.mountToolbar` and
|
|
659
|
+
`Brass.mountPresenceRoster` render the header roster for you. To
|
|
660
|
+
render remote cursors, diff the roster by `connectionId` on the
|
|
661
|
+
`change` event; the full recipe, coordinate conventions, and types are
|
|
662
|
+
in [`docs/presence.md`](./docs/presence.md).
|
|
663
|
+
|
|
664
|
+
## Streams (partitioning large documents)
|
|
665
|
+
|
|
666
|
+
You can split a document into named streams, each an independently
|
|
667
|
+
loaded unit of its state. Opening a document costs only the
|
|
668
|
+
streams your app touches, so a large design partitioned by discipline
|
|
669
|
+
opens at the size of one discipline, not the whole building:
|
|
670
|
+
|
|
671
|
+
```ts
|
|
672
|
+
const hvac = await doc.stream('hvac'); // downloads + subscribes hvac only
|
|
673
|
+
const records = hvac.orderedMap('instances', 'instance_order');
|
|
674
|
+
records.mutate((m) => {
|
|
675
|
+
m[id] = { type, params };
|
|
676
|
+
});
|
|
677
|
+
```
|
|
678
|
+
|
|
679
|
+
`Brass.openDocumentById(docId, { stream })` opens the document with that
|
|
680
|
+
stream as its own state, so an app that works in one part of a large
|
|
681
|
+
document downloads only that part; it defaults to `'default'`, the stream
|
|
682
|
+
every document has. `doc.getOrCreateStream(name)` opens the document's
|
|
683
|
+
stream of that name and makes it when the document does not hold it yet
|
|
684
|
+
(idempotent, and concurrent callers converge on one stream, so an app
|
|
685
|
+
reaches for the stream it expects on every open); `doc.stream(name)`
|
|
686
|
+
opens one the document already holds and rejects any other name, and
|
|
687
|
+
resolves the name the document was opened at back to the document itself;
|
|
688
|
+
`doc.listStreams()` lists them. Code that iterates stream names
|
|
689
|
+
therefore handles partitioned and unpartitioned documents with one path.
|
|
690
|
+
A stream costs one extra round trip, so add one when it carries real
|
|
691
|
+
content: partition by load unit (a discipline, a heavy layer), not by
|
|
692
|
+
concept.
|
|
693
|
+
|
|
694
|
+
A schema names the streams it expects under `streams`. An app that
|
|
695
|
+
follows references between records reads every stream the document holds
|
|
696
|
+
(`doc.listStreams()`). A document can hold streams your schema does not
|
|
697
|
+
name, and your app leaves those alone. Naming a stream is how records
|
|
698
|
+
you work with (clash findings, review notes, a cache) ride in the
|
|
699
|
+
document: they sync and version with it, and an exporter composes only
|
|
700
|
+
the streams it declares, so records in a stream it does not name stay
|
|
701
|
+
out of the exported file. See [`docs/streams.md`](./docs/streams.md) and
|
|
702
|
+
[`docs/schemas.md`](./docs/schemas.md).
|
|
703
|
+
|
|
704
|
+
## Imported files
|
|
705
|
+
|
|
706
|
+
Start from what your app will open: the file the app exists for,
|
|
707
|
+
whether it is your own or an example from a user. Add it to an
|
|
708
|
+
organization (the dashboard, or any app's hosted picker, brings a file
|
|
709
|
+
in) and read what the document it becomes holds; that document decides
|
|
710
|
+
the schema your app declares. Your app opens it the way every document
|
|
711
|
+
opens: through the session and the hosted picker.
|
|
712
|
+
|
|
713
|
+
A document imported from a file (an upload or a drive link) lands in
|
|
714
|
+
one of two shapes, and the document's own page tells you which:
|
|
715
|
+
|
|
716
|
+
- **It holds records under a schema.** An importer for the format
|
|
717
|
+
already exists (an installed app provides it). Adopt the document's
|
|
718
|
+
schema (below) and read and edit the records through the ordinary
|
|
719
|
+
handle surface
|
|
720
|
+
(`doc.orderedMap('instances', 'instance_order')`). Brass re-emits the
|
|
721
|
+
original file on export and drive-push through an installed app that
|
|
722
|
+
serialises the format. A large
|
|
723
|
+
document may spread its records across several streams; if your app
|
|
724
|
+
follows references between records, read every stream the document
|
|
725
|
+
holds, see
|
|
726
|
+
[streams](./docs/streams.md#reading-a-document-spread-across-streams).
|
|
727
|
+
- **It is the stored file, and no schema resolves.** Nothing imports
|
|
728
|
+
the format yet, so your app is its first importer. Register both
|
|
729
|
+
halves of the format at boot: the importer parses the stored file
|
|
730
|
+
into records, the serializer reconstitutes the file from them.
|
|
731
|
+
|
|
732
|
+
```ts
|
|
733
|
+
Brass.registerImporter(async (src, doc) => {
|
|
734
|
+
const bytes = new Uint8Array(await (await fetch(src.url)).arrayBuffer());
|
|
735
|
+
const records = doc.orderedMap('instances', 'instance_order');
|
|
736
|
+
records.mutate((m) => {
|
|
737
|
+
for (const r of parseMyFormat(bytes)) m[r.id] = { type: r.type, params: r.body };
|
|
738
|
+
});
|
|
739
|
+
});
|
|
740
|
+
Brass.registerSerializer((doc) => serializeMyFormat(doc));
|
|
741
|
+
```
|
|
742
|
+
|
|
743
|
+
Brass runs the importer when the file needs importing, and the
|
|
744
|
+
records reach every open client through its normal subscription.
|
|
745
|
+
Declare the `schema` of the records your importer writes in
|
|
746
|
+
`brass-app.json`; importing publishes that schema and stamps it on
|
|
747
|
+
the document, so the next app can adopt it like any imported
|
|
748
|
+
document's. Declare the file match there too
|
|
749
|
+
(`"accepts": [{ "extension": "gpx" }]`) so Brass offers your app when
|
|
750
|
+
someone uploads one.
|
|
751
|
+
|
|
752
|
+
To make your app open documents of an existing file type, adopt that
|
|
753
|
+
type's schema rather than re-derive its record shape. Get a document of
|
|
754
|
+
the shape you target (upload a sample file to your organization, or
|
|
755
|
+
open a document another app produced), copy
|
|
756
|
+
its schema from the document's page in the dashboard (**Download
|
|
757
|
+
brass-app.json**, or `npx @brass-build/cli schema pull --doc <docId>`),
|
|
758
|
+
and keep it verbatim as `schema` in your `brass-app.json`. The schema's family folds
|
|
759
|
+
into your `opens`, so documents of that shape route to your app, and
|
|
760
|
+
building against the copied body keeps you compatible with the other
|
|
761
|
+
apps that edit them. The declared schema is what routes existing
|
|
762
|
+
documents to your app: the picker lists documents by matching your
|
|
763
|
+
declared schema (or `accepts`), so ship the manifest with your first
|
|
764
|
+
deploy.
|
|
765
|
+
|
|
766
|
+
Every app ships a capability manifest at `/.well-known/brass-app.json`
|
|
767
|
+
in its bundle root, next to `index.html`. It declares the schema your
|
|
768
|
+
app works with, the files it accepts, the format it exports, and the
|
|
769
|
+
icon the platform renders as your app's mark. The platform re-reads it
|
|
770
|
+
after each deploy, so publishing your bundle is what registers it, and
|
|
771
|
+
an app that does not serve one registers nothing.
|
|
772
|
+
[`docs/app-manifest.md`](./docs/app-manifest.md) covers where the file
|
|
773
|
+
goes, what each key does, and how to confirm your build carries it.
|
|
774
|
+
|
|
775
|
+
The record-store shape, the conversion recipe, and schemas are covered
|
|
776
|
+
in [`docs/imported-documents.md`](./docs/imported-documents.md) and
|
|
777
|
+
[`docs/schemas.md`](./docs/schemas.md).
|
|
778
|
+
|
|
779
|
+
## Reference
|
|
780
|
+
|
|
781
|
+
`Brass` is the top-level namespace your app calls; its members are
|
|
782
|
+
below. The package's TypeScript types document `Document` and the
|
|
783
|
+
container handles (your editor's autocomplete lists every member and
|
|
784
|
+
signature), and the examples above show them in use, so this reference
|
|
785
|
+
does not repeat them.
|
|
786
|
+
|
|
787
|
+
### `Brass`
|
|
788
|
+
|
|
789
|
+
| Member | Returns |
|
|
790
|
+
| ------------------------------------------------------------ | --------------------------------------- |
|
|
791
|
+
| `Brass.init(appId, options?)` | `Promise<void>` |
|
|
792
|
+
| `Brass.signIn()` | `void` (navigates page) |
|
|
793
|
+
| `Brass.signOut()` | `void` (navigates page) |
|
|
794
|
+
| `Brass.user` | `BrassUser \| null` |
|
|
795
|
+
| `Brass.authStatus` | `'unknown' \| 'signed-in' \| 'signed-out' \| 'guest'` |
|
|
796
|
+
| `Brass.isGuest` | `boolean` (anonymous link viewer) |
|
|
797
|
+
| `Brass.onUserChange(cb)` | `() => void` (unsubscribe) |
|
|
798
|
+
| `Brass.onError(cb)` | `() => void` (unsubscribe) |
|
|
799
|
+
| `Brass.getAccessToken()` | `Promise<string \| null>` |
|
|
800
|
+
| `Brass.session` | `DocumentSession` (see above) |
|
|
801
|
+
| `Brass.mountToolbar(el, options?)` | `SessionToolbarHandle` (`{ tools, destroy }`) |
|
|
802
|
+
| `Brass.createDocument(options?)` | `Promise<OpenedDocument>` (`session.create` drives this) |
|
|
803
|
+
| `Brass.openDocumentById(docId, options?)` | `Promise<OpenedDocument>` (the session's open / pick path drives this) |
|
|
804
|
+
| `Brass.selectDocument()` | `Promise<SelectDocumentResult \| null>` (the pick; also render from the session, see `docs/hosted-ui-advanced.md`) |
|
|
805
|
+
| `Brass.shareDocument(docId)` | `Promise<void>` |
|
|
806
|
+
| `Brass.registerImporter(fn)` | `void` (see `docs/imported-documents.md`) |
|
|
807
|
+
| `Brass.registerSerializer(fn)` | `void` (see `docs/imported-documents.md`) |
|
|
808
|
+
| `Brass.documents` | namespace getter (metadata read, thumbnails, branches for a known doc id) |
|
|
809
|
+
|
|
810
|
+
The composable header mounts (`mountHeader`, `mountFileMenu`,
|
|
811
|
+
`mountDocumentTitle`, `mountUndoRedo`, `mountPresenceRoster`,
|
|
812
|
+
`mountDriveSync`, `mountAccountMenu`, `mountVersionPreview`) live in
|
|
813
|
+
[`docs/custom-chrome.md`](./docs/custom-chrome.md).
|
|
814
|
+
|
|
815
|
+
`OpenedDocument = Document & { readonly id: string }`. Open / create
|
|
816
|
+
methods return `OpenedDocument`; type app-level variables as
|
|
817
|
+
`OpenedDocument` so `doc.id` stays non-null. `BrassUser` is
|
|
818
|
+
`{ sub: string; email?: string; display_name?: string; avatar_url?: string }`.
|
|
819
|
+
|
|
820
|
+
### Errors
|
|
821
|
+
|
|
822
|
+
Every call rejects with one error type, `BrassError`. HTTP calls carry
|
|
823
|
+
`.status: number`, and the hosted-UI launchers (`selectDocument`,
|
|
824
|
+
`shareDocument`, ...) carry `.code` (`'embed_error'`,
|
|
825
|
+
`'not_in_browser'`). A call made without a signed-in session rejects
|
|
826
|
+
synchronously with `.status === 401`.
|
|
827
|
+
|
|
828
|
+
## Guides
|
|
829
|
+
|
|
830
|
+
- [`AGENTS.md`](./AGENTS.md): instructions for AI coding agents, covering the review loop, the UX defaults a generated app should meet, and shipping it to a published URL.
|
|
831
|
+
- [`docs/app-manifest.md`](./docs/app-manifest.md): the `brass-app.json` capability manifest every app ships, where it goes in your bundle, and what each key declares.
|
|
832
|
+
- [`docs/imported-documents.md`](./docs/imported-documents.md): the record store an imported file becomes, `Brass.registerImporter`, `doc.sourceBlob()`, and `Brass.registerSerializer`.
|
|
833
|
+
- [`docs/schemas.md`](./docs/schemas.md): declaring a schema, creating a stream, what lets an app open a document, and what breaks that.
|
|
834
|
+
- [`docs/custom-chrome.md`](./docs/custom-chrome.md): composable header pieces, and rendering `session.commands` yourself for a fully custom shell.
|
|
835
|
+
- [`docs/presence.md`](./docs/presence.md): remote cursors, roster rendering, coordinate conventions, full types.
|
|
836
|
+
- [`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.
|