@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
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// The ceilings an op can hit, measured before it is sent.
|
|
2
|
+
//
|
|
3
|
+
// Duplicated from the server's `frameExceedsWsLimit` / `op-blob.ts`
|
|
4
|
+
// rather than imported: this package ships unbundled and can only import
|
|
5
|
+
// its own relative files. The server owns the contract; these follow it.
|
|
6
|
+
// The transport does not deliver a message above this. It closes the
|
|
7
|
+
// connection instead, taking down everything else multiplexed onto the
|
|
8
|
+
// socket, so an oversized frame must never be sent.
|
|
9
|
+
const WS_FRAME_MAX_BYTES = 128 * 1024;
|
|
10
|
+
// What one stored record holds inline. Past it the blob is uploaded
|
|
11
|
+
// straight to storage rather than carried in a request body, so this is
|
|
12
|
+
// where the write path changes shape, not where it stops.
|
|
13
|
+
const OP_ROW_MAX_BLOB_CHARS = 350 * 1024;
|
|
14
|
+
// The largest op the platform accepts. A single value past this cannot
|
|
15
|
+
// be split (a record is the merge unit, so one assignment is the
|
|
16
|
+
// smallest thing a commit carries), which is why the refusal points at
|
|
17
|
+
// storing the bulk as a file instead.
|
|
18
|
+
const MAX_OP_BLOB_CHARS = 8 * 1024 * 1024;
|
|
19
|
+
/**
|
|
20
|
+
* True when the serialized frame would exceed what the socket carries.
|
|
21
|
+
*
|
|
22
|
+
* The envelope is measured with the blob emptied, so a multi-megabyte
|
|
23
|
+
* value is never serialized just to be counted. Every byte is ASCII
|
|
24
|
+
* (base64 blob, topic, id), so length is byte length.
|
|
25
|
+
*/
|
|
26
|
+
export function opFrameExceedsWsLimit(frame) {
|
|
27
|
+
const envelopeChars = JSON.stringify({ ...frame, op_blob: '' }).length;
|
|
28
|
+
return envelopeChars + frame.op_blob.length > WS_FRAME_MAX_BYTES;
|
|
29
|
+
}
|
|
30
|
+
/** True when the blob is too large to ride in a request body, so it is
|
|
31
|
+
* uploaded to storage first and the write names the staged object. */
|
|
32
|
+
export function opBlobNeedsStaging(opBlobB64) {
|
|
33
|
+
return opBlobB64.length > OP_ROW_MAX_BLOB_CHARS;
|
|
34
|
+
}
|
|
35
|
+
/** True when no single op can hold the blob, so the server could only
|
|
36
|
+
* refuse it. Checked locally to save an upload that ends in a 400. */
|
|
37
|
+
export function opBlobExceedsDurableLimit(opBlobB64) {
|
|
38
|
+
return opBlobB64.length > MAX_OP_BLOB_CHARS;
|
|
39
|
+
}
|
|
40
|
+
//# sourceMappingURL=ws-frame-limit.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ws-frame-limit.js","sourceRoot":"","sources":["../src/ws-frame-limit.ts"],"names":[],"mappings":"AAAA,0DAA0D;AAC1D,EAAE;AACF,oEAAoE;AACpE,yEAAyE;AACzE,yEAAyE;AAEzE,qEAAqE;AACrE,uEAAuE;AACvE,oDAAoD;AACpD,MAAM,kBAAkB,GAAG,GAAG,GAAG,IAAI,CAAC;AAEtC,oEAAoE;AACpE,wEAAwE;AACxE,0DAA0D;AAC1D,MAAM,qBAAqB,GAAG,GAAG,GAAG,IAAI,CAAC;AAEzC,uEAAuE;AACvE,iEAAiE;AACjE,uEAAuE;AACvE,sCAAsC;AACtC,MAAM,iBAAiB,GAAG,CAAC,GAAG,IAAI,GAAG,IAAI,CAAC;AAS1C;;;;;;GAMG;AACH,MAAM,UAAU,qBAAqB,CAAC,KAAc;IAClD,MAAM,aAAa,GAAG,IAAI,CAAC,SAAS,CAAC,EAAE,GAAG,KAAK,EAAE,OAAO,EAAE,EAAE,EAAE,CAAC,CAAC,MAAM,CAAC;IACvE,OAAO,aAAa,GAAG,KAAK,CAAC,OAAO,CAAC,MAAM,GAAG,kBAAkB,CAAC;AACnE,CAAC;AAED;uEACuE;AACvE,MAAM,UAAU,kBAAkB,CAAC,SAAiB;IAClD,OAAO,SAAS,CAAC,MAAM,GAAG,qBAAqB,CAAC;AAClD,CAAC;AAED;uEACuE;AACvE,MAAM,UAAU,yBAAyB,CAAC,SAAiB;IACzD,OAAO,SAAS,CAAC,MAAM,GAAG,iBAAiB,CAAC;AAC9C,CAAC"}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# The capability manifest (`brass-app.json`)
|
|
2
|
+
|
|
3
|
+
Every app ships a `brass-app.json`, and the platform reads it to decide
|
|
4
|
+
where your app shows up. Brass adds an "Open in" link to a document
|
|
5
|
+
card, offers your app when someone uploads a file it handles, and shows
|
|
6
|
+
a download button on the documents your app writes out.
|
|
7
|
+
|
|
8
|
+
## Where it goes
|
|
9
|
+
|
|
10
|
+
The manifest is a file called `brass-app.json`. It must be placed in a
|
|
11
|
+
directory called `/.well-known/` at your bundle root, next to
|
|
12
|
+
`index.html`. Some bundlers skip dot-directories when they copy static
|
|
13
|
+
assets, so confirm that the manifest is actually in your `dist/`
|
|
14
|
+
directory after you build.
|
|
15
|
+
|
|
16
|
+
The platform re-reads the manifest after each deploy, so publishing
|
|
17
|
+
your bundle is what registers it. If Brass hosts your bundle
|
|
18
|
+
(`brass publish`), it reads the manifest from the bytes it stores, so
|
|
19
|
+
your capabilities register even when your load gate is on. If you serve
|
|
20
|
+
the bundle from your own origin, the platform fetches
|
|
21
|
+
`<origin>/.well-known/brass-app.json` as an anonymous client, so serve
|
|
22
|
+
that path publicly even when the rest of your app requires sign-in.
|
|
23
|
+
|
|
24
|
+
These tokens only drive discovery and grant no access, so you declare
|
|
25
|
+
them yourself.
|
|
26
|
+
|
|
27
|
+
## What goes in it
|
|
28
|
+
|
|
29
|
+
```json
|
|
30
|
+
{
|
|
31
|
+
"opens": ["tracker"],
|
|
32
|
+
"accepts": [{ "extension": "csv" }, { "content_type": "text/csv" }],
|
|
33
|
+
"creates": "tracker",
|
|
34
|
+
"exports": { "content_type": "text/csv", "file_extension": ".csv" },
|
|
35
|
+
"icon": "/icon.svg",
|
|
36
|
+
"schema": { "...": "see schemas.md" }
|
|
37
|
+
}
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Every field is optional, so ship only the ones your app uses.
|
|
41
|
+
|
|
42
|
+
- **`opens`** lists the `schema_type`s of documents your app reads.
|
|
43
|
+
Declaring a `schema` already folds its family in, so most apps list
|
|
44
|
+
nothing extra here.
|
|
45
|
+
- **`accepts`** matches an uploaded file by extension or content type,
|
|
46
|
+
so Brass offers your app to whoever uploads one. Your app then reads
|
|
47
|
+
the file's bytes through `sourceBlob()`.
|
|
48
|
+
- **`creates`** is the `schema_type` Brass stamps on the documents your
|
|
49
|
+
app makes, so peer apps that declare `opens` for the token can open
|
|
50
|
+
them. It folds into your own `opens` as well.
|
|
51
|
+
- **`exports`** names the file format your app writes a document out
|
|
52
|
+
to, and your `Brass.registerSerializer` callback produces the bytes.
|
|
53
|
+
Declaring it makes those documents downloadable. Declare it when your
|
|
54
|
+
app interoperates with a format outside Brass.
|
|
55
|
+
[`imported-documents.md`](./imported-documents.md) covers how an
|
|
56
|
+
importing app chooses that format.
|
|
57
|
+
- **`icon`** points at your app's mark, which the platform renders on
|
|
58
|
+
your app's tiles and on the hosted consent screen when someone grants
|
|
59
|
+
your app access. Draw a small SVG and serve it from your own bundle
|
|
60
|
+
(`"icon": "/icon.svg"`, resolved against your origin), or give an
|
|
61
|
+
absolute `https://` URL. An emoji (`"icon": "🏗️"`) also renders,
|
|
62
|
+
though apps in one domain tend to land on the same few.
|
|
63
|
+
- **`schema`** declares the record shape your app works with, whether
|
|
64
|
+
you invented it or adopted an existing one. See
|
|
65
|
+
[`schemas.md`](./schemas.md).
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# Custom chrome
|
|
2
|
+
|
|
3
|
+
`Brass.mountToolbar` renders the whole document bar and is the
|
|
4
|
+
recommended path for an app that is one working surface under a bar.
|
|
5
|
+
This page covers the two custom shapes, from least to most custom: a
|
|
6
|
+
multi-pane app that builds its own header out of the **standard
|
|
7
|
+
pieces**, and a fully custom shell that renders `session.commands`
|
|
8
|
+
into its own DOM.
|
|
9
|
+
|
|
10
|
+
## Composable header pieces (multi-pane apps)
|
|
11
|
+
|
|
12
|
+
A multi-pane app (a tree beside an inspector, a list beside a preview)
|
|
13
|
+
builds its own header for its own layout, but should not re-implement
|
|
14
|
+
the account menu, presence roster, title, undo / redo, or the header's
|
|
15
|
+
responsive layout by hand. Take the header shell with
|
|
16
|
+
`Brass.mountHeader`, then drop the standard pieces into its three
|
|
17
|
+
slots:
|
|
18
|
+
|
|
19
|
+
```ts
|
|
20
|
+
// Each piece mount owns (replaces the children of) the element you
|
|
21
|
+
// give it, so hand each its own child within a cluster.
|
|
22
|
+
const slot = (parent: HTMLElement) => {
|
|
23
|
+
const el = document.createElement('div');
|
|
24
|
+
parent.append(el);
|
|
25
|
+
return el;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const header = Brass.mountHeader(document.getElementById('app-header')!);
|
|
29
|
+
Brass.mountFileMenu(slot(header.start)); // left: file menu + title
|
|
30
|
+
Brass.mountDocumentTitle(slot(header.start));
|
|
31
|
+
Brass.mountUndoRedo(header.tools); // middle: wraps on mobile
|
|
32
|
+
Brass.mountDriveSync(slot(header.end)); // right: pinned top-right
|
|
33
|
+
Brass.mountPresenceRoster(slot(header.end));
|
|
34
|
+
Brass.mountAccountMenu(slot(header.end)); // the account avatar
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
`mountHeader` gives you the same responsive bar layout `mountToolbar`
|
|
38
|
+
uses, from one definition: `start` ellipsizes its title rather than
|
|
39
|
+
wrapping, `tools` drops to its own row on a narrow screen, and `end`
|
|
40
|
+
stays pinned top-right, so the account avatar never wraps below the
|
|
41
|
+
tools on mobile (the detail a hand-rolled flex header gets wrong).
|
|
42
|
+
Each piece renders one cluster wired to `Brass.session` and returns
|
|
43
|
+
`{ destroy(): void }`; `header.destroy()` removes the shell.
|
|
44
|
+
|
|
45
|
+
- `Brass.mountFileMenu` renders the hamburger file menu (the
|
|
46
|
+
`session.commands` menu surface).
|
|
47
|
+
- `Brass.mountDocumentTitle` renders the document title plus a
|
|
48
|
+
location chip; both open the hosted location editor (rename / move /
|
|
49
|
+
file). On a non-default branch the title carries a branch chip.
|
|
50
|
+
- `Brass.mountUndoRedo` renders undo / redo as prominent buttons.
|
|
51
|
+
- `Brass.mountPresenceRoster` renders the who's-here roster of
|
|
52
|
+
coloured initials. It is the receiver half only: still publish your
|
|
53
|
+
own presence state (see the README's "Presence").
|
|
54
|
+
- `Brass.mountDriveSync` renders the drive-sync badge. It renders
|
|
55
|
+
nothing on a non-drive doc, so it is safe to mount in any header.
|
|
56
|
+
- `Brass.mountAccountMenu` renders the account avatar menu (email, an
|
|
57
|
+
Open dashboard link, and sign out).
|
|
58
|
+
|
|
59
|
+
`mountHeader` already includes the historical-version preview banner
|
|
60
|
+
(the return-to-live strip shown while a document is previewing an
|
|
61
|
+
earlier version) above the bar, so a multi-pane app on the header shell
|
|
62
|
+
gets correct preview UX with no extra mount. Only a fully custom shell
|
|
63
|
+
(next section, no `mountHeader`) mounts it itself, with
|
|
64
|
+
`Brass.mountVersionPreview(el)`; like the drive badge it renders
|
|
65
|
+
nothing until there is something to show.
|
|
66
|
+
|
|
67
|
+
Mount the chrome once, when the user signs in, not per document: the
|
|
68
|
+
file menu has to exist in the "signed in, nothing open" state or a
|
|
69
|
+
fresh user has no New / Open (the same rule as `mountToolbar`).
|
|
70
|
+
|
|
71
|
+
## Fully custom shells: the command list as data
|
|
72
|
+
|
|
73
|
+
The widgets are one renderer of this same command list, so nothing
|
|
74
|
+
below is a different surface, just a lower-level entry point into it.
|
|
75
|
+
|
|
76
|
+
`session.commands` is the SDK's built-in actions as data: the SDK owns
|
|
77
|
+
each command's behavior and live state. Each carries a `surface` hint
|
|
78
|
+
(`'menu'` for a file menu, `'toolbar'` for a prominent button) to
|
|
79
|
+
filter on when placing it. It's a hint, not a constraint. The
|
|
80
|
+
`Command` type is exported, so type your render code against it rather
|
|
81
|
+
than re-declaring a local interface:
|
|
82
|
+
|
|
83
|
+
```ts
|
|
84
|
+
import type { Command } from '@brass-build/client';
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
The full built-in set when a doc is bound, in list order:
|
|
88
|
+
|
|
89
|
+
| `id` | `group` | `surface` | `label` | Enabled when |
|
|
90
|
+
| ---------- | ---------- | --------- | ------------------ | --------------------------------------- |
|
|
91
|
+
| `new` | `session` | `menu` | New | Signed in. |
|
|
92
|
+
| `open` | `session` | `menu` | Open... | Signed in. |
|
|
93
|
+
| `history` | `document` | `menu` | Version history... | A document is open. |
|
|
94
|
+
| `branches` | `document` | `menu` | Branches... | A document is open. Has `children`. |
|
|
95
|
+
| `share` | `document` | `menu` | Share... / Share / Download... | A document is open. |
|
|
96
|
+
| `manage` | `document` | `menu` | Manage... | A document is open. |
|
|
97
|
+
| `undo` | `edit` | `toolbar` | Undo | The document is editable and there is something to undo. |
|
|
98
|
+
| `redo` | `edit` | `toolbar` | Redo | The document is editable and there is something to redo. |
|
|
99
|
+
|
|
100
|
+
The order runs use, then manage, then destructive: `new` / `open` put a
|
|
101
|
+
different document on screen, `history` / `branches` put a different
|
|
102
|
+
state of this one there, `share` hands the document to a person or to a
|
|
103
|
+
file, and `manage` (the entry holding the delete) comes last. Render the
|
|
104
|
+
list in the order you receive it.
|
|
105
|
+
|
|
106
|
+
Each command carries `enabled` (read on every notification),
|
|
107
|
+
`running` (true while `run()` is in flight, drives the pending
|
|
108
|
+
affordance), and `run()` (always resolves; failures route to
|
|
109
|
+
`Brass.onError`, never throw).
|
|
110
|
+
|
|
111
|
+
Read `label` on every notification too. `share` relabels once the SDK
|
|
112
|
+
knows whether the document has a file to hand over, since downloading is
|
|
113
|
+
one row of the share dialog rather than a command of its own.
|
|
114
|
+
|
|
115
|
+
Draw a rule where `group` changes: `'session'` (a different document),
|
|
116
|
+
`'document'` (this document), `'edit'` (undo / redo). The widgets do
|
|
117
|
+
this, so a custom menu matches them by following the same rule.
|
|
118
|
+
|
|
119
|
+
A command may carry `children` (a submenu) and `selected` (the
|
|
120
|
+
active choice). Render a command with `children` as a submenu parent
|
|
121
|
+
with a disclosure chevron; render each child as a nested item,
|
|
122
|
+
showing a checkmark or bold style when `child.selected` is true.
|
|
123
|
+
|
|
124
|
+
Reading the list rather than hard-coding the commands you know about
|
|
125
|
+
means you don't miss one as the SDK adds built-ins. Filter by `surface`
|
|
126
|
+
to place each, and invoke one by id with `session.run('undo')` (the
|
|
127
|
+
hook for keyboard shortcuts).
|
|
128
|
+
|
|
129
|
+
## What each command does by default
|
|
130
|
+
|
|
131
|
+
Reading the list and calling `run()` wires all of it; you render and
|
|
132
|
+
invoke, the SDK owns the behavior:
|
|
133
|
+
|
|
134
|
+
- **`history`** drives version preview end to end. During a preview
|
|
135
|
+
`doc.readOnly` is `true`, so gate your own edit controls on it;
|
|
136
|
+
returning to live is the chrome's job (the `mountVersionPreview`
|
|
137
|
+
banner), not yours.
|
|
138
|
+
- **`branches`** carries `children` (the branches, the active one
|
|
139
|
+
`selected: true`) plus a fork-new child. Render it as a submenu and
|
|
140
|
+
invoke a child's `run()`; a flat-list shell can call the parent's
|
|
141
|
+
`run()` to open the hosted picker instead.
|
|
142
|
+
- **`share`** opens the hosted share dialog: who can open the document,
|
|
143
|
+
and, where there is a file to hand over, the download.
|
|
144
|
+
- **`manage`** opens the same dialog on the document itself: its name,
|
|
145
|
+
where it sits, Duplicate, and Delete.
|
|
146
|
+
- **`undo` / `redo`** call `doc.undo()` / `doc.redo()` (see "Undo,
|
|
147
|
+
checkpoints, and version history" in the README).
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# Hosted UI: advanced
|
|
2
|
+
|
|
3
|
+
Some surfaces are owned by Brass: you call one method and Brass renders
|
|
4
|
+
the UI in a modal iframe over your page, so your app never builds the
|
|
5
|
+
screen, fetches the data, or handles the mutation. `Brass.session`
|
|
6
|
+
wires the common ones (`history`, `share`, `manage`) behind the file
|
|
7
|
+
menu; this page covers the rest and how they behave in browsers that
|
|
8
|
+
block the iframe.
|
|
9
|
+
|
|
10
|
+
## Surfaces
|
|
11
|
+
|
|
12
|
+
| Method | Returns |
|
|
13
|
+
| --- | --- |
|
|
14
|
+
| `Brass.selectDocument()` | `Promise<SelectDocumentResult \| null>`: opens the hosted document picker; resolves to the picked document, or `null` if the user dismisses it. |
|
|
15
|
+
| `Brass.shareDocument(docId)` | `Promise<void>`: opens the hosted share dialog for the document. |
|
|
16
|
+
|
|
17
|
+
Version history is a built-in file-menu command (`history` in
|
|
18
|
+
`session.commands` and the SDK toolbar's file menu), not a launcher
|
|
19
|
+
method; an app on `mountToolbar` or `mountFileMenu` gets it with no
|
|
20
|
+
code.
|
|
21
|
+
|
|
22
|
+
## Redirects in browsers that block the iframe
|
|
23
|
+
|
|
24
|
+
Where the browser blocks the Hosted UI iframe (Safari, Firefox, Chrome
|
|
25
|
+
with third-party cookies off), these surfaces open as a full-page
|
|
26
|
+
redirect to Brass and return to your app when the user is done. The
|
|
27
|
+
picker returns with the chosen document opened into `Brass.session`.
|
|
28
|
+
|
|
29
|
+
So render the active document from the session (or `mountToolbar`): on
|
|
30
|
+
the redirect path the page reloads, so the code after `await
|
|
31
|
+
Brass.selectDocument()` never runs, and the pick arrives through the
|
|
32
|
+
session instead. The resolved value is the modal path's inline
|
|
33
|
+
convenience, correct whenever you receive it.
|
|
34
|
+
|
|
35
|
+
## `BrassError` `.code` values
|
|
36
|
+
|
|
37
|
+
The hosted-UI launchers reject with `BrassError` carrying a `.code`
|
|
38
|
+
(rather than the `.status` the HTTP namespaces set):
|
|
39
|
+
|
|
40
|
+
- `'embed_error'`: the hosted page loaded but couldn't proceed (e.g.
|
|
41
|
+
the user isn't signed in to Brass in this context).
|
|
42
|
+
- `'not_in_browser'`: called outside a browser environment.
|
|
43
|
+
|
|
44
|
+
## Theming
|
|
45
|
+
|
|
46
|
+
Hosted UI surfaces render in Brass's own visual language; light/dark is
|
|
47
|
+
the one axis you control, defaulting to `'light'`. Override in two
|
|
48
|
+
cases:
|
|
49
|
+
|
|
50
|
+
- **Dark-only app**: `hostedUiTheme: 'dark'`.
|
|
51
|
+
- **App that follows the user's OS preference**: `hostedUiTheme: 'auto'`,
|
|
52
|
+
and only if your own CSS also responds to
|
|
53
|
+
`@media (prefers-color-scheme: dark)`. Otherwise a dark-OS user gets a
|
|
54
|
+
dark embed inside your light app with the close button invisible over
|
|
55
|
+
it.
|
|
56
|
+
|
|
57
|
+
```ts
|
|
58
|
+
await Brass.init('brass_app_live_...', {
|
|
59
|
+
hostedUiTheme: 'auto',
|
|
60
|
+
});
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## CSP
|
|
64
|
+
|
|
65
|
+
If your app sends a `Content-Security-Policy`, it needs `frame-src
|
|
66
|
+
https://app.brass.build` (the README's "Setup" lists the full set). A
|
|
67
|
+
bare `default-src 'self'` blocks the iframe, because `frame-src` falls
|
|
68
|
+
back to `default-src`, and the failure is silent. Apps that send no CSP,
|
|
69
|
+
or no `frame-src` / `default-src`, need no change.
|
|
70
|
+
|
|
71
|
+
## Mixing with `Brass.session`
|
|
72
|
+
|
|
73
|
+
The session's file-menu commands invoke these surfaces for you:
|
|
74
|
+
`open` opens the picker, `share` and `manage` open the
|
|
75
|
+
document-settings dialog, and `history` opens version history. Each
|
|
76
|
+
command's `run()` always resolves; a failure routes to `Brass.onError`
|
|
77
|
+
rather than throwing.
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
# Imported files and formats
|
|
2
|
+
|
|
3
|
+
Your app reads and edits an imported document through the ordinary
|
|
4
|
+
handle surface, and Brass does the importing. A user uploads a file or
|
|
5
|
+
opens a drive item, and the platform turns it into a document, so there
|
|
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:
|
|
8
|
+
|
|
9
|
+
- **A format with an importer**: the parsed records ARE the document
|
|
10
|
+
(an installed app provides the importer). You read and edit the
|
|
11
|
+
records through the ordinary handle surface, and Brass re-emits the
|
|
12
|
+
original on export and drive-push through an installed app that
|
|
13
|
+
serialises the format. Your app is an **editor**: declare the
|
|
14
|
+
document's schema in `brass-app.json`, the contract peers evaluate
|
|
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`.
|
|
27
|
+
|
|
28
|
+
Documents your app creates itself have no source file. If your app
|
|
29
|
+
only makes its own documents, it reads and edits records and declares a
|
|
30
|
+
schema so peers can open them.
|
|
31
|
+
|
|
32
|
+
## The record store
|
|
33
|
+
|
|
34
|
+
The record store is the same for every format. It lives at the
|
|
35
|
+
document root as two containers: `instances` (a map keyed by record id
|
|
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:
|
|
129
|
+
|
|
130
|
+
```ts
|
|
131
|
+
Brass.registerImporter(async (src, doc) => {
|
|
132
|
+
// src: { url, filename, content_type, byte_size, expires_in, streams }
|
|
133
|
+
const bytes = new Uint8Array(await (await fetch(src.url)).arrayBuffer());
|
|
134
|
+
const parsed = parseMyFormat(bytes); // your own parser
|
|
135
|
+
const records = doc.orderedMap('instances', 'instance_order');
|
|
136
|
+
records.mutate((m) => {
|
|
137
|
+
for (const r of parsed) m[r.id] = { type: r.type, params: r.body };
|
|
138
|
+
});
|
|
139
|
+
await doc.createCheckpoint();
|
|
140
|
+
});
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
The importer receives the stored source file (a short-lived download
|
|
144
|
+
URL plus the file's name, type, and size, with the bytes not inlined)
|
|
145
|
+
and the open document, parses the bytes, and writes the records
|
|
146
|
+
through the ordinary handles. `DocumentImporter` types the callback.
|
|
147
|
+
|
|
148
|
+
Brass decides when an import is needed and runs your importer once. A
|
|
149
|
+
document that already holds its records, or has no stored file, is left
|
|
150
|
+
alone, and the records reach every open client through its normal
|
|
151
|
+
subscription. Declare the `schema` of the records your
|
|
152
|
+
importer writes in `brass-app.json`: importing publishes it and
|
|
153
|
+
stamps it on the document, so the document routes and resolves its
|
|
154
|
+
schema like any imported document.
|
|
155
|
+
|
|
156
|
+
Sealing the write with `doc.createCheckpoint()` at the end of the
|
|
157
|
+
importer makes the import one undo step, so an undo reverts it whole
|
|
158
|
+
instead of leaving a half-deleted model. An importer that throws
|
|
159
|
+
surfaces through `Brass.onError`, and Brass retries the import later.
|
|
160
|
+
|
|
161
|
+
**Spread a large format across streams.** When your format is large,
|
|
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
|
+
```
|
|
181
|
+
|
|
182
|
+
Name `geometry` under `streams` in the same schema you ship in
|
|
183
|
+
`brass-app.json`, so a reader can resolve its contract. A document can
|
|
184
|
+
also hold streams your schema does not name (a review overlay, a
|
|
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')`).
|
|
192
|
+
|
|
193
|
+
`doc.sourceBlob()` is the same source-file read as the importer's
|
|
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.
|
|
197
|
+
|
|
198
|
+
A source file is a set of one or more byte streams: the primary stream
|
|
199
|
+
(what the filename names) plus any sidecar streams uploaded with it (a
|
|
200
|
+
glTF's `.bin` buffer, a shapefile's `.dbf`). `src.streams` lists every
|
|
201
|
+
stream, primary first, each as `{ name, url, content_type, byte_size }`
|
|
202
|
+
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 (it
|
|
204
|
+
mirrors the top-level fields), so code that iterates `streams` handles
|
|
205
|
+
both cases in one path:
|
|
206
|
+
|
|
207
|
+
```ts
|
|
208
|
+
const byName = new Map(src.streams.map((s) => [s.name, s]));
|
|
209
|
+
const buffer = byName.get('scene.bin'); // undefined when not part of the set
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
**Choose the record as the smallest thing two people edit
|
|
213
|
+
independently.** Concurrent edits to one record resolve
|
|
214
|
+
last-writer-wins on its whole `params`, so a grid of
|
|
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.
|
|
219
|
+
|
|
220
|
+
## Registering your format's serializer
|
|
221
|
+
|
|
222
|
+
Your serializer rebuilds the file from your records, and the platform
|
|
223
|
+
runs it for the download button and for drive publish. Write it as the
|
|
224
|
+
inverse of your importer so that it produces the format the importer
|
|
225
|
+
read. Register it once at boot, either before or after `Brass.init`:
|
|
226
|
+
|
|
227
|
+
```ts
|
|
228
|
+
Brass.registerSerializer((doc) => {
|
|
229
|
+
const records = doc.orderedMap('instances', 'instance_order');
|
|
230
|
+
const text = myFormatToText(records.ordered()); // inverse of your parser
|
|
231
|
+
return new TextEncoder().encode(text);
|
|
232
|
+
});
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
The serializer takes the open `Document` and returns the bytes
|
|
236
|
+
(`Uint8Array`, or a promise of one). Producing the bytes is its only
|
|
237
|
+
job, and `DocumentSerializer` types the callback. With it registered, your
|
|
238
|
+
app only ever works with records, never with file I/O.
|
|
239
|
+
|
|
240
|
+
Your app exports so that software outside Brass can read its work. The
|
|
241
|
+
format therefore has to be one that software already opens. If your app
|
|
242
|
+
imports a file, write that same format back out. If your app makes its
|
|
243
|
+
documents from nothing, ask the people who use it which tool they need
|
|
244
|
+
the download to open in, and write that tool's format. A format you
|
|
245
|
+
invent yourself produces a file only your app can read, and apps inside
|
|
246
|
+
Brass already share documents through the schema you declare.
|
|
247
|
+
|
|
248
|
+
## Declaring what your app handles
|
|
249
|
+
|
|
250
|
+
Your app declares what it reads and writes in its capability manifest
|
|
251
|
+
at `/.well-known/brass-app.json`. `accepts` matches the uploaded files
|
|
252
|
+
you import, `exports` names the format you write back out, and `schema`
|
|
253
|
+
declares the records you work with. See
|
|
254
|
+
[`app-manifest.md`](./app-manifest.md).
|
|
255
|
+
|
|
256
|
+
An editor only needs to declare `opens`, and its copy of the format's
|
|
257
|
+
`schema` already does that. Brass delegates a document's import and
|
|
258
|
+
export to an installed app that handles the format, so your app reaches
|
|
259
|
+
the records without writing either half. Write your own importer and
|
|
260
|
+
serializer if you would rather not depend on another app being
|
|
261
|
+
installed, or if you need a format the installed apps do not handle.
|