@brass-build/client 0.1.0 → 0.2.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 +72 -63
- package/CHANGELOG.md +86 -0
- package/README.md +317 -231
- package/dist/auth-flow.d.ts +10 -1
- package/dist/auth-flow.d.ts.map +1 -1
- package/dist/auth-flow.js +64 -20
- package/dist/auth-flow.js.map +1 -1
- package/dist/client.d.ts +3 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +217 -75
- package/dist/client.js.map +1 -1
- package/dist/document-engine.d.ts +4 -0
- package/dist/document-engine.d.ts.map +1 -0
- package/dist/document-engine.js +9 -0
- package/dist/document-engine.js.map +1 -0
- package/dist/document-streams.d.ts +8 -1
- package/dist/document-streams.d.ts.map +1 -1
- package/dist/document-streams.js +79 -8
- package/dist/document-streams.js.map +1 -1
- package/dist/documents-namespace.d.ts.map +1 -1
- package/dist/documents-namespace.js +19 -3
- package/dist/documents-namespace.js.map +1 -1
- package/dist/documents.d.ts +43 -14
- package/dist/documents.d.ts.map +1 -1
- package/dist/documents.js +871 -223
- package/dist/documents.js.map +1 -1
- package/dist/embed-protocol.d.ts +3 -0
- package/dist/embed-protocol.d.ts.map +1 -0
- package/dist/embed-protocol.js +26 -0
- package/dist/embed-protocol.js.map +1 -0
- package/dist/emitter.d.ts.map +1 -1
- package/dist/emitter.js +8 -1
- package/dist/emitter.js.map +1 -1
- package/dist/export-guest.d.ts.map +1 -1
- package/dist/export-guest.js +14 -6
- package/dist/export-guest.js.map +1 -1
- package/dist/fetch-deadline.d.ts +22 -0
- package/dist/fetch-deadline.d.ts.map +1 -0
- package/dist/fetch-deadline.js +96 -0
- package/dist/fetch-deadline.js.map +1 -0
- package/dist/fetch-retry.d.ts +1 -0
- package/dist/fetch-retry.d.ts.map +1 -1
- package/dist/fetch-retry.js +12 -5
- package/dist/fetch-retry.js.map +1 -1
- package/dist/handle-types.d.ts +150 -0
- package/dist/handle-types.d.ts.map +1 -0
- package/dist/handle-types.js +8 -0
- package/dist/handle-types.js.map +1 -0
- package/dist/handles.d.ts +48 -48
- package/dist/handles.d.ts.map +1 -1
- package/dist/handles.js +3122 -501
- package/dist/handles.js.map +1 -1
- package/dist/hosted-runtime.d.ts.map +1 -1
- package/dist/hosted-runtime.js +8 -12
- package/dist/hosted-runtime.js.map +1 -1
- package/dist/hosted-ui.d.ts +0 -1
- package/dist/hosted-ui.d.ts.map +1 -1
- package/dist/hosted-ui.js +29 -24
- package/dist/hosted-ui.js.map +1 -1
- package/dist/importer.d.ts.map +1 -1
- package/dist/importer.js +253 -8
- package/dist/importer.js.map +1 -1
- package/dist/index.d.ts +10 -10
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +11 -2
- package/dist/index.js.map +1 -1
- package/dist/internal.d.ts +1 -1
- package/dist/internal.d.ts.map +1 -1
- package/dist/internal.js.map +1 -1
- package/dist/join-coverage.d.ts +12 -0
- package/dist/join-coverage.d.ts.map +1 -0
- package/dist/join-coverage.js +37 -0
- package/dist/join-coverage.js.map +1 -0
- package/dist/jwt.d.ts.map +1 -1
- package/dist/jwt.js +5 -1
- package/dist/jwt.js.map +1 -1
- package/dist/launch-context.js +2 -2
- package/dist/launch-context.js.map +1 -1
- package/dist/mutate-batching.d.ts +25 -2
- package/dist/mutate-batching.d.ts.map +1 -1
- package/dist/mutate-batching.js +57 -10
- package/dist/mutate-batching.js.map +1 -1
- package/dist/open-document.d.ts +15 -1
- package/dist/open-document.d.ts.map +1 -1
- package/dist/open-document.js +1851 -321
- package/dist/open-document.js.map +1 -1
- package/dist/presence.d.ts +28 -0
- package/dist/presence.d.ts.map +1 -1
- package/dist/presence.js +149 -7
- package/dist/presence.js.map +1 -1
- package/dist/record-paging.d.ts +336 -71
- package/dist/record-paging.d.ts.map +1 -1
- package/dist/record-paging.js +591 -127
- package/dist/record-paging.js.map +1 -1
- package/dist/selection-persistence.d.ts.map +1 -1
- package/dist/selection-persistence.js +11 -1
- package/dist/selection-persistence.js.map +1 -1
- package/dist/serializer.d.ts +30 -1
- package/dist/serializer.d.ts.map +1 -1
- package/dist/session-persistence.d.ts.map +1 -1
- package/dist/session-persistence.js +9 -8
- package/dist/session-persistence.js.map +1 -1
- package/dist/session-toolbar.d.ts +0 -1
- package/dist/session-toolbar.d.ts.map +1 -1
- package/dist/session-toolbar.js +226 -75
- package/dist/session-toolbar.js.map +1 -1
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +87 -18
- package/dist/session.js.map +1 -1
- package/dist/tail-ops.d.ts +14 -4
- package/dist/tail-ops.d.ts.map +1 -1
- package/dist/tail-ops.js +75 -15
- package/dist/tail-ops.js.map +1 -1
- package/dist/topic-lifecycle.d.ts +4 -0
- package/dist/topic-lifecycle.d.ts.map +1 -1
- package/dist/topic-lifecycle.js +25 -1
- package/dist/topic-lifecycle.js.map +1 -1
- package/dist/types.d.ts +47 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/undo.d.ts +2 -3
- package/dist/undo.d.ts.map +1 -1
- package/dist/undo.js +11 -0
- package/dist/undo.js.map +1 -1
- package/dist/unload-guard.d.ts +5 -0
- package/dist/unload-guard.d.ts.map +1 -0
- package/dist/unload-guard.js +88 -0
- package/dist/unload-guard.js.map +1 -0
- package/dist/version-bar.d.ts +4 -3
- package/dist/version-bar.d.ts.map +1 -1
- package/dist/version-bar.js +140 -19
- package/dist/version-bar.js.map +1 -1
- package/dist/version.d.ts +2 -2
- package/dist/version.js +1 -1
- package/dist/vite.d.ts +18 -2
- package/dist/vite.d.ts.map +1 -1
- package/dist/vite.js +133 -8
- package/dist/vite.js.map +1 -1
- package/dist/ws-client.d.ts +23 -3
- package/dist/ws-client.d.ts.map +1 -1
- package/dist/ws-client.js +339 -89
- package/dist/ws-client.js.map +1 -1
- package/dist/ws-frame-limit.d.ts.map +1 -1
- package/dist/ws-frame-limit.js +19 -12
- package/dist/ws-frame-limit.js.map +1 -1
- package/docs/app-manifest.md +7 -8
- package/docs/custom-chrome.md +17 -20
- package/docs/hosted-ui-advanced.md +14 -17
- package/docs/imported-documents.md +53 -197
- package/docs/presence.md +45 -21
- package/docs/schemas.md +12 -17
- package/docs/streams.md +55 -57
- package/package.json +4 -3
- package/src/auth-flow.ts +90 -23
- package/src/client.ts +225 -78
- package/src/document-engine.ts +15 -0
- package/src/document-signals.ts +7 -7
- package/src/document-streams.ts +95 -9
- package/src/documents-namespace.ts +18 -3
- package/src/documents.ts +983 -277
- package/src/embed-protocol.ts +25 -0
- package/src/emitter.ts +8 -1
- package/src/export-guest.ts +15 -9
- package/src/fetch-deadline.ts +119 -0
- package/src/fetch-retry.ts +14 -5
- package/src/handle-types.ts +303 -0
- package/src/handles.ts +3406 -683
- package/src/hosted-runtime.ts +8 -12
- package/src/hosted-ui.ts +28 -32
- package/src/importer.ts +261 -8
- package/src/index.ts +29 -18
- package/src/internal.ts +1 -0
- package/src/join-coverage.ts +42 -0
- package/src/jwt.ts +8 -1
- package/src/launch-context.ts +2 -2
- package/src/mutate-batching.ts +60 -11
- package/src/open-document.ts +2355 -515
- package/src/presence.ts +210 -11
- package/src/record-paging.ts +687 -137
- package/src/selection-persistence.ts +10 -1
- package/src/serializer.ts +33 -1
- package/src/session-persistence.ts +9 -8
- package/src/session-toolbar.ts +249 -80
- package/src/session.ts +91 -17
- package/src/tail-ops.ts +76 -10
- package/src/topic-lifecycle.ts +31 -1
- package/src/types.ts +56 -4
- package/src/undo.ts +13 -5
- package/src/unload-guard.ts +91 -0
- package/src/version-bar.ts +163 -25
- package/src/version.ts +1 -1
- package/src/vite.ts +175 -10
- package/src/ws-client.ts +392 -92
- package/src/ws-frame-limit.ts +19 -12
|
@@ -1,131 +1,24 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Handling a file format
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
is no client import call to make. How much of the file mapping you write
|
|
7
|
-
yourself depends on whether the format already has an importer:
|
|
3
|
+
A user uploads a file or opens a drive item, and Brass turns it into a
|
|
4
|
+
document your app opens like any other. What your app writes depends on
|
|
5
|
+
whether the format already has an importer.
|
|
8
6
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
compatibility against. The schema is also the record contract: its
|
|
16
|
-
`description`s declare how each field is encoded, so an analysis app
|
|
17
|
-
derives what it computes (a tree, geometry, quantities) from the
|
|
18
|
-
records the schema describes. A large document spreads those records
|
|
19
|
-
across the streams the schema names, so read them all (see [Reading a
|
|
20
|
-
document spread across
|
|
21
|
-
streams](./streams.md#reading-a-document-spread-across-streams)).
|
|
22
|
-
- **Any other file**: the document is an opaque file (source
|
|
23
|
-
`schema_type` is `brass:file`). Your app is the importer/exporter:
|
|
24
|
-
it registers its parser with `Brass.registerImporter` (Brass runs it
|
|
25
|
-
on the stored bytes and stamps your schema on the document) and the
|
|
26
|
-
inverse with `Brass.registerSerializer`.
|
|
7
|
+
**A format someone already imports.** The parsed records are the
|
|
8
|
+
document, and Brass re-emits the original on export and drive-push
|
|
9
|
+
through an installed app that serialises the format. Your app is an
|
|
10
|
+
editor: declare the document's schema in `brass-app.json` so peers can
|
|
11
|
+
open it, and read the records through the ordinary handle surface. See
|
|
12
|
+
[`schemas.md`](./schemas.md).
|
|
27
13
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
14
|
+
**Any other file.** The document is an opaque file (source `schema_type`
|
|
15
|
+
is `brass:file`) until your app parses it. Register the parse half with
|
|
16
|
+
`Brass.registerImporter` and the inverse with `Brass.registerSerializer`.
|
|
31
17
|
|
|
32
|
-
##
|
|
18
|
+
## Registering an importer
|
|
33
19
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
`'#23'` whose values are `{ type, params }` records) and
|
|
37
|
-
`instance_order` (the same keys in file order, which the serializer
|
|
38
|
-
walks to re-emit the file). Every record in `instances` must have its
|
|
39
|
-
key in `instance_order`, or the serializer drops it.
|
|
40
|
-
|
|
41
|
-
**Reach for both as one handle:
|
|
42
|
-
`doc.orderedMap('instances', 'instance_order')`.** The ordered map
|
|
43
|
-
keeps the order list in step for you: an add appends the key, a delete
|
|
44
|
-
prunes it, both in one commit (one undo step). You never write
|
|
45
|
-
`instance_order` by hand.
|
|
46
|
-
|
|
47
|
-
```ts
|
|
48
|
-
const records = doc.orderedMap('instances', 'instance_order');
|
|
49
|
-
|
|
50
|
-
records.read(); // { '#1': { type, params }, ... }
|
|
51
|
-
records.get('#1'); // one record, or undefined
|
|
52
|
-
records.ordered(); // [{ key, value }, ...] in file order
|
|
53
|
-
records.keys(); // ['#1', '#2', ...] in file order
|
|
54
|
-
|
|
55
|
-
records.mutate((m) => {
|
|
56
|
-
m[newKey] = { type, params }; // add: appended to order
|
|
57
|
-
delete m['#7']; // delete: pruned from order
|
|
58
|
-
m['#3'].params = [...]; // field edit: order untouched
|
|
59
|
-
});
|
|
60
|
-
```
|
|
61
|
-
|
|
62
|
-
`type` is the format's record tag, and `params` is the record body, whose
|
|
63
|
-
shape is format-specific. Values read back are detached copies.
|
|
64
|
-
|
|
65
|
-
**Edit a record by reassigning its whole `params`.** The record is the
|
|
66
|
-
unit of change: the value you read back is a detached copy, so
|
|
67
|
-
mutating it element-wise does not persist. Build the new `params` and
|
|
68
|
-
assign it. Because edits live in the canonical store, the original
|
|
69
|
-
format re-emits faithfully on export.
|
|
70
|
-
|
|
71
|
-
**A newly added record lands at the end of source order.** When the
|
|
72
|
-
order is structural (a record must sit inside a section or a parent's
|
|
73
|
-
child range to re-emit validly), don't lean on physical record order:
|
|
74
|
-
have your serializer reconstruct the structure from the records' own
|
|
75
|
-
declared section / parent on the way out.
|
|
76
|
-
|
|
77
|
-
## Cost by change: subscribe, don't re-read
|
|
78
|
-
|
|
79
|
-
An imported model is commonly hundreds of thousands of records, so a
|
|
80
|
-
per-change `read()` to rebuild your view is O(file) per keystroke. The
|
|
81
|
-
subscription hands you a `change` on every fire: the first fire
|
|
82
|
-
carries the whole store as `change.set` (build your view there), and
|
|
83
|
-
every later fire names only the changed records (one path for your own
|
|
84
|
-
edits, remote edits, and post-open hydration alike). Don't memoise the
|
|
85
|
-
derivation on a record-count fingerprint: an in-place edit keeps the
|
|
86
|
-
count, so the memo reads back stale. And keep view-only changes (pan,
|
|
87
|
-
zoom, selection) off the content paint.
|
|
88
|
-
|
|
89
|
-
## How a format maps to records
|
|
90
|
-
|
|
91
|
-
Map every format to records by the same recipe, which is what lets
|
|
92
|
-
independently-built apps predict and share a format's records:
|
|
93
|
-
|
|
94
|
-
1. **One record per element the format enumerates as its own
|
|
95
|
-
addressable item** (a STEP instance, an entity, a named element).
|
|
96
|
-
Inline only anonymous leaf scalars that have no identity of their
|
|
97
|
-
own. The record is the concurrent-edit unit: its whole `params`
|
|
98
|
-
merges last-writer-wins, so keep `params` a small plain value and
|
|
99
|
-
never pack several independently-edited values into one record.
|
|
100
|
-
2. **`type` is the format's own class tag, verbatim**: no renaming, no
|
|
101
|
-
prefix.
|
|
102
|
-
3. **The key is the format's own stable id, verbatim.** When the
|
|
103
|
-
format has no native id, synthesize one deterministically from the
|
|
104
|
-
record's own content, never from its position in the file (a
|
|
105
|
-
positional index is not stable across a re-import) and never at
|
|
106
|
-
random.
|
|
107
|
-
4. **`params` is the object's own fields as a plain value**,
|
|
108
|
-
reassigned whole to edit it.
|
|
109
|
-
5. **A relationship is its own record only when the format makes it an
|
|
110
|
-
object**, and otherwise it stays a reference field, on the **child**
|
|
111
|
-
pointing up to its parent. A mutable child-list on the parent is a
|
|
112
|
-
concurrently-edited array, so two people adding different children
|
|
113
|
-
collide.
|
|
114
|
-
6. **Decompose only what has a model.** Named objects shred into
|
|
115
|
-
records; binary bulk is referenced by hash, not inlined; a file
|
|
116
|
-
with no object model stays an opaque `brass:file` blob.
|
|
117
|
-
|
|
118
|
-
To build against a format someone else imported, read its schema: it
|
|
119
|
-
declares exactly how the importer encoded each field into `params`
|
|
120
|
-
(value shapes, the reference convention) in its `description`s. Get it
|
|
121
|
-
from the document's dashboard page (**Download brass-app.json**), and
|
|
122
|
-
declare that copy in `brass-app.json` to route those documents to your
|
|
123
|
-
app. See [`schemas.md`](./schemas.md).
|
|
124
|
-
|
|
125
|
-
## Registering your format's importer (`Brass.registerImporter`)
|
|
126
|
-
|
|
127
|
-
For a file kept as-is, register your parse half once at boot, and Brass
|
|
128
|
-
runs it when an import is needed:
|
|
20
|
+
Register your parse half once at boot, and Brass runs it when an import
|
|
21
|
+
is needed:
|
|
129
22
|
|
|
130
23
|
```ts
|
|
131
24
|
Brass.registerImporter(async (src, doc) => {
|
|
@@ -140,89 +33,52 @@ Brass.registerImporter(async (src, doc) => {
|
|
|
140
33
|
});
|
|
141
34
|
```
|
|
142
35
|
|
|
143
|
-
The importer receives the stored source file (a short-lived download
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
through the ordinary handles. `DocumentImporter` types the callback.
|
|
36
|
+
The importer receives the stored source file (a short-lived download URL
|
|
37
|
+
plus the file's name, type, and size, with the bytes not inlined) and the
|
|
38
|
+
open document. `DocumentImporter` types the callback.
|
|
147
39
|
|
|
148
40
|
Brass decides when an import is needed and runs your importer once. A
|
|
149
41
|
document that already holds its records, or has no stored file, is left
|
|
150
42
|
alone, and the records reach every open client through its normal
|
|
151
|
-
subscription. Declare the `schema` of the records your
|
|
152
|
-
|
|
153
|
-
stamps it on the document, so the document routes and resolves its
|
|
154
|
-
schema like any imported document.
|
|
43
|
+
subscription. Declare the `schema` of the records your importer writes in
|
|
44
|
+
`brass-app.json`: importing publishes it and stamps it on the document.
|
|
155
45
|
|
|
156
|
-
Sealing the write with `doc.createCheckpoint()`
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
keep the records a default view reads in the default stream, and move
|
|
163
|
-
the bulk (geometry, an index most opens skip) onto streams you create. Name those streams in your schema's `streams`, and a reader
|
|
164
|
-
merges them the same way it merges any format's:
|
|
165
|
-
|
|
166
|
-
```ts
|
|
167
|
-
Brass.registerImporter(async (src, doc) => {
|
|
168
|
-
const { structured, geometry } = parseMyFormat(await bytesOf(src));
|
|
169
|
-
doc.orderedMap('instances', 'instance_order').mutate((m) => {
|
|
170
|
-
for (const r of structured) m[r.id] = { type: r.type, params: r.body };
|
|
171
|
-
});
|
|
172
|
-
// A stream your schema names, so your serializer composes it back and
|
|
173
|
-
// any app expecting it merges its records.
|
|
174
|
-
const geom = await doc.getOrCreateStream('geometry');
|
|
175
|
-
geom.orderedMap('instances', 'instance_order').mutate((m) => {
|
|
176
|
-
for (const r of geometry) m[r.id] = { type: r.type, params: r.body };
|
|
177
|
-
});
|
|
178
|
-
await doc.createCheckpoint();
|
|
179
|
-
});
|
|
180
|
-
```
|
|
46
|
+
Sealing the write with `doc.createCheckpoint()` makes the import one undo
|
|
47
|
+
step, so an undo reverts it whole instead of leaving a half-deleted
|
|
48
|
+
model. An importer that throws surfaces through `Brass.onError`. Brass
|
|
49
|
+
retries an import that wrote no records. Once records are written, it
|
|
50
|
+
refuses a later import of that document, because running the importer
|
|
51
|
+
again over them would write each of those records twice.
|
|
181
52
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
computed cache), and your serializer composes only the streams it
|
|
186
|
-
declares, so those stay in the document and out of the file. Nothing
|
|
187
|
-
here is privileged: a document your importer spreads is
|
|
188
|
-
indistinguishable from one the platform produced, and every app that
|
|
189
|
-
declares the same streams reads it the same way. Your serializer
|
|
190
|
-
reassembles it by reading those same streams back
|
|
191
|
-
(`doc.stream('geometry')`).
|
|
53
|
+
Key each record by the format's own stable id. When the format has no
|
|
54
|
+
native id, derive one from the record's own content rather than from its
|
|
55
|
+
position in the file, which is not stable across a re-import.
|
|
192
56
|
|
|
193
|
-
|
|
194
|
-
`src` argument, for code outside the importer that needs the original
|
|
195
|
-
bytes (a "view original" affordance). It rejects with a `BrassError`
|
|
196
|
-
for a document that has no stored original file.
|
|
57
|
+
## Reading the source bytes
|
|
197
58
|
|
|
198
59
|
A source file is a set of one or more byte streams: the primary stream
|
|
199
60
|
(what the filename names) plus any sidecar streams uploaded with it (a
|
|
200
61
|
glTF's `.bin` buffer, a shapefile's `.dbf`). `src.streams` lists every
|
|
201
62
|
stream, primary first, each as `{ name, url, content_type, byte_size }`
|
|
202
63
|
with its own download URL, so an app fetches exactly the streams it
|
|
203
|
-
needs, in parallel. A single-stream file has exactly one entry
|
|
204
|
-
|
|
205
|
-
both cases in one path:
|
|
64
|
+
needs, in parallel. A single-stream file has exactly one entry, so code
|
|
65
|
+
that iterates `streams` handles both cases in one path:
|
|
206
66
|
|
|
207
67
|
```ts
|
|
208
68
|
const byName = new Map(src.streams.map((s) => [s.name, s]));
|
|
209
69
|
const buffer = byName.get('scene.bin'); // undefined when not part of the set
|
|
210
70
|
```
|
|
211
71
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
independently-edited values is one record per cell (keyed by row and
|
|
216
|
-
column), not one record per row. Reconstruct rows for display and for
|
|
217
|
-
the serializer by walking the cell records. The same reasoning bans
|
|
218
|
-
nesting a collaboratively-edited array inside one record.
|
|
72
|
+
`doc.sourceBlob()` is the same read for code outside the importer that
|
|
73
|
+
needs the original bytes (a "view original" affordance). It rejects with
|
|
74
|
+
a `BrassError` for a document that has no stored original file.
|
|
219
75
|
|
|
220
|
-
## Registering
|
|
76
|
+
## Registering a serializer
|
|
221
77
|
|
|
222
78
|
Your serializer rebuilds the file from your records, and the platform
|
|
223
79
|
runs it for the download button and for drive publish. Write it as the
|
|
224
|
-
inverse of your importer
|
|
225
|
-
|
|
80
|
+
inverse of your importer. Register it once at boot, either before or
|
|
81
|
+
after `Brass.init`:
|
|
226
82
|
|
|
227
83
|
```ts
|
|
228
84
|
Brass.registerSerializer((doc) => {
|
|
@@ -232,24 +88,24 @@ Brass.registerSerializer((doc) => {
|
|
|
232
88
|
});
|
|
233
89
|
```
|
|
234
90
|
|
|
235
|
-
The serializer
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
91
|
+
The serializer returns the bytes (`Uint8Array`, or a promise of one).
|
|
92
|
+
`DocumentSerializer` types the callback. It runs detached, with no
|
|
93
|
+
subscription feeding it, so the document it is handed reads its
|
|
94
|
+
containers whole: `StoredDocument` types that document, and its `map` /
|
|
95
|
+
`list` return a `StoredMap` / `StoredList`, which add `read()` to the
|
|
96
|
+
handle an app holds.
|
|
239
97
|
|
|
240
|
-
Your app exports so that software outside Brass can read its work
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
invent yourself produces a file only your app can read, and apps inside
|
|
246
|
-
Brass already share documents through the schema you declare.
|
|
98
|
+
Your app exports so that software outside Brass can read its work, so
|
|
99
|
+
write a format that software already opens. If your app imports a file,
|
|
100
|
+
write that same format back out. If your app makes its documents from
|
|
101
|
+
nothing, ask the people who use it which tool they need the download to
|
|
102
|
+
open in, and write that tool's format.
|
|
247
103
|
|
|
248
104
|
## Declaring what your app handles
|
|
249
105
|
|
|
250
|
-
Your app declares what it reads and writes in its capability manifest
|
|
251
|
-
|
|
252
|
-
|
|
106
|
+
Your app declares what it reads and writes in its capability manifest at
|
|
107
|
+
`/.well-known/brass-app.json`. `imports` matches the uploaded files you
|
|
108
|
+
import, `exports` names the format you write back out, and `schema`
|
|
253
109
|
declares the records you work with. See
|
|
254
110
|
[`app-manifest.md`](./app-manifest.md).
|
|
255
111
|
|
package/docs/presence.md
CHANGED
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
# Presence (live cursors, who's-here)
|
|
2
2
|
|
|
3
|
-
`doc.presence` is the
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
ephemeral: nothing survives a disconnect.
|
|
3
|
+
`doc.presence` is the "who is here right now" layer over an open
|
|
4
|
+
document: the roster of other live participants plus a small per-user
|
|
5
|
+
`state` (cursor / camera / selection) and an app-namespaced `ext` blob.
|
|
6
|
+
It is non-null on any open writable `Document`, and `null` otherwise.
|
|
7
|
+
Presence is fully ephemeral: nothing survives a disconnect.
|
|
9
8
|
|
|
10
9
|
```ts
|
|
11
10
|
import type { PresenceParticipant } from '@brass-build/client';
|
|
@@ -24,12 +23,9 @@ doc.presence?.set({ ext: { myApp: { tool: 'select' } } });
|
|
|
24
23
|
// canvas APIs without parsing) and their last `state`. Interpolate
|
|
25
24
|
// between updates in your render loop.
|
|
26
25
|
//
|
|
27
|
-
// `on('change', ...)` fires once on subscribe
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
// It does not fire on subscribe when you are the only one here, and an
|
|
31
|
-
// empty roster is the default paint anyway, so one listener covers both
|
|
32
|
-
// the initial render and every change.
|
|
26
|
+
// `on('change', ...)` fires once on subscribe when peers are already
|
|
27
|
+
// present, then on every join, leave, or `state` / `ext` update, so one
|
|
28
|
+
// listener covers both the initial render and every change.
|
|
33
29
|
const renderRoster = (roster: PresenceParticipant[]) => {
|
|
34
30
|
for (const p of roster) renderAvatar(p.connectionId, p.color, p.state);
|
|
35
31
|
};
|
|
@@ -79,16 +75,44 @@ indicator) goes under `ext` instead so it stays namespaced and out of
|
|
|
79
75
|
the cross-app conventions. The `ext` block is otherwise free-form:
|
|
80
76
|
`ext.myApp = { tool: 'select', chatBubble: 'hi' }`.
|
|
81
77
|
|
|
78
|
+
## How much you can publish
|
|
79
|
+
|
|
80
|
+
Each layer is capped at 2,048 bytes of serialised JSON, exported as
|
|
81
|
+
`PRESENCE_LAYER_MAX_BYTES`. Brass measures the two layers separately, so a
|
|
82
|
+
large `ext` blob leaves `state` its full budget.
|
|
83
|
+
|
|
84
|
+
`selection` is the field that reaches the cap in ordinary use, because it
|
|
85
|
+
grows with what the user picked, and one marquee select crosses it.
|
|
86
|
+
Publish a bounded summary when a selection can get large, such as the
|
|
87
|
+
first few ids plus a count, or the selection's bounding box.
|
|
88
|
+
|
|
89
|
+
The SDK measures each layer before it sends and withholds one that is over
|
|
90
|
+
the cap, so peers keep the last value that fit rather than losing it. Read
|
|
91
|
+
`presence.status` for the current answer, or subscribe with `onStatus`:
|
|
92
|
+
|
|
93
|
+
```ts
|
|
94
|
+
doc.presence?.onStatus((status) => {
|
|
95
|
+
if (status.oversize) {
|
|
96
|
+
showNotice(`Not sharing your ${status.withheld.join(' and ')}`);
|
|
97
|
+
} else {
|
|
98
|
+
clearNotice();
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
`onStatus` fires on attach and then whenever the answer changes, so an app
|
|
104
|
+
publishing an oversized payload at 10 Hz hears about it once.
|
|
105
|
+
|
|
82
106
|
## Types
|
|
83
107
|
|
|
84
|
-
`doc.presence` is typed `PresenceClient | null
|
|
108
|
+
`doc.presence` is typed `PresenceClient | null`. Each `roster` entry
|
|
85
109
|
is a `PresenceParticipant` with
|
|
86
110
|
`{ connectionId, sub, displayName, avatarUrl, color, joinedAt, lastSeen,
|
|
87
111
|
state, ext }`. `displayName`, `avatarUrl`, and `color` are always
|
|
88
|
-
present as strings
|
|
89
|
-
Both types are exported from `@brass-build/client
|
|
90
|
-
`PresenceSetInput` and `PresenceChangeListener
|
|
91
|
-
handlers and roster state
|
|
112
|
+
present as strings, and `avatarUrl` is `''` when the user has no
|
|
113
|
+
picture. Both types are exported from `@brass-build/client`, alongside
|
|
114
|
+
`PresenceSetInput` and `PresenceChangeListener`, for annotating your
|
|
115
|
+
handlers and roster state.
|
|
92
116
|
|
|
93
117
|
## Hand-rolling the header roster (custom shell)
|
|
94
118
|
|
|
@@ -107,7 +131,7 @@ const renderRoster = (roster: readonly PresenceParticipant[]): void => {
|
|
|
107
131
|
const pip = document.createElement('span');
|
|
108
132
|
pip.className = 'pip';
|
|
109
133
|
pip.style.background = p.color;
|
|
110
|
-
pip.title = p.displayName
|
|
134
|
+
pip.title = p.displayName;
|
|
111
135
|
if (p.avatarUrl) {
|
|
112
136
|
const img = document.createElement('img');
|
|
113
137
|
img.src = p.avatarUrl;
|
|
@@ -115,7 +139,7 @@ const renderRoster = (roster: readonly PresenceParticipant[]): void => {
|
|
|
115
139
|
img.style.cssText = 'width:100%;height:100%;object-fit:cover;border-radius:50%';
|
|
116
140
|
pip.appendChild(img);
|
|
117
141
|
} else {
|
|
118
|
-
pip.textContent =
|
|
142
|
+
pip.textContent = p.displayName.slice(0, 1).toUpperCase();
|
|
119
143
|
}
|
|
120
144
|
return pip;
|
|
121
145
|
}));
|
|
@@ -125,5 +149,5 @@ const renderRoster = (roster: readonly PresenceParticipant[]): void => {
|
|
|
125
149
|
Drive it from the same `change` subscription as the in-surface remote
|
|
126
150
|
cursors (see the README's "Presence" section). The listener fires on
|
|
127
151
|
subscribe whenever peers are already present, so it paints the initial
|
|
128
|
-
roster for you
|
|
129
|
-
|
|
152
|
+
roster for you. Render `doc.presence?.roster ?? []` yourself to put the
|
|
153
|
+
empty state on screen before anyone joins.
|
package/docs/schemas.md
CHANGED
|
@@ -18,8 +18,9 @@ stream's root collections and records.
|
|
|
18
18
|
other apps converge on, so it names the artifact rather than your app or
|
|
19
19
|
your version: `gcode`, not `acme-cam` and not `gcode-2`. Lowercase
|
|
20
20
|
`a-z 0-9 . _ -`, up to 64 characters, the same rule a stream name follows,
|
|
21
|
-
so a namespaced `acme.roster` works and a URL does not.
|
|
22
|
-
|
|
21
|
+
so a namespaced `acme.roster` works and a URL does not. Brass does not
|
|
22
|
+
record a declaration whose family is missing or not a word, and reports
|
|
23
|
+
that on your app's page in the dashboard.
|
|
23
24
|
- **`streams`** maps each stream name your app expects a document to hold
|
|
24
25
|
to that stream's contract. `default` is the stream a document opens
|
|
25
26
|
without asking for one, and takes an entry like any other name. Two
|
|
@@ -28,7 +29,7 @@ stream's root collections and records.
|
|
|
28
29
|
is the record schema, so the keys are record ids. An ordered collection
|
|
29
30
|
is an array, or an array of keys alongside the map.
|
|
30
31
|
- **Foreign keys** ride as an `x-brass-ref` extension on the referring
|
|
31
|
-
field, an object
|
|
32
|
+
field, an object whose `to` names the collection it points at.
|
|
32
33
|
Standard validators ignore an `x-` keyword.
|
|
33
34
|
- **The `x-brass-` prefix is reserved for Brass.** Give your own extensions
|
|
34
35
|
their own prefix (`x-acme-`), so a name you choose keeps its meaning when
|
|
@@ -53,7 +54,7 @@ and an approximation reads as a different contract.
|
|
|
53
54
|
Streams let one document load in parts, so an app pays for what it opens
|
|
54
55
|
rather than the document's total size. `doc.getOrCreateStream(name)`
|
|
55
56
|
opens the document's stream of that name and makes it when the document
|
|
56
|
-
does not hold it yet
|
|
57
|
+
does not hold it yet. `doc.stream(name)` opens one the document already
|
|
57
58
|
holds and rejects any other name.
|
|
58
59
|
|
|
59
60
|
```ts
|
|
@@ -80,11 +81,9 @@ spread across several.
|
|
|
80
81
|
|
|
81
82
|
An app opens a document when they share a family and **everything the app
|
|
82
83
|
requires the document already guarantees.** That is the whole test, and
|
|
83
|
-
it runs per stream, over the streams both sides name.
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
exactly, only ask no more of it, so an app opens documents written
|
|
87
|
-
against shapes it was never built for.
|
|
84
|
+
it runs per stream, over the streams both sides name. Your schema does
|
|
85
|
+
not have to match a document's exactly, only ask no more of it, so an
|
|
86
|
+
app opens documents written against shapes it was never built for.
|
|
88
87
|
|
|
89
88
|
An exporter composes the streams it declares, so an app exporting a
|
|
90
89
|
document writes the parts it knows about and leaves the rest. Two apps
|
|
@@ -103,10 +102,10 @@ that can break anything.
|
|
|
103
102
|
documents never promised it.
|
|
104
103
|
- **Adding an optional property is safe** in both directions.
|
|
105
104
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
105
|
+
Keep records open (no `additionalProperties: false`) so your app
|
|
106
|
+
preserves fields it does not recognise rather than dropping them on
|
|
107
|
+
write, and handle a missing field gracefully, since Brass enforces
|
|
108
|
+
nothing and a writer may not have written it.
|
|
110
109
|
|
|
111
110
|
## Adding a required property anyway
|
|
112
111
|
|
|
@@ -129,7 +128,3 @@ written before `site` existed, and every document it saves guarantees
|
|
|
129
128
|
`site` as well. An app still on the old shape reads the result, because
|
|
130
129
|
the document now guarantees more than that app expects. Leave `writes`
|
|
131
130
|
out and one contract covers both sides, which is what most apps want.
|
|
132
|
-
|
|
133
|
-
The two directions are the whole rule: an app opens a document that
|
|
134
|
-
guarantees at least what the app expects, and writes one whose guarantees
|
|
135
|
-
its own writes cover.
|