@brass-build/client 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +26 -5
- package/CHANGELOG.md +61 -2
- package/README.md +247 -87
- package/dist/acting-organization.d.ts +131 -0
- package/dist/acting-organization.d.ts.map +1 -0
- package/dist/acting-organization.js +278 -0
- package/dist/acting-organization.js.map +1 -0
- package/dist/api-headers.js +7 -7
- package/dist/api-headers.js.map +1 -1
- package/dist/call-service.d.ts +74 -0
- package/dist/call-service.d.ts.map +1 -0
- package/dist/call-service.js +253 -0
- package/dist/call-service.js.map +1 -0
- package/dist/client.d.ts +29 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +583 -12
- package/dist/client.js.map +1 -1
- package/dist/document-checkpoints.d.ts +1 -0
- package/dist/document-checkpoints.d.ts.map +1 -1
- package/dist/document-checkpoints.js +27 -0
- package/dist/document-checkpoints.js.map +1 -1
- package/dist/document-contents.d.ts +15 -0
- package/dist/document-contents.d.ts.map +1 -0
- package/dist/document-contents.js +88 -0
- package/dist/document-contents.js.map +1 -0
- package/dist/document-signals.d.ts +31 -1
- package/dist/document-signals.d.ts.map +1 -1
- package/dist/document-signals.js +59 -0
- package/dist/document-signals.js.map +1 -1
- package/dist/document-streams.d.ts +4 -2
- package/dist/document-streams.d.ts.map +1 -1
- package/dist/document-streams.js +6 -0
- package/dist/document-streams.js.map +1 -1
- package/dist/document-thumbnail-capture.d.ts +20 -0
- package/dist/document-thumbnail-capture.d.ts.map +1 -1
- package/dist/document-thumbnail-capture.js +247 -9
- package/dist/document-thumbnail-capture.js.map +1 -1
- package/dist/documents-namespace.d.ts.map +1 -1
- package/dist/documents-namespace.js +3 -0
- package/dist/documents-namespace.js.map +1 -1
- package/dist/documents.d.ts +51 -3
- package/dist/documents.d.ts.map +1 -1
- package/dist/documents.js +415 -36
- package/dist/documents.js.map +1 -1
- package/dist/export-guest.d.ts.map +1 -1
- package/dist/export-guest.js +5 -0
- package/dist/export-guest.js.map +1 -1
- package/dist/handle-types.d.ts +23 -0
- package/dist/handle-types.d.ts.map +1 -1
- package/dist/handles.d.ts +1 -1
- package/dist/handles.d.ts.map +1 -1
- package/dist/handles.js +3 -3
- package/dist/handles.js.map +1 -1
- package/dist/hosted-ui.d.ts +10 -0
- package/dist/hosted-ui.d.ts.map +1 -1
- package/dist/hosted-ui.js +115 -30
- package/dist/hosted-ui.js.map +1 -1
- package/dist/importer.d.ts.map +1 -1
- package/dist/importer.js +28 -66
- package/dist/importer.js.map +1 -1
- package/dist/index.d.ts +8 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +25 -1
- package/dist/index.js.map +1 -1
- package/dist/internal.d.ts +2 -0
- package/dist/internal.d.ts.map +1 -1
- package/dist/internal.js +2 -0
- package/dist/internal.js.map +1 -1
- package/dist/mutate-batching.js +2 -2
- package/dist/mutate-batching.js.map +1 -1
- package/dist/open-document.d.ts +16 -1
- package/dist/open-document.d.ts.map +1 -1
- package/dist/open-document.js +123 -35
- package/dist/open-document.js.map +1 -1
- package/dist/presence.d.ts +10 -0
- package/dist/presence.d.ts.map +1 -1
- package/dist/presence.js.map +1 -1
- package/dist/record-paging.d.ts +13 -14
- package/dist/record-paging.d.ts.map +1 -1
- package/dist/record-paging.js +13 -14
- package/dist/record-paging.js.map +1 -1
- package/dist/serializer.d.ts +3 -3
- package/dist/session-toolbar.d.ts +9 -0
- package/dist/session-toolbar.d.ts.map +1 -1
- package/dist/session-toolbar.js +191 -21
- package/dist/session-toolbar.js.map +1 -1
- package/dist/tail-ops.js +2 -2
- package/dist/tail-ops.js.map +1 -1
- package/dist/types.d.ts +0 -42
- package/dist/types.d.ts.map +1 -1
- package/dist/undo.d.ts +1 -1
- package/dist/undo.d.ts.map +1 -1
- package/dist/undo.js +1 -1
- package/dist/undo.js.map +1 -1
- package/dist/version-bar.d.ts.map +1 -1
- package/dist/version-bar.js +75 -4
- package/dist/version-bar.js.map +1 -1
- package/dist/version.d.ts +2 -2
- package/dist/version.js +1 -1
- package/dist/wasm-ready-gate.d.ts +13 -0
- package/dist/wasm-ready-gate.d.ts.map +1 -0
- package/dist/wasm-ready-gate.js +28 -0
- package/dist/wasm-ready-gate.js.map +1 -0
- package/dist/wasm-runtime.browser.d.ts +11 -0
- package/dist/wasm-runtime.browser.d.ts.map +1 -0
- package/dist/wasm-runtime.browser.js +44 -0
- package/dist/wasm-runtime.browser.js.map +1 -0
- package/dist/wasm-runtime.d.ts +6 -0
- package/dist/wasm-runtime.d.ts.map +1 -0
- package/dist/wasm-runtime.js +20 -0
- package/dist/wasm-runtime.js.map +1 -0
- package/dist/when-saved.d.ts +2 -0
- package/dist/when-saved.d.ts.map +1 -0
- package/dist/when-saved.js +70 -0
- package/dist/when-saved.js.map +1 -0
- package/dist/ws-client.d.ts +4 -0
- package/dist/ws-client.d.ts.map +1 -1
- package/dist/ws-client.js +70 -1
- package/dist/ws-client.js.map +1 -1
- package/dist/ws-frame-limit.d.ts.map +1 -1
- package/dist/ws-frame-limit.js +9 -10
- package/dist/ws-frame-limit.js.map +1 -1
- package/docs/app-manifest.md +335 -5
- package/docs/custom-chrome.md +7 -2
- package/docs/presence.md +12 -0
- package/docs/services.md +265 -0
- package/docs/text-fields.md +191 -0
- package/package.json +4 -5
- package/src/acting-organization.ts +396 -0
- package/src/api-headers.ts +7 -7
- package/src/call-service.ts +354 -0
- package/src/client.ts +655 -15
- package/src/document-checkpoints.ts +30 -0
- package/src/document-contents.ts +104 -0
- package/src/document-signals.ts +113 -1
- package/src/document-streams.ts +15 -1
- package/src/document-thumbnail-capture.ts +253 -9
- package/src/documents-namespace.ts +20 -10
- package/src/documents.ts +460 -33
- package/src/export-guest.ts +5 -0
- package/src/handle-types.ts +35 -0
- package/src/handles.ts +7 -6
- package/src/hosted-ui.ts +155 -29
- package/src/importer.ts +28 -62
- package/src/index.ts +43 -4
- package/src/internal.ts +10 -0
- package/src/mutate-batching.ts +2 -2
- package/src/open-document.ts +179 -39
- package/src/presence.ts +6 -0
- package/src/record-paging.ts +13 -14
- package/src/serializer.ts +3 -3
- package/src/session-toolbar.ts +219 -22
- package/src/tail-ops.ts +2 -2
- package/src/types.ts +6 -4
- package/src/undo.ts +1 -1
- package/src/version-bar.ts +77 -7
- package/src/version.ts +1 -1
- package/src/wasm-ready-gate.ts +30 -0
- package/src/wasm-runtime.browser.ts +48 -0
- package/src/wasm-runtime.ts +21 -0
- package/src/when-saved.ts +66 -0
- package/src/ws-client.ts +70 -1
- package/src/ws-frame-limit.ts +9 -10
- package/dist/vite.d.ts +0 -31
- package/dist/vite.d.ts.map +0 -1
- package/dist/vite.js +0 -161
- package/dist/vite.js.map +0 -1
- package/src/vite.ts +0 -214
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
# Text fields
|
|
2
|
+
|
|
3
|
+
`doc.text(key)` is the container for prose several people edit at once. The
|
|
4
|
+
[README](../README.md#prose-several-people-edit) covers what it is and how
|
|
5
|
+
to wire a field to it. This page covers what a real editor needs on top:
|
|
6
|
+
painting a peer's edit without throwing away the caret, the IME window,
|
|
7
|
+
what a keystroke costs, and where a text container belongs in a document.
|
|
8
|
+
|
|
9
|
+
## Paint a peer's edit without losing the caret
|
|
10
|
+
|
|
11
|
+
Assigning `field.value` moves the caret to the end, so an editor that
|
|
12
|
+
assigns unconditionally throws away the reader's place on every peer
|
|
13
|
+
keystroke, and one that skips a focused field never shows a peer's typing
|
|
14
|
+
to anyone who is typing themselves. Find the span the incoming value
|
|
15
|
+
replaced, and move the caret across it:
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
const report = doc.text('report');
|
|
19
|
+
let painted = report.read();
|
|
20
|
+
field.value = painted;
|
|
21
|
+
|
|
22
|
+
report.subscribe((value, origin) => {
|
|
23
|
+
if (origin?.source === 'local') { painted = value; return; }
|
|
24
|
+
if (value === painted) return;
|
|
25
|
+
|
|
26
|
+
// The one span that differs, found by trimming the common prefix and
|
|
27
|
+
// the common suffix. A keystroke, a paste, and a peer's edit each look
|
|
28
|
+
// like this.
|
|
29
|
+
const max = Math.min(painted.length, value.length);
|
|
30
|
+
let start = 0;
|
|
31
|
+
while (start < max && painted.charCodeAt(start) === value.charCodeAt(start)) start += 1;
|
|
32
|
+
let tail = 0;
|
|
33
|
+
while (tail < max - start
|
|
34
|
+
&& painted.charCodeAt(painted.length - 1 - tail)
|
|
35
|
+
=== value.charCodeAt(value.length - 1 - tail)) tail += 1;
|
|
36
|
+
const removed = painted.length - tail - start;
|
|
37
|
+
const inserted = value.length - tail - start;
|
|
38
|
+
|
|
39
|
+
// The caret binds to the text on its right, so a peer typing where it
|
|
40
|
+
// sits pushes it past what they wrote and leaves it in front of the same
|
|
41
|
+
// words. One before the span does not move, one after it shifts by the
|
|
42
|
+
// span's change in length.
|
|
43
|
+
const carry = (caret: number): number =>
|
|
44
|
+
caret < start ? caret
|
|
45
|
+
: caret >= start + removed ? caret + inserted - removed
|
|
46
|
+
: start + Math.min(caret - start, inserted);
|
|
47
|
+
|
|
48
|
+
// A reader with text selected has two offsets, and `selectionDirection`
|
|
49
|
+
// says which of them their next keystroke extends from.
|
|
50
|
+
const focused = document.activeElement === field;
|
|
51
|
+
const anchor = carry(field.selectionStart ?? 0);
|
|
52
|
+
const head = carry(field.selectionEnd ?? field.selectionStart ?? 0);
|
|
53
|
+
const direction = field.selectionDirection;
|
|
54
|
+
// Assigning `value` resets the scroll, which throws a reader who is
|
|
55
|
+
// partway down a long field back to the top on every peer keystroke.
|
|
56
|
+
const scroll = field.scrollTop;
|
|
57
|
+
|
|
58
|
+
field.value = value;
|
|
59
|
+
painted = value;
|
|
60
|
+
field.scrollTop = scroll;
|
|
61
|
+
if (focused) {
|
|
62
|
+
field.setSelectionRange(nudgeOffPair(value, anchor), nudgeOffPair(value, head), direction);
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Offsets count UTF-16 code units, which is what a text field's
|
|
68
|
+
`selectionStart` reports, so the two agree with no conversion. The same
|
|
69
|
+
units are why an offset needs one more check before it is used: a character
|
|
70
|
+
outside the Basic Multilingual Plane, an emoji above all, is two code units,
|
|
71
|
+
and the `charCodeAt` walk above stops between them whenever the edit sits
|
|
72
|
+
next to one. `nudgeOffPair` moves such an offset off the seam:
|
|
73
|
+
|
|
74
|
+
```ts
|
|
75
|
+
function nudgeOffPair(text: string, at: number): number {
|
|
76
|
+
const bounded = Math.max(0, Math.min(at, text.length));
|
|
77
|
+
if (bounded <= 0 || bounded >= text.length) return bounded;
|
|
78
|
+
const before = text.charCodeAt(bounded - 1);
|
|
79
|
+
const after = text.charCodeAt(bounded);
|
|
80
|
+
const splits = before >= 0xd800 && before <= 0xdbff && after >= 0xdc00 && after <= 0xdfff;
|
|
81
|
+
return splits ? bounded - 1 : bounded;
|
|
82
|
+
}
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Restoring the selection only while the field is focused keeps the paint
|
|
86
|
+
from setting a selection nobody asked for, and from scrolling an offscreen
|
|
87
|
+
field into view.
|
|
88
|
+
|
|
89
|
+
`origin` carries who wrote the change, the way a map subscriber's does:
|
|
90
|
+
`local`, `remote`, `undo`, or `redo`, and undefined on a fire describing no
|
|
91
|
+
authored transition (registration, and the catch-up a version checkout
|
|
92
|
+
produces). Splitting on `local` is what keeps this work off the typing
|
|
93
|
+
path, where it would otherwise run on every keystroke.
|
|
94
|
+
|
|
95
|
+
## Defer the paint, not the write, while an IME is open
|
|
96
|
+
|
|
97
|
+
Assigning to the field while an IME is open discards what it is holding,
|
|
98
|
+
which costs a reader typing Japanese, Chinese, or Korean the word they were
|
|
99
|
+
in the middle of. So track `compositionstart` / `compositionend` and skip
|
|
100
|
+
the paint in between.
|
|
101
|
+
|
|
102
|
+
Keep writing through `update` the whole time. `input` fires during a
|
|
103
|
+
composition, the write touches no DOM, and the container merges it with
|
|
104
|
+
whatever a peer sends meanwhile:
|
|
105
|
+
|
|
106
|
+
```ts
|
|
107
|
+
let composing = false;
|
|
108
|
+
let missed = false;
|
|
109
|
+
|
|
110
|
+
field.addEventListener('compositionstart', () => { composing = true; });
|
|
111
|
+
field.addEventListener('compositionend', () => {
|
|
112
|
+
composing = false;
|
|
113
|
+
painted = field.value;
|
|
114
|
+
report.update(painted);
|
|
115
|
+
// What arrived while the composition was open is older than what the
|
|
116
|
+
// reader just committed, so paint the container's own value, which
|
|
117
|
+
// carries both.
|
|
118
|
+
if (missed) {
|
|
119
|
+
missed = false;
|
|
120
|
+
const latest = report.read();
|
|
121
|
+
if (latest !== painted) paintPeerEdit(latest);
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Holding the incoming value and applying it at `compositionend` is the
|
|
127
|
+
version that looks right and loses data: that value predates the
|
|
128
|
+
composition, so painting it discards the word the reader just committed.
|
|
129
|
+
Deferring the write instead has the same shape of bug one layer down, since
|
|
130
|
+
the field's value then has no record of the peer's edit and `update` diffs
|
|
131
|
+
it back out.
|
|
132
|
+
|
|
133
|
+
## Publish the caret too
|
|
134
|
+
|
|
135
|
+
`state.textCaret` in [`presence.md`](./presence.md) is
|
|
136
|
+
`{ key, anchor, head }`: the container the caret sits in, and its two
|
|
137
|
+
offsets in the same UTF-16 code units this page uses. Publishing it on
|
|
138
|
+
every selection change lets peers draw where you are working, and the 10 Hz
|
|
139
|
+
presence debounce already paces it.
|
|
140
|
+
|
|
141
|
+
## What a keystroke costs
|
|
142
|
+
|
|
143
|
+
`update` compares against the whole stored string to find the smallest
|
|
144
|
+
edit, so its cost scales with the field's own length. Measured through the
|
|
145
|
+
handle on a 2.1 GHz core, after 20,000 edits, typing at a realistic
|
|
146
|
+
revision pattern:
|
|
147
|
+
|
|
148
|
+
| field | ms per keystroke |
|
|
149
|
+
| --- | --- |
|
|
150
|
+
| 20 KB | 0.72 |
|
|
151
|
+
| 100 KB | 1.72 |
|
|
152
|
+
| 300 KB | 4.09 |
|
|
153
|
+
|
|
154
|
+
One paragraph, one description, or one report is far inside a frame at
|
|
155
|
+
typing speed. A field holding a book is not, and an app whose document
|
|
156
|
+
grows that large wants its prose split across containers, one per section,
|
|
157
|
+
so an edit compares against a section rather than the book.
|
|
158
|
+
|
|
159
|
+
A second, smaller term grows with the document's accumulated edit history,
|
|
160
|
+
because materializing a text container costs more as its rope fragments.
|
|
161
|
+
Composing sentences barely fragments it (20,000 keystrokes on a 20 KB field
|
|
162
|
+
stay at 0.46 ms and leave a 2 KB snapshot); revising in short runs across
|
|
163
|
+
the field reaches 0.64 ms. The history term is permanent rather than
|
|
164
|
+
per-session, since a snapshot carries the history.
|
|
165
|
+
|
|
166
|
+
## One root per document-shaped thing, not per row
|
|
167
|
+
|
|
168
|
+
A text container is a top-level root, and no nested-text surface exists, so
|
|
169
|
+
per-element prose that merges costs one root per element. Loro prices a
|
|
170
|
+
container against how many the document holds, so that does not stay flat
|
|
171
|
+
the way the record store does, where paging holds container count down past
|
|
172
|
+
`UNPAGED_MAX_RECORDS`. Creating one text root per element against the same
|
|
173
|
+
elements written as records:
|
|
174
|
+
|
|
175
|
+
| elements | a root each | as records | roots heap | records heap |
|
|
176
|
+
| --- | --- | --- | --- | --- |
|
|
177
|
+
| 5,000 | 599 ms | 403 ms | 27 MB | 3 MB |
|
|
178
|
+
| 10,000 | 1,460 ms | 182 ms | 52 MB | 2 MB |
|
|
179
|
+
| 20,000 | 9,318 ms | 404 ms | 104 MB | 3 MB |
|
|
180
|
+
| 40,000 | 26,419 ms | 1,206 ms | 194 MB | 10 MB |
|
|
181
|
+
|
|
182
|
+
Registering a subscriber costs the same per root, so an editor that shows
|
|
183
|
+
every row's note pays that cost again on every open.
|
|
184
|
+
|
|
185
|
+
So prose that belongs to each row of a repeated collection stays in the
|
|
186
|
+
row's record, where the row is the merge unit. Two people typing in one
|
|
187
|
+
row's note then keep one of the two, which is the trade a repeated
|
|
188
|
+
collection makes. Where one particular row is the thing people write
|
|
189
|
+
together, give that row's prose a root of its own and leave the rest in the
|
|
190
|
+
store. Roots in the hundreds are cheap; the curve is what decides, not a
|
|
191
|
+
fixed cap.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@brass-build/client",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Client SDK for Brass",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -11,16 +11,15 @@
|
|
|
11
11
|
"types": "./dist/index.d.ts",
|
|
12
12
|
"import": "./dist/index.js"
|
|
13
13
|
},
|
|
14
|
-
"./vite": {
|
|
15
|
-
"types": "./dist/vite.d.ts",
|
|
16
|
-
"import": "./dist/vite.js"
|
|
17
|
-
},
|
|
18
14
|
"./internal": {
|
|
19
15
|
"types": "./dist/internal.d.ts",
|
|
20
16
|
"import": "./dist/internal.js"
|
|
21
17
|
},
|
|
22
18
|
"./package.json": "./package.json"
|
|
23
19
|
},
|
|
20
|
+
"browser": {
|
|
21
|
+
"./dist/wasm-runtime.js": "./dist/wasm-runtime.browser.js"
|
|
22
|
+
},
|
|
24
23
|
"files": [
|
|
25
24
|
"dist",
|
|
26
25
|
"src",
|
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
// Which organization an act that has no document spends.
|
|
2
|
+
//
|
|
3
|
+
// An app opened from a dashboard is launched with one, and an app holding an
|
|
4
|
+
// open document inherits that document's. Everything else is resolved by the
|
|
5
|
+
// server from the caller's own context at the first act that needs it, so the
|
|
6
|
+
// app is never handed a list and never asks at boot.
|
|
7
|
+
//
|
|
8
|
+
// The per-app sessionStorage slot below carries the answer forward within a
|
|
9
|
+
// tab: keys are namespaced per app id so integrations on one origin don't
|
|
10
|
+
// collide, and the slot is cleared on sign-out (session storage also drops on
|
|
11
|
+
// tab close).
|
|
12
|
+
|
|
13
|
+
import { applyAppAssertionHeader, applyClientVersionHeader } from './api-headers.js';
|
|
14
|
+
import { BrassError } from './errors.js';
|
|
15
|
+
|
|
16
|
+
const KEY_PREFIX = '__brass_acting_org_';
|
|
17
|
+
|
|
18
|
+
// Single per-page-load warn: a resolve runs on every act that needs an
|
|
19
|
+
// organization, so without the latch a server-rendered host would flood its
|
|
20
|
+
// logs.
|
|
21
|
+
let warnedUnavailable = false;
|
|
22
|
+
|
|
23
|
+
function getStorage(): Storage | null {
|
|
24
|
+
if (typeof sessionStorage === 'undefined') return null;
|
|
25
|
+
try {
|
|
26
|
+
return sessionStorage;
|
|
27
|
+
} catch {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function warnOnceUnavailable(): void {
|
|
33
|
+
if (warnedUnavailable) return;
|
|
34
|
+
warnedUnavailable = true;
|
|
35
|
+
console.warn(
|
|
36
|
+
'Brass: sessionStorage is unavailable, so the organization this app acts in is resolved again on every page load.',
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function orgKey(appId: string): string {
|
|
41
|
+
return `${KEY_PREFIX}${appId}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function storeActingOrganization(appId: string, organizationId: string): void {
|
|
45
|
+
const storage = getStorage();
|
|
46
|
+
if (!storage) {
|
|
47
|
+
warnOnceUnavailable();
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
try {
|
|
51
|
+
storage.setItem(orgKey(appId), organizationId);
|
|
52
|
+
} catch {
|
|
53
|
+
// Quota / disabled storage; drop silently. The stored value is a
|
|
54
|
+
// preference the server re-derives without it, so losing it costs a
|
|
55
|
+
// resolve rather than the act that needed one.
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function readActingOrganization(appId: string): string | null {
|
|
60
|
+
const storage = getStorage();
|
|
61
|
+
let raw: string | null;
|
|
62
|
+
if (!storage) return null;
|
|
63
|
+
try {
|
|
64
|
+
raw = storage.getItem(orgKey(appId));
|
|
65
|
+
} catch {
|
|
66
|
+
// A storage that holds a handle but refuses reads (a partitioned or
|
|
67
|
+
// locked-down context). See `storeActingOrganization`.
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
const trimmed = raw?.trim() ?? '';
|
|
71
|
+
return trimmed === '' ? null : trimmed;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function clearActingOrganization(appId: string): void {
|
|
75
|
+
const storage = getStorage();
|
|
76
|
+
if (!storage) return;
|
|
77
|
+
try {
|
|
78
|
+
storage.removeItem(orgKey(appId));
|
|
79
|
+
} catch {
|
|
80
|
+
// See `storeActingOrganization`.
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// What the server answers when asked which organization this caller acts in:
|
|
85
|
+
// one organization, or the reason it cannot name one. Never a list.
|
|
86
|
+
export type ResolveOutcome =
|
|
87
|
+
| {
|
|
88
|
+
kind: 'resolved';
|
|
89
|
+
organizationId: string;
|
|
90
|
+
organizationName: string;
|
|
91
|
+
/**
|
|
92
|
+
* Whether this app is installed on the organization. A deliberate
|
|
93
|
+
* switch reads it so the install is raised before the switch takes
|
|
94
|
+
* effect, rather than leaving the reader acting somewhere the next call
|
|
95
|
+
* is refused.
|
|
96
|
+
*/
|
|
97
|
+
installed: boolean;
|
|
98
|
+
}
|
|
99
|
+
| { kind: 'needs-choice' }
|
|
100
|
+
| { kind: 'no-organization' };
|
|
101
|
+
|
|
102
|
+
export interface ResolveDeps {
|
|
103
|
+
/** The open document's organization, where one is open. */
|
|
104
|
+
activeDocumentOrganizationId: () => Promise<string | null>;
|
|
105
|
+
/** An organization already resolved on this page load. */
|
|
106
|
+
cachedResolution: () => string | null;
|
|
107
|
+
/**
|
|
108
|
+
* The organization the launch named, where this page load carried one. A
|
|
109
|
+
* person clicking Open from an organization's dashboard has just said where
|
|
110
|
+
* they are working, so it outranks a carried choice and is never read as
|
|
111
|
+
* stale: an app the organization has not installed is what the install
|
|
112
|
+
* consent behind the refusal is for.
|
|
113
|
+
*/
|
|
114
|
+
launchPreference: () => string | null;
|
|
115
|
+
/** The choice this tab carries for this app, from an earlier act. */
|
|
116
|
+
storedPreference: () => string | null;
|
|
117
|
+
fetchResolved: (prefer: string | null) => Promise<ResolveOutcome>;
|
|
118
|
+
/** The hosted dialog that asks the person; null when they dismissed it. */
|
|
119
|
+
pickOrganization: () => Promise<string | null>;
|
|
120
|
+
/**
|
|
121
|
+
* Drop the stored choice, for one the app can no longer act in. The server
|
|
122
|
+
* validates a preference against MEMBERSHIP, so a choice naming an
|
|
123
|
+
* organization the app has since been uninstalled from stays resolvable
|
|
124
|
+
* forever while serving nothing.
|
|
125
|
+
*/
|
|
126
|
+
forgetPreference: () => void;
|
|
127
|
+
remember: (
|
|
128
|
+
organizationId: string,
|
|
129
|
+
/** Empty where the resolution carried an id and no name. */
|
|
130
|
+
organizationName: string,
|
|
131
|
+
/**
|
|
132
|
+
* Whether to carry the choice past this page load. An organization this
|
|
133
|
+
* app is not installed on has not become one the app can act in yet: the
|
|
134
|
+
* act that resolved it is about to be refused, and the reader may decline
|
|
135
|
+
* the install that follows. Recording it anyway pins every later visit to
|
|
136
|
+
* an organization nothing they press can work in, with the chooser out of
|
|
137
|
+
* reach. It is persisted when the install lands instead.
|
|
138
|
+
*/
|
|
139
|
+
persist: boolean,
|
|
140
|
+
) => void;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Which organization the next act uses.
|
|
145
|
+
*
|
|
146
|
+
* `ask: false` runs the same ladder and stops where it would raise the hosted
|
|
147
|
+
* chooser, answering null instead. That is what an app reading its own state
|
|
148
|
+
* before it acts needs: the question the chooser asks is one only the person
|
|
149
|
+
* can answer, and asking it unbidden takes the page over for something the
|
|
150
|
+
* reader never pressed.
|
|
151
|
+
*/
|
|
152
|
+
export async function resolveActingOrganization(
|
|
153
|
+
deps: ResolveDeps,
|
|
154
|
+
options: { ask?: boolean } = {},
|
|
155
|
+
): Promise<string | null> {
|
|
156
|
+
const fromDocument = await deps.activeDocumentOrganizationId();
|
|
157
|
+
if (fromDocument) {
|
|
158
|
+
// Recorded, not merely returned. This is where the app is working, so it
|
|
159
|
+
// is what the next act spends once the document closes; without it a
|
|
160
|
+
// reader who opens a document, closes it and creates another is asked
|
|
161
|
+
// where to put the second one, having just worked in the answer. It carries no name (the
|
|
162
|
+
// document named an id) and does not reach the tab's slot: a document
|
|
163
|
+
// can live in an organization the reader reaches through a shared
|
|
164
|
+
// project and is not a member of, which is a place to act from while it
|
|
165
|
+
// is open rather than a choice to carry.
|
|
166
|
+
deps.remember(fromDocument, '', false);
|
|
167
|
+
return fromDocument;
|
|
168
|
+
}
|
|
169
|
+
const cached = deps.cachedResolution();
|
|
170
|
+
if (cached) return cached;
|
|
171
|
+
|
|
172
|
+
// The stored choice is a preference rather than an answer: the server
|
|
173
|
+
// validates it against membership and ignores one that no longer holds, so
|
|
174
|
+
// a stale value self-heals into whatever the caller's context resolves to.
|
|
175
|
+
const launch = deps.launchPreference();
|
|
176
|
+
const stored = launch === null ? deps.storedPreference() : null;
|
|
177
|
+
const first = await deps.fetchResolved(launch ?? stored);
|
|
178
|
+
if (first.kind === 'resolved') {
|
|
179
|
+
// Membership is not the whole of what makes a choice usable. An
|
|
180
|
+
// organization the app has been uninstalled from still resolves, so a
|
|
181
|
+
// stored choice naming one would raise the install for a place somebody
|
|
182
|
+
// deliberately removed this app from, on every load, for as long as the
|
|
183
|
+
// tab lives. Read as stale instead: drop it and ask again with no
|
|
184
|
+
// preference, which reaches whatever this caller's context resolves to
|
|
185
|
+
// and otherwise the chooser, where the reader says where to work now.
|
|
186
|
+
//
|
|
187
|
+
// Only for a choice this tab was CARRYING. A launch, a resolution the
|
|
188
|
+
// reader just made, or their single membership is the answer either way,
|
|
189
|
+
// and the install is what it needs.
|
|
190
|
+
if (!first.installed && stored !== null && first.organizationId === stored) {
|
|
191
|
+
deps.forgetPreference();
|
|
192
|
+
const healed = await deps.fetchResolved(null);
|
|
193
|
+
if (healed.kind === 'resolved') return keep(deps, healed);
|
|
194
|
+
} else {
|
|
195
|
+
return keep(deps, first);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Context could not answer. Only the person can, so a caller that asked not
|
|
200
|
+
// to raise a surface is told that rather than asked.
|
|
201
|
+
if (options.ask === false) return null;
|
|
202
|
+
|
|
203
|
+
// The surface offers their own memberships and creates one where they have
|
|
204
|
+
// none, so the answer it gives is always one the server can resolve: asking
|
|
205
|
+
// twice would be asking the same question again rather than making progress.
|
|
206
|
+
const picked = await deps.pickOrganization();
|
|
207
|
+
if (!picked) {
|
|
208
|
+
throw new BrassError(
|
|
209
|
+
first.kind === 'no-organization'
|
|
210
|
+
? 'No organization yet. Create one to continue.'
|
|
211
|
+
: 'No organization chosen.',
|
|
212
|
+
{ status: 400 },
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
const second = await deps.fetchResolved(picked);
|
|
216
|
+
if (second.kind === 'resolved') return keep(deps, second);
|
|
217
|
+
throw new BrassError('Brass could not use the organization you chose.', {
|
|
218
|
+
status: 400,
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function keep(
|
|
223
|
+
deps: ResolveDeps,
|
|
224
|
+
outcome: Extract<ResolveOutcome, { kind: 'resolved' }>,
|
|
225
|
+
): string {
|
|
226
|
+
deps.remember(outcome.organizationId, outcome.organizationName, outcome.installed);
|
|
227
|
+
return outcome.organizationId;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* What the chrome names: the organization the NEXT act would spend.
|
|
232
|
+
*
|
|
233
|
+
* An open document takes it over, because the document's organization is what
|
|
234
|
+
* every act made while it is open spends. That holds even when the name is
|
|
235
|
+
* unknown, which is a document in an organization this reader reaches through
|
|
236
|
+
* a directly shared project: the menu says nothing rather than naming the memo
|
|
237
|
+
* over it. Closing the document hands it back, so the two never disagree with
|
|
238
|
+
* what the app is about to do.
|
|
239
|
+
*
|
|
240
|
+
* Named whether or not the caller belongs to anywhere else. The menu offers a
|
|
241
|
+
* switch, and the surface behind it is also how a first or a second
|
|
242
|
+
* organization gets created, so what a caller with one has is a choice they
|
|
243
|
+
* have not made yet rather than no choice; naming the organization is what
|
|
244
|
+
* makes that switch legible instead of an offer to change something the menu
|
|
245
|
+
* never states.
|
|
246
|
+
*
|
|
247
|
+
* Null where neither is known, which is an ordinary state rather than a gap
|
|
248
|
+
* to fill: an app reached by its own URL, holding no document and nothing
|
|
249
|
+
* stored, has no organization until an act needs one, and resolving one to
|
|
250
|
+
* decorate a menu is exactly the boot-time question this design refuses.
|
|
251
|
+
*/
|
|
252
|
+
export function visibleOrganization(
|
|
253
|
+
openDocument: { organization: Named | null } | null,
|
|
254
|
+
memo: Named | null,
|
|
255
|
+
): { id: string; name: string } | null {
|
|
256
|
+
// An open document takes the menu over ENTIRELY, naming its organization
|
|
257
|
+
// or nothing. Falling back to the memo here would be the exact fault this
|
|
258
|
+
// exists to prevent: the document's organization is what its acts spend,
|
|
259
|
+
// so a menu naming the memo over an open document names an organization
|
|
260
|
+
// the app is not using.
|
|
261
|
+
if (openDocument) return named(openDocument.organization);
|
|
262
|
+
return named(memo);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
interface Named {
|
|
266
|
+
id: string;
|
|
267
|
+
name: string;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function named(organization: Named | null): { id: string; name: string } | null {
|
|
271
|
+
// An id with no name is a resolution taken from an open document, which
|
|
272
|
+
// names no organization. There is nothing to draw until the chrome asks the
|
|
273
|
+
// server for one.
|
|
274
|
+
if (!organization || organization.name === '') return null;
|
|
275
|
+
return { id: organization.id, name: organization.name };
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export interface ActingOrganizationFetchDeps {
|
|
279
|
+
apiBaseUrl: string;
|
|
280
|
+
getAccessToken: () => Promise<string | null>;
|
|
281
|
+
getAppAssertion: () => Promise<string | null>;
|
|
282
|
+
fetchImpl?: typeof fetch;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// The refusals that are a question for the person rather than a fault: the
|
|
286
|
+
// caller belongs to several organizations and none of the context named one,
|
|
287
|
+
// or belongs to none at all.
|
|
288
|
+
const OUTCOME_CODES: ReadonlyMap<string, 'needs-choice' | 'no-organization'> = new Map([
|
|
289
|
+
['destination_required', 'needs-choice'],
|
|
290
|
+
['no_organization', 'no-organization'],
|
|
291
|
+
]);
|
|
292
|
+
|
|
293
|
+
export async function fetchActingOrganization(
|
|
294
|
+
deps: ActingOrganizationFetchDeps,
|
|
295
|
+
prefer: string | null,
|
|
296
|
+
): Promise<ResolveOutcome> {
|
|
297
|
+
const token = await deps.getAccessToken();
|
|
298
|
+
if (!token) throw new BrassError('Not signed in', { status: 401 });
|
|
299
|
+
const headers: Record<string, string> = { authorization: `Bearer ${token}` };
|
|
300
|
+
applyClientVersionHeader(headers);
|
|
301
|
+
await applyAppAssertionHeader(headers, deps.getAppAssertion);
|
|
302
|
+
const base = deps.apiBaseUrl.replace(/\/$/, '');
|
|
303
|
+
const query = prefer ? `?prefer=${encodeURIComponent(prefer)}` : '';
|
|
304
|
+
const doFetch = deps.fetchImpl ?? fetch;
|
|
305
|
+
const response = await doFetch(`${base}/me/acting-organization${query}`, {
|
|
306
|
+
method: 'GET',
|
|
307
|
+
headers,
|
|
308
|
+
});
|
|
309
|
+
type Body = {
|
|
310
|
+
organization_id?: unknown;
|
|
311
|
+
organization_name?: unknown;
|
|
312
|
+
installed?: unknown;
|
|
313
|
+
error?: unknown;
|
|
314
|
+
code?: unknown;
|
|
315
|
+
};
|
|
316
|
+
let body: Body = {};
|
|
317
|
+
try {
|
|
318
|
+
body = (await response.json()) as Body;
|
|
319
|
+
} catch {
|
|
320
|
+
// Leaves the generic message below, which the status already qualifies.
|
|
321
|
+
}
|
|
322
|
+
if (response.ok && typeof body.organization_id === 'string') {
|
|
323
|
+
return {
|
|
324
|
+
kind: 'resolved',
|
|
325
|
+
organizationId: body.organization_id,
|
|
326
|
+
organizationName:
|
|
327
|
+
typeof body.organization_name === 'string' ? body.organization_name : '',
|
|
328
|
+
// Absent reads as installed, so an older api that does not send it
|
|
329
|
+
// leaves the act to raise the refusal as it always did rather than
|
|
330
|
+
// asking for an install nothing established was missing.
|
|
331
|
+
installed: body.installed !== false,
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
const kind =
|
|
335
|
+
typeof body.code === 'string' ? OUTCOME_CODES.get(body.code) : undefined;
|
|
336
|
+
if (response.status === 409 && kind) return { kind };
|
|
337
|
+
throw new BrassError(
|
|
338
|
+
typeof body.error === 'string' ? body.error : 'Could not resolve an organization',
|
|
339
|
+
{ status: response.status },
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* A deliberate switch, as the account menu drives it. Separate from
|
|
345
|
+
* `resolveActingOrganization` because the two differ in what follows the
|
|
346
|
+
* choice: a resolve is followed by the act that asked for it, which raises
|
|
347
|
+
* its own refusal, while a switch is followed by nothing at all. So the
|
|
348
|
+
* switch has to establish for itself that the app can act where the reader
|
|
349
|
+
* sent it, and has to tell the app that everything it read is now about a
|
|
350
|
+
* different organization.
|
|
351
|
+
*/
|
|
352
|
+
export interface SwitchDeps {
|
|
353
|
+
/** The organization being acted in now, so the surface can mark it. */
|
|
354
|
+
current: () => string | null;
|
|
355
|
+
pickOrganization: (current: string | undefined) => Promise<string | null>;
|
|
356
|
+
fetchResolved: (prefer: string) => Promise<ResolveOutcome>;
|
|
357
|
+
/** The hosted install dialog, pinned; null when the reader declined. */
|
|
358
|
+
offerInstall: (organizationId: string) => Promise<boolean>;
|
|
359
|
+
remember: (organizationId: string, organizationName: string) => void;
|
|
360
|
+
/** Re-reads the app under the new organization. */
|
|
361
|
+
reload: () => void;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
export async function switchActingOrganization(deps: SwitchDeps): Promise<boolean> {
|
|
365
|
+
const current = deps.current() ?? undefined;
|
|
366
|
+
const picked = await deps.pickOrganization(current);
|
|
367
|
+
if (!picked) return false;
|
|
368
|
+
// Re-picking the organization the app is already acting in is a valid
|
|
369
|
+
// answer to the question the surface asks, and it moves nothing. The
|
|
370
|
+
// ordinary way to reach it is a caller with one organization opening the
|
|
371
|
+
// chooser to see what is there, so it must not cost them their open
|
|
372
|
+
// document and a page reload to say where they already are.
|
|
373
|
+
if (picked === current) return false;
|
|
374
|
+
// The pick goes back to the server, which names the organization as the
|
|
375
|
+
// platform holds it and refuses one the caller cannot act in.
|
|
376
|
+
const named = await deps.fetchResolved(picked);
|
|
377
|
+
if (named.kind !== 'resolved') return false;
|
|
378
|
+
// An organization this app is not installed on cannot serve it, so the
|
|
379
|
+
// install is asked for here rather than left to the next act. Without this
|
|
380
|
+
// the switch appears to succeed and the refusal arrives later, or on a
|
|
381
|
+
// reload, against work the reader has already started.
|
|
382
|
+
if (!named.installed && !(await deps.offerInstall(named.organizationId))) {
|
|
383
|
+
// Declining abandons the switch. Recording an organization the app cannot
|
|
384
|
+
// act in leaves the reader on one organization's screen with nothing they
|
|
385
|
+
// press able to work.
|
|
386
|
+
return false;
|
|
387
|
+
}
|
|
388
|
+
deps.remember(named.organizationId, named.organizationName);
|
|
389
|
+
// Everything the app read under the previous organization is about a
|
|
390
|
+
// different one now, and the app has no way to know which of its own state
|
|
391
|
+
// that covers. The reader asked for this, so re-reading the app is the
|
|
392
|
+
// expected consequence of their own act rather than a surprise, and the
|
|
393
|
+
// choice is already recorded so it survives.
|
|
394
|
+
deps.reload();
|
|
395
|
+
return true;
|
|
396
|
+
}
|
package/src/api-headers.ts
CHANGED
|
@@ -16,13 +16,13 @@ export async function applyAppAssertionHeader(
|
|
|
16
16
|
if (assertion) headers['x-brass-app-assertion'] = assertion;
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
19
|
+
// Names the SDK and its version. Brass answers a build too old to serve with
|
|
20
|
+
// an upgrade message, which reaches the app as `session.error` and is
|
|
21
|
+
// rendered verbatim.
|
|
22
|
+
//
|
|
23
|
+
// Same boundary as the app-assertion header: Brass's own API calls carry it,
|
|
24
|
+
// a presigned URL never does. Attached unconditionally, so an app that only
|
|
25
|
+
// ever reads carries it too.
|
|
26
26
|
export function applyClientVersionHeader(headers: Record<string, string>): void {
|
|
27
27
|
headers['x-brass-client'] = SDK_CLIENT_ID;
|
|
28
28
|
}
|