@abraca/dabra 1.9.1 → 2.0.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/dist/abracadabra-provider.cjs +12680 -9133
- package/dist/abracadabra-provider.cjs.map +1 -1
- package/dist/abracadabra-provider.esm.js +12697 -9200
- package/dist/abracadabra-provider.esm.js.map +1 -1
- package/dist/index.d.ts +1426 -118
- package/package.json +1 -1
- package/src/AbracadabraBaseProvider.ts +51 -2
- package/src/AbracadabraClient.ts +516 -66
- package/src/AbracadabraProvider.ts +22 -7
- package/src/AbracadabraWS.ts +1 -1
- package/src/ChatClient.ts +193 -113
- package/src/ContentManager.ts +80 -12
- package/src/CryptoIdentityKeystore.ts +3 -3
- package/src/DocConverters.ts +161 -6
- package/src/DocKeyManager.ts +60 -12
- package/src/DocTypes.ts +10 -0
- package/src/DocumentManager.ts +62 -85
- package/src/EncryptedChatClient.ts +173 -0
- package/src/EncryptedY.ts +2 -2
- package/src/IdentityDoc.ts +25 -0
- package/src/MnemonicKeyDerivation.ts +4 -4
- package/src/NotificationsClient.ts +120 -98
- package/src/OutgoingMessages/SubdocMessage.ts +2 -2
- package/src/RpcClient.ts +659 -0
- package/src/TreeManager.ts +61 -17
- package/src/TreeTimestamps.ts +28 -25
- package/src/index.ts +71 -1
- package/src/messageRecord.ts +121 -0
- package/src/types.ts +166 -16
- package/src/webrtc/AbracadabraWebRTC.ts +2 -2
- package/src/webrtc/DataChannelRouter.ts +2 -2
- package/src/webrtc/E2EEChannel.ts +3 -3
- package/src/webrtc/FileTransferChannel.ts +9 -2
package/src/DocConverters.ts
CHANGED
|
@@ -118,7 +118,7 @@ function serializeElement(el: Y.XmlElement, indent = ""): string {
|
|
|
118
118
|
if (!docId) return "";
|
|
119
119
|
const seamlessAttr = el.getAttribute("seamless");
|
|
120
120
|
const seamless =
|
|
121
|
-
seamlessAttr === true || seamlessAttr === "true";
|
|
121
|
+
(seamlessAttr as unknown) === true || seamlessAttr === "true";
|
|
122
122
|
return seamless ? `![[${docId}]]{seamless}` : `![[${docId}]]`;
|
|
123
123
|
}
|
|
124
124
|
|
|
@@ -154,7 +154,7 @@ function serializeElement(el: Y.XmlElement, indent = ""): string {
|
|
|
154
154
|
const label = el.getAttribute("label") || "Details";
|
|
155
155
|
const open = el.getAttribute("open");
|
|
156
156
|
const props: string[] = [`label="${label}"`];
|
|
157
|
-
if (open === true || open === "true") props.push('open="true"');
|
|
157
|
+
if ((open as unknown) === true || open === "true") props.push('open="true"');
|
|
158
158
|
const inner = serializeChildren(el, indent);
|
|
159
159
|
return `::collapsible{${props.join(" ")}}\n${inner}\n::`;
|
|
160
160
|
}
|
|
@@ -219,7 +219,7 @@ function serializeElement(el: Y.XmlElement, indent = ""): string {
|
|
|
219
219
|
const props: string[] = [];
|
|
220
220
|
if (fieldName) props.push(`name="${fieldName}"`);
|
|
221
221
|
props.push(`type="${fieldType}"`);
|
|
222
|
-
if (required === true || required === "true")
|
|
222
|
+
if ((required as unknown) === true || required === "true")
|
|
223
223
|
props.push('required="true"');
|
|
224
224
|
const inner = serializeChildren(el, indent);
|
|
225
225
|
return `::field{${props.join(" ")}}\n${inner}\n::`;
|
|
@@ -266,7 +266,7 @@ function serializeTaskList(el: Y.XmlElement, indent: string): string {
|
|
|
266
266
|
) {
|
|
267
267
|
const checked = item.getAttribute("checked");
|
|
268
268
|
const marker =
|
|
269
|
-
checked === true || checked === "true" ? "[x]" : "[ ]";
|
|
269
|
+
(checked as unknown) === true || checked === "true" ? "[x]" : "[ ]";
|
|
270
270
|
const content = elementTextContent(item);
|
|
271
271
|
lines.push(`${indent}- ${marker} ${content}`);
|
|
272
272
|
}
|
|
@@ -504,7 +504,7 @@ export function parseFrontmatter(markdown: string): FrontmatterResult {
|
|
|
504
504
|
|
|
505
505
|
const checkedRaw = raw["checked"] ?? raw["done"];
|
|
506
506
|
if (checkedRaw !== undefined)
|
|
507
|
-
meta.checked = checkedRaw === "true" || checkedRaw === true;
|
|
507
|
+
meta.checked = checkedRaw === "true" || (checkedRaw as unknown) === true;
|
|
508
508
|
|
|
509
509
|
const dateStart = getStr(["date", "created"]);
|
|
510
510
|
if (dateStart) meta.dateStart = dateStart;
|
|
@@ -533,7 +533,7 @@ export function parseFrontmatter(markdown: string): FrontmatterResult {
|
|
|
533
533
|
if (datetimeEnd) meta.datetimeEnd = datetimeEnd;
|
|
534
534
|
const allDayRaw = raw["allDay"];
|
|
535
535
|
if (allDayRaw !== undefined)
|
|
536
|
-
meta.allDay = allDayRaw === "true" || allDayRaw === true;
|
|
536
|
+
meta.allDay = allDayRaw === "true" || (allDayRaw as unknown) === true;
|
|
537
537
|
|
|
538
538
|
// Geo fields
|
|
539
539
|
const geoLatRaw = getStr(["geoLat"]);
|
|
@@ -1705,3 +1705,158 @@ export function populateYDocFromMarkdown(
|
|
|
1705
1705
|
contentBlocks.forEach((block, i) => fillBlock(bodyEls[i]!, block));
|
|
1706
1706
|
});
|
|
1707
1707
|
}
|
|
1708
|
+
|
|
1709
|
+
// ── Block element builders (exported for ContentManager and SDK consumers) ────
|
|
1710
|
+
|
|
1711
|
+
export function buildHeadingElement(text: string, level: 1|2|3|4|5|6 = 1): Y.XmlElement {
|
|
1712
|
+
const el = new Y.XmlElement("heading");
|
|
1713
|
+
el.setAttribute("level", level as any);
|
|
1714
|
+
fillTextInto(el, parseInline(text));
|
|
1715
|
+
return el;
|
|
1716
|
+
}
|
|
1717
|
+
|
|
1718
|
+
export function buildParagraphElement(text: string): Y.XmlElement {
|
|
1719
|
+
const el = new Y.XmlElement("paragraph");
|
|
1720
|
+
fillTextInto(el, parseInline(text));
|
|
1721
|
+
return el;
|
|
1722
|
+
}
|
|
1723
|
+
|
|
1724
|
+
export function buildBulletListElement(items: string[]): Y.XmlElement {
|
|
1725
|
+
const el = new Y.XmlElement("bulletList");
|
|
1726
|
+
const itemEls = items.map(() => new Y.XmlElement("listItem"));
|
|
1727
|
+
el.insert(0, itemEls);
|
|
1728
|
+
items.forEach((text, i) => {
|
|
1729
|
+
const paraEl = new Y.XmlElement("paragraph");
|
|
1730
|
+
itemEls[i]!.insert(0, [paraEl]);
|
|
1731
|
+
fillTextInto(paraEl, parseInline(text));
|
|
1732
|
+
});
|
|
1733
|
+
return el;
|
|
1734
|
+
}
|
|
1735
|
+
|
|
1736
|
+
export function buildOrderedListElement(items: string[]): Y.XmlElement {
|
|
1737
|
+
const el = new Y.XmlElement("orderedList");
|
|
1738
|
+
const itemEls = items.map(() => new Y.XmlElement("listItem"));
|
|
1739
|
+
el.insert(0, itemEls);
|
|
1740
|
+
items.forEach((text, i) => {
|
|
1741
|
+
const paraEl = new Y.XmlElement("paragraph");
|
|
1742
|
+
itemEls[i]!.insert(0, [paraEl]);
|
|
1743
|
+
fillTextInto(paraEl, parseInline(text));
|
|
1744
|
+
});
|
|
1745
|
+
return el;
|
|
1746
|
+
}
|
|
1747
|
+
|
|
1748
|
+
export function buildTaskListElement(
|
|
1749
|
+
items: Array<{ text: string; checked?: boolean }>,
|
|
1750
|
+
): Y.XmlElement {
|
|
1751
|
+
const el = new Y.XmlElement("taskList");
|
|
1752
|
+
const itemEls = items.map(() => new Y.XmlElement("taskItem"));
|
|
1753
|
+
el.insert(0, itemEls);
|
|
1754
|
+
items.forEach((item, i) => {
|
|
1755
|
+
itemEls[i]!.setAttribute("checked", (item.checked ?? false) as any);
|
|
1756
|
+
const paraEl = new Y.XmlElement("paragraph");
|
|
1757
|
+
itemEls[i]!.insert(0, [paraEl]);
|
|
1758
|
+
fillTextInto(paraEl, parseInline(item.text));
|
|
1759
|
+
});
|
|
1760
|
+
return el;
|
|
1761
|
+
}
|
|
1762
|
+
|
|
1763
|
+
export function buildCodeBlockElement(code: string, language?: string): Y.XmlElement {
|
|
1764
|
+
const el = new Y.XmlElement("codeBlock");
|
|
1765
|
+
if (language) el.setAttribute("language", language);
|
|
1766
|
+
const xt = new Y.XmlText();
|
|
1767
|
+
el.insert(0, [xt]);
|
|
1768
|
+
xt.insert(0, code);
|
|
1769
|
+
return el;
|
|
1770
|
+
}
|
|
1771
|
+
|
|
1772
|
+
export function buildBlockquoteElement(text: string): Y.XmlElement {
|
|
1773
|
+
const el = new Y.XmlElement("blockquote");
|
|
1774
|
+
const paraEl = new Y.XmlElement("paragraph");
|
|
1775
|
+
el.insert(0, [paraEl]);
|
|
1776
|
+
fillTextInto(paraEl, parseInline(text));
|
|
1777
|
+
return el;
|
|
1778
|
+
}
|
|
1779
|
+
|
|
1780
|
+
export function buildHorizontalRuleElement(): Y.XmlElement {
|
|
1781
|
+
return new Y.XmlElement("horizontalRule");
|
|
1782
|
+
}
|
|
1783
|
+
|
|
1784
|
+
/** Parse markdown into block Y.XmlElements (no header/meta). */
|
|
1785
|
+
export function buildBlocksFromMarkdown(markdown: string): Y.XmlElement[] {
|
|
1786
|
+
const blocks = parseBlocks(markdown);
|
|
1787
|
+
return blocks
|
|
1788
|
+
.filter((b) => b.type !== "heading" || (b as any).level !== 1)
|
|
1789
|
+
.map((b) => {
|
|
1790
|
+
const el = new Y.XmlElement(blockElName(b));
|
|
1791
|
+
fillBlock(el, b);
|
|
1792
|
+
return el;
|
|
1793
|
+
});
|
|
1794
|
+
}
|
|
1795
|
+
|
|
1796
|
+
// ── Block reader ──────────────────────────────────────────────────────────────
|
|
1797
|
+
|
|
1798
|
+
export interface DocumentBlock {
|
|
1799
|
+
type: string;
|
|
1800
|
+
attrs: Record<string, unknown>;
|
|
1801
|
+
text: string;
|
|
1802
|
+
items?: string[];
|
|
1803
|
+
children?: DocumentBlock[];
|
|
1804
|
+
}
|
|
1805
|
+
|
|
1806
|
+
export function readBlocksFromFragment(fragment: Y.XmlFragment): DocumentBlock[] {
|
|
1807
|
+
const result: DocumentBlock[] = [];
|
|
1808
|
+
for (let i = 0; i < fragment.length; i++) {
|
|
1809
|
+
const child = fragment.get(i);
|
|
1810
|
+
if (!(child instanceof Y.XmlElement)) continue;
|
|
1811
|
+
const name = child.nodeName;
|
|
1812
|
+
if (name === "documentHeader" || name === "documentMeta") continue;
|
|
1813
|
+
result.push(_xmlElToBlock(child));
|
|
1814
|
+
}
|
|
1815
|
+
return result;
|
|
1816
|
+
}
|
|
1817
|
+
|
|
1818
|
+
function _xmlElToBlock(el: Y.XmlElement): DocumentBlock {
|
|
1819
|
+
const name = el.nodeName;
|
|
1820
|
+
switch (name) {
|
|
1821
|
+
case "heading":
|
|
1822
|
+
return { type: "heading", attrs: { level: el.getAttribute("level") }, text: elementTextContent(el) };
|
|
1823
|
+
case "paragraph":
|
|
1824
|
+
case "blockquote":
|
|
1825
|
+
case "horizontalRule":
|
|
1826
|
+
return { type: name, attrs: {}, text: elementTextContent(el) };
|
|
1827
|
+
case "codeBlock": {
|
|
1828
|
+
const lang = el.getAttribute("language");
|
|
1829
|
+
return { type: "codeBlock", attrs: lang ? { language: lang } : {}, text: elementTextContent(el) };
|
|
1830
|
+
}
|
|
1831
|
+
case "bulletList":
|
|
1832
|
+
case "orderedList": {
|
|
1833
|
+
const items: string[] = [];
|
|
1834
|
+
for (let i = 0; i < el.length; i++) {
|
|
1835
|
+
const item = el.get(i);
|
|
1836
|
+
if (item instanceof Y.XmlElement && item.nodeName === "listItem")
|
|
1837
|
+
items.push(elementTextContent(item));
|
|
1838
|
+
}
|
|
1839
|
+
return { type: name, attrs: {}, text: "", items };
|
|
1840
|
+
}
|
|
1841
|
+
case "taskList": {
|
|
1842
|
+
const items: string[] = [];
|
|
1843
|
+
const children: DocumentBlock[] = [];
|
|
1844
|
+
for (let i = 0; i < el.length; i++) {
|
|
1845
|
+
const item = el.get(i);
|
|
1846
|
+
if (item instanceof Y.XmlElement && item.nodeName === "taskItem") {
|
|
1847
|
+
const checked = item.getAttribute("checked");
|
|
1848
|
+
const text = elementTextContent(item);
|
|
1849
|
+
items.push(text);
|
|
1850
|
+
children.push({
|
|
1851
|
+
type: "taskItem",
|
|
1852
|
+
attrs: { checked: checked === "true" || (checked as unknown) === true },
|
|
1853
|
+
text,
|
|
1854
|
+
});
|
|
1855
|
+
}
|
|
1856
|
+
}
|
|
1857
|
+
return { type: "taskList", attrs: {}, text: "", items, children };
|
|
1858
|
+
}
|
|
1859
|
+
default:
|
|
1860
|
+
return { type: name, attrs: {}, text: elementTextContent(el) };
|
|
1861
|
+
}
|
|
1862
|
+
}
|
package/src/DocKeyManager.ts
CHANGED
|
@@ -45,16 +45,64 @@ export class DocKeyManager {
|
|
|
45
45
|
}
|
|
46
46
|
|
|
47
47
|
const envelope = await client.getMyKeyEnvelope(docId);
|
|
48
|
-
if (
|
|
48
|
+
if (envelope) {
|
|
49
|
+
const x25519PrivKey = await keystore.getX25519PrivateKey();
|
|
50
|
+
try {
|
|
51
|
+
const wrapped = fromBase64(envelope.encrypted_key);
|
|
52
|
+
const docKey = await this._unwrapKey(wrapped, x25519PrivKey, docId);
|
|
53
|
+
this.cache.set(docId, { key: docKey, epoch: envelope.key_epoch, fetchedAt: Date.now() });
|
|
54
|
+
return docKey;
|
|
55
|
+
} finally {
|
|
56
|
+
x25519PrivKey.fill(0);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
49
59
|
|
|
50
|
-
|
|
60
|
+
// Inheritance fallback. The doc has no envelope of our own — but it
|
|
61
|
+
// might inherit its encryption mode from an ancestor (e.g. a child
|
|
62
|
+
// page under an E2E space). The ancestor IS provisioned for us
|
|
63
|
+
// (otherwise we couldn't open the parent either). Resolve the
|
|
64
|
+
// encryption-source via /docs/:id/encryption.inherited_from, fetch
|
|
65
|
+
// OUR envelope on that source, unwrap with the source's salt,
|
|
66
|
+
// re-wrap for the current docId with its own salt, upload the new
|
|
67
|
+
// envelope so subsequent calls hit the direct-envelope path.
|
|
51
68
|
try {
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
69
|
+
const enc = await client.getDocEncryption(docId);
|
|
70
|
+
if (enc.effective_mode !== "e2e" || !enc.inherited_from || enc.inherited_from === docId) {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
const sourceEnv = await client.getMyKeyEnvelope(enc.inherited_from);
|
|
74
|
+
if (!sourceEnv) return null;
|
|
75
|
+
const x25519PrivKey = await keystore.getX25519PrivateKey();
|
|
76
|
+
try {
|
|
77
|
+
const wrappedSrc = fromBase64(sourceEnv.encrypted_key);
|
|
78
|
+
const docKey = await this._unwrapKey(wrappedSrc, x25519PrivKey, enc.inherited_from);
|
|
79
|
+
this.cache.set(docId, { key: docKey, epoch: sourceEnv.key_epoch, fetchedAt: Date.now() });
|
|
80
|
+
// Re-wrap for the current doc + upload so future opens take
|
|
81
|
+
// the direct path. Best-effort — caller already has the key.
|
|
82
|
+
const me = await client.getMe();
|
|
83
|
+
if (me.publicKey) {
|
|
84
|
+
const myKeys = await client.listUserKeys(me.id);
|
|
85
|
+
const primaryKey = myKeys[0];
|
|
86
|
+
if (primaryKey?.x25519Key) {
|
|
87
|
+
const x25519Pub = fromBase64(primaryKey.x25519Key.replace(/-/g, "+").replace(/_/g, "/"));
|
|
88
|
+
const rewrapped = await this.wrapKeyForRecipient(docKey, x25519Pub, docId);
|
|
89
|
+
const b64 = (() => {
|
|
90
|
+
let s = "";
|
|
91
|
+
for (let i = 0; i < rewrapped.length; i++) s += String.fromCharCode(rewrapped[i]!);
|
|
92
|
+
return btoa(s);
|
|
93
|
+
})();
|
|
94
|
+
await client.uploadKeyEnvelopes(docId, {
|
|
95
|
+
key_epoch: sourceEnv.key_epoch,
|
|
96
|
+
envelopes: [{ recipient_key_id: primaryKey.id, encrypted_key: b64 }],
|
|
97
|
+
}).catch(() => null);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return docKey;
|
|
101
|
+
} finally {
|
|
102
|
+
x25519PrivKey.fill(0);
|
|
103
|
+
}
|
|
104
|
+
} catch {
|
|
105
|
+
return null;
|
|
58
106
|
}
|
|
59
107
|
}
|
|
60
108
|
|
|
@@ -73,11 +121,11 @@ export class DocKeyManager {
|
|
|
73
121
|
|
|
74
122
|
const salt = new TextEncoder().encode(docId);
|
|
75
123
|
const keyBytes = hkdf(sha256, sharedSecret, salt, HKDF_INFO, 32);
|
|
76
|
-
const wrapKey = await crypto.subtle.importKey("raw", keyBytes, { name: "AES-GCM" }, false, ["encrypt"]);
|
|
124
|
+
const wrapKey = await crypto.subtle.importKey("raw", keyBytes as BufferSource, { name: "AES-GCM" }, false, ["encrypt"]);
|
|
77
125
|
|
|
78
126
|
const rawDocKey = await crypto.subtle.exportKey("raw", docKey);
|
|
79
127
|
const nonce = crypto.getRandomValues(new Uint8Array(12));
|
|
80
|
-
const ciphertext = new Uint8Array(await crypto.subtle.encrypt({ name: "AES-GCM", iv: nonce }, wrapKey, rawDocKey));
|
|
128
|
+
const ciphertext = new Uint8Array(await crypto.subtle.encrypt({ name: "AES-GCM", iv: nonce as BufferSource }, wrapKey, rawDocKey));
|
|
81
129
|
|
|
82
130
|
const result = new Uint8Array(32 + 12 + ciphertext.length);
|
|
83
131
|
result.set(ephemeralPub, 0);
|
|
@@ -94,8 +142,8 @@ export class DocKeyManager {
|
|
|
94
142
|
const sharedSecret = x25519.getSharedSecret(recipientX25519PrivKey, ephemeralPub);
|
|
95
143
|
const salt = new TextEncoder().encode(docId);
|
|
96
144
|
const keyBytes = hkdf(sha256, sharedSecret, salt, HKDF_INFO, 32);
|
|
97
|
-
const wrapKey = await crypto.subtle.importKey("raw", keyBytes, { name: "AES-GCM" }, false, ["decrypt"]);
|
|
98
|
-
const rawDocKey = await crypto.subtle.decrypt({ name: "AES-GCM", iv: nonce }, wrapKey, ciphertext);
|
|
145
|
+
const wrapKey = await crypto.subtle.importKey("raw", keyBytes as BufferSource, { name: "AES-GCM" }, false, ["decrypt"]);
|
|
146
|
+
const rawDocKey = await crypto.subtle.decrypt({ name: "AES-GCM", iv: nonce as BufferSource }, wrapKey, ciphertext as BufferSource);
|
|
99
147
|
return crypto.subtle.importKey("raw", rawDocKey, { name: "AES-GCM" }, true, ["encrypt", "decrypt"]);
|
|
100
148
|
}
|
|
101
149
|
|
package/src/DocTypes.ts
CHANGED
|
@@ -321,6 +321,16 @@ export const PAGE_TYPES: Record<string, PageTypeInfo> = {
|
|
|
321
321
|
core: true,
|
|
322
322
|
supportsChildren: true,
|
|
323
323
|
},
|
|
324
|
+
prose: {
|
|
325
|
+
key: 'prose',
|
|
326
|
+
label: 'Prose',
|
|
327
|
+
icon: 'pen-tool',
|
|
328
|
+
description: 'Long-form prose with serif typography and a narrow readable measure',
|
|
329
|
+
core: true,
|
|
330
|
+
supportsChildren: true,
|
|
331
|
+
childLabel: 'Item',
|
|
332
|
+
defaultDepth: -1,
|
|
333
|
+
},
|
|
324
334
|
kanban: {
|
|
325
335
|
key: 'kanban',
|
|
326
336
|
label: 'Kanban',
|
package/src/DocumentManager.ts
CHANGED
|
@@ -24,7 +24,9 @@
|
|
|
24
24
|
import * as Y from "yjs";
|
|
25
25
|
import { AbracadabraProvider } from "./AbracadabraProvider.ts";
|
|
26
26
|
import { AbracadabraClient } from "./AbracadabraClient.ts";
|
|
27
|
-
import type {
|
|
27
|
+
import type { AbracadabraWS } from "./AbracadabraWS.ts";
|
|
28
|
+
import type { ServerInfo, DocumentMeta } from "./types.ts";
|
|
29
|
+
import { Kind, SERVER_ROOT_ID } from "./types.ts";
|
|
28
30
|
import { TreeManager } from "./TreeManager.ts";
|
|
29
31
|
import { ContentManager } from "./ContentManager.ts";
|
|
30
32
|
import { MetaManager } from "./MetaManager.ts";
|
|
@@ -52,26 +54,19 @@ export interface DocumentManagerConfig {
|
|
|
52
54
|
* already authenticated or configured the client externally.
|
|
53
55
|
*/
|
|
54
56
|
client?: AbracadabraClient;
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
visibility,
|
|
69
|
-
is_hub: doc.is_hub ?? false,
|
|
70
|
-
owner_id: doc.owner_id ?? null,
|
|
71
|
-
created_at: 0,
|
|
72
|
-
updated_at: doc.updated_at ?? 0,
|
|
73
|
-
public_access: publicAccess ?? null,
|
|
74
|
-
};
|
|
57
|
+
/**
|
|
58
|
+
* Shared WebSocket connection. Required in Node.js environments — pass an
|
|
59
|
+
* AbracadabraWS constructed with WebSocketPolyfill set to the `ws` package.
|
|
60
|
+
* When omitted, each provider creates its own connection from client.wsUrl.
|
|
61
|
+
* The caller owns this instance and must destroy it after dm.destroy().
|
|
62
|
+
*/
|
|
63
|
+
websocketProvider?: AbracadabraWS;
|
|
64
|
+
/**
|
|
65
|
+
* Known root document ID. When provided, connect() skips server discovery
|
|
66
|
+
* and connects directly to this document. Useful in tests or CLIs where
|
|
67
|
+
* the entry-point docId is already known.
|
|
68
|
+
*/
|
|
69
|
+
rootDocId?: string;
|
|
75
70
|
}
|
|
76
71
|
|
|
77
72
|
interface CachedProvider {
|
|
@@ -92,7 +87,7 @@ export class DocumentManager {
|
|
|
92
87
|
private _config: DocumentManagerConfig;
|
|
93
88
|
private _serverInfo: ServerInfo | null = null;
|
|
94
89
|
private _rootDocId: string | null = null;
|
|
95
|
-
private _spaces:
|
|
90
|
+
private _spaces: DocumentMeta[] = [];
|
|
96
91
|
private _rootDoc: Y.Doc | null = null;
|
|
97
92
|
private _rootProvider: AbracadabraProvider | null = null;
|
|
98
93
|
private childCache = new Map<string, CachedProvider>();
|
|
@@ -138,7 +133,11 @@ export class DocumentManager {
|
|
|
138
133
|
return this._rootProvider;
|
|
139
134
|
}
|
|
140
135
|
|
|
141
|
-
|
|
136
|
+
/**
|
|
137
|
+
* Spaces visible to the caller — direct children of the server root with
|
|
138
|
+
* `kind === "space"`. Populated by {@link connect}.
|
|
139
|
+
*/
|
|
140
|
+
get spaces(): DocumentMeta[] {
|
|
142
141
|
return this._spaces;
|
|
143
142
|
}
|
|
144
143
|
|
|
@@ -172,74 +171,42 @@ export class DocumentManager {
|
|
|
172
171
|
* **Authentication must be done before calling connect().**
|
|
173
172
|
* Call `dm.client.loginWithKey(publicKey, signFn)` or `dm.client.login()`
|
|
174
173
|
* to authenticate first.
|
|
174
|
+
*
|
|
175
|
+
* When `rootDocId` is set in the config, server discovery is skipped and
|
|
176
|
+
* the manager connects directly to that document.
|
|
175
177
|
*/
|
|
176
178
|
async connect(): Promise<void> {
|
|
177
|
-
// Step 1: Discover server info
|
|
179
|
+
// Step 1: Discover server info (used for awareness colour, name, etc.).
|
|
178
180
|
this._serverInfo = await this.client.serverInfo();
|
|
179
181
|
|
|
180
|
-
// Step 2:
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
initialDocId =
|
|
194
|
-
this.log(
|
|
195
|
-
`No hub, using first root doc: ${roots[0].label ?? roots[0].id}`,
|
|
196
|
-
);
|
|
197
|
-
}
|
|
198
|
-
} catch {
|
|
199
|
-
try {
|
|
200
|
-
this._spaces = await this.client.listSpaces();
|
|
201
|
-
const hub = this._spaces.find((s) => s.is_hub);
|
|
202
|
-
if (hub) {
|
|
203
|
-
initialDocId = hub.doc_id;
|
|
204
|
-
} else if (this._spaces.length > 0) {
|
|
205
|
-
initialDocId = this._spaces[0].doc_id;
|
|
206
|
-
}
|
|
207
|
-
} catch {
|
|
208
|
-
this.log(
|
|
209
|
-
"Neither /docs?root=true nor /spaces available, using index_doc_id",
|
|
210
|
-
);
|
|
182
|
+
// Step 2: Pick an entry-point doc.
|
|
183
|
+
//
|
|
184
|
+
// In the new model the dashboard's notion of "open the hub" maps to
|
|
185
|
+
// "open the first Space". A Space is a top-level doc with
|
|
186
|
+
// `kind === "space"`. If the caller pinned a `rootDocId`, honour it
|
|
187
|
+
// directly — useful in tests/CLIs where the entry doc is known.
|
|
188
|
+
let initialDocId: string | null = this._config.rootDocId ?? null;
|
|
189
|
+
|
|
190
|
+
if (!initialDocId) {
|
|
191
|
+
const roots = await this.client.listChildren();
|
|
192
|
+
this._spaces = roots.filter((d) => d.kind === Kind.Space);
|
|
193
|
+
const first = this._spaces[0] ?? roots[0];
|
|
194
|
+
if (first) {
|
|
195
|
+
initialDocId = first.id;
|
|
196
|
+
this.log(`Entry document: ${first.label ?? first.id} (${first.id})`);
|
|
211
197
|
}
|
|
212
198
|
}
|
|
213
199
|
|
|
214
200
|
if (!initialDocId) {
|
|
215
201
|
throw new Error(
|
|
216
|
-
|
|
202
|
+
`No entry point found: server has no top-level documents under ${SERVER_ROOT_ID}. Create a Space first.`,
|
|
217
203
|
);
|
|
218
204
|
}
|
|
219
205
|
|
|
220
206
|
this._rootDocId = initialDocId;
|
|
221
207
|
|
|
222
208
|
// Step 3: Connect provider and sync
|
|
223
|
-
|
|
224
|
-
const provider = new AbracadabraProvider({
|
|
225
|
-
name: initialDocId,
|
|
226
|
-
document: doc,
|
|
227
|
-
client: this.client,
|
|
228
|
-
disableOfflineStore:
|
|
229
|
-
this._config.disableOfflineStore ?? true,
|
|
230
|
-
subdocLoading: "lazy",
|
|
231
|
-
});
|
|
232
|
-
|
|
233
|
-
await waitForSync(provider);
|
|
234
|
-
|
|
235
|
-
provider.awareness.setLocalStateField("user", {
|
|
236
|
-
name: this.displayName,
|
|
237
|
-
color: this.displayColor,
|
|
238
|
-
});
|
|
239
|
-
provider.awareness.setLocalStateField("status", null);
|
|
240
|
-
|
|
241
|
-
this._rootDoc = doc;
|
|
242
|
-
this._rootProvider = provider;
|
|
209
|
+
await this._connectToRoot(initialDocId);
|
|
243
210
|
this.log("Connected and synced");
|
|
244
211
|
}
|
|
245
212
|
|
|
@@ -251,35 +218,45 @@ export class DocumentManager {
|
|
|
251
218
|
}
|
|
252
219
|
this.childCache.clear();
|
|
253
220
|
|
|
254
|
-
// Destroy current root provider
|
|
221
|
+
// Destroy current root provider (not the wsp — caller owns it)
|
|
255
222
|
if (this._rootProvider) {
|
|
256
223
|
this._rootProvider.destroy();
|
|
257
224
|
this._rootProvider = null;
|
|
258
225
|
}
|
|
259
226
|
this._rootDoc = null;
|
|
260
227
|
|
|
261
|
-
|
|
228
|
+
await this._connectToRoot(docId);
|
|
229
|
+
this._rootDocId = docId;
|
|
230
|
+
this.log(`Switched to space ${docId}`);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Create, sync, and set awareness on a root provider for the given docId. */
|
|
234
|
+
private async _connectToRoot(docId: string): Promise<void> {
|
|
262
235
|
const doc = new Y.Doc({ guid: docId });
|
|
236
|
+
const wsp = this._config.websocketProvider;
|
|
263
237
|
const provider = new AbracadabraProvider({
|
|
264
238
|
name: docId,
|
|
265
239
|
document: doc,
|
|
266
240
|
client: this.client,
|
|
267
|
-
disableOfflineStore:
|
|
268
|
-
this._config.disableOfflineStore ?? true,
|
|
241
|
+
disableOfflineStore: this._config.disableOfflineStore ?? true,
|
|
269
242
|
subdocLoading: "lazy",
|
|
243
|
+
...(wsp ? { websocketProvider: wsp } : {}),
|
|
270
244
|
});
|
|
271
245
|
|
|
246
|
+
// When a shared websocketProvider is supplied, manageSocket=false so
|
|
247
|
+
// the base constructor does NOT call attach(). We must do it ourselves.
|
|
248
|
+
if (wsp) provider.attach();
|
|
249
|
+
|
|
272
250
|
await waitForSync(provider);
|
|
273
251
|
|
|
274
|
-
provider.awareness
|
|
252
|
+
provider.awareness?.setLocalStateField("user", {
|
|
275
253
|
name: this.displayName,
|
|
276
254
|
color: this.displayColor,
|
|
277
255
|
});
|
|
256
|
+
provider.awareness?.setLocalStateField("status", null);
|
|
278
257
|
|
|
279
258
|
this._rootDoc = doc;
|
|
280
259
|
this._rootProvider = provider;
|
|
281
|
-
this._rootDocId = docId;
|
|
282
|
-
this.log(`Switched to space ${docId}`);
|
|
283
260
|
}
|
|
284
261
|
|
|
285
262
|
/** Graceful shutdown. */
|
|
@@ -290,7 +267,7 @@ export class DocumentManager {
|
|
|
290
267
|
this.childCache.clear();
|
|
291
268
|
|
|
292
269
|
if (this._rootProvider) {
|
|
293
|
-
this._rootProvider.awareness
|
|
270
|
+
this._rootProvider.awareness?.setLocalStateField(
|
|
294
271
|
"status",
|
|
295
272
|
null,
|
|
296
273
|
);
|
|
@@ -319,7 +296,7 @@ export class DocumentManager {
|
|
|
319
296
|
const childProvider = await this._rootProvider.loadChild(docId);
|
|
320
297
|
await waitForSync(childProvider);
|
|
321
298
|
|
|
322
|
-
childProvider.awareness
|
|
299
|
+
childProvider.awareness?.setLocalStateField("user", {
|
|
323
300
|
name: this.displayName,
|
|
324
301
|
color: this.displayColor,
|
|
325
302
|
});
|