@jtfmumm/patchwork-standalone-frame 0.1.0 → 0.3.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/mount.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- import type { ToolRegistration } from "./index.ts";
2
- export declare function mountStandaloneApp<D>(rootElement: HTMLElement, tool: ToolRegistration<D>): void;
1
+ import type { ToolRegistration, StandaloneFrameConfig, Plugin } from "./index.ts";
2
+ export declare function mountStandaloneApp<D>(rootElement: HTMLElement, toolOrPlugins: ToolRegistration<D> | Plugin<D>[], config?: StandaloneFrameConfig): Promise<void>;
@@ -1,5 +1,5 @@
1
1
  import type { AutomergeUrl } from "@automerge/automerge-repo";
2
- import { type AutomergeRepoKeyhive } from "@automerge/automerge-repo-keyhive";
2
+ import type { AutomergeRepoKeyhive } from "@automerge/automerge-repo-keyhive";
3
3
  interface ShareModalProps {
4
4
  isOpen: boolean;
5
5
  docUrl: AutomergeUrl;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jtfmumm/patchwork-standalone-frame",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "Reusable standalone frame for patchwork tools with keyhive, doc history, and access control",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -22,6 +22,11 @@
22
22
  "@automerge/automerge-repo-storage-indexeddb": "^2.5.2-alpha.0",
23
23
  "solid-js": "^1.9.9"
24
24
  },
25
+ "peerDependenciesMeta": {
26
+ "@automerge/automerge-repo-keyhive": {
27
+ "optional": true
28
+ }
29
+ },
25
30
  "devDependencies": {
26
31
  "@automerge/automerge-repo": "^2.5.2-alpha.0",
27
32
  "@automerge/automerge-repo-keyhive": "0.1.0-alpha.17za",
package/src/frame.tsx CHANGED
@@ -1,20 +1,7 @@
1
1
  import { createSignal, Show, For, onMount, onCleanup } from "solid-js";
2
- import {
3
- Repo,
4
- type AutomergeUrl,
5
- type DocHandle,
6
- } from "@automerge/automerge-repo";
7
- import { IndexedDBStorageAdapter } from "@automerge/automerge-repo-storage-indexeddb";
8
- import { BrowserWebSocketClientAdapter } from "@automerge/automerge-repo-network-websocket";
9
- import {
10
- initializeAutomergeRepoKeyhive,
11
- initKeyhiveWasm,
12
- docIdFromAutomergeUrl,
13
- Identifier,
14
- uint8ArrayToHex,
15
- type AutomergeRepoKeyhive,
16
- } from "@automerge/automerge-repo-keyhive";
17
- import type { ToolRegistration, ToolElement } from "./index.ts";
2
+ import type { AutomergeUrl, DocHandle } from "@automerge/automerge-repo";
3
+ import type { AutomergeRepoKeyhive } from "@automerge/automerge-repo-keyhive";
4
+ import type { ToolRegistration, ToolElement, StandaloneFrameConfig, FrameRepo, FrameDocHandle } from "./index.ts";
18
5
  import {
19
6
  type DocHistoryEntry,
20
7
  loadHistory,
@@ -28,19 +15,16 @@ import { ShareModal } from "./share-modal.tsx";
28
15
  import { ConfirmModal } from "./confirm-modal.tsx";
29
16
  import { NewDocModal } from "./new-doc-modal.tsx";
30
17
 
31
- // Init keyhive WASM at module level (must happen before initializeAutomergeRepoKeyhive)
32
- initKeyhiveWasm();
33
-
34
18
  declare global {
35
19
  interface Window {
36
20
  hive?: AutomergeRepoKeyhive;
37
21
  }
38
22
  }
39
23
 
40
- export function StandaloneApp<D>(props: { tool: ToolRegistration<D> }) {
24
+ export function StandaloneApp<D>(props: { tool: ToolRegistration<D>; config?: StandaloneFrameConfig }) {
41
25
  const tool = props.tool;
42
26
 
43
- const [handle, setHandle] = createSignal<DocHandle<D> | null>(null);
27
+ const [handle, setHandle] = createSignal<FrameDocHandle<D> | null>(null);
44
28
  const [docTitle, setDocTitle] = createSignal<string>("...");
45
29
  const [history, setHistory] = createSignal<DocHistoryEntry[]>([]);
46
30
  const [dropdownOpen, setDropdownOpen] = createSignal(false);
@@ -56,11 +40,11 @@ export function StandaloneApp<D>(props: { tool: ToolRegistration<D> }) {
56
40
  const [pendingDocUrl, setPendingDocUrl] = createSignal<string | null>(null);
57
41
  const [loading, setLoading] = createSignal(false);
58
42
 
59
- let repo!: Repo;
43
+ let repo!: FrameRepo;
60
44
  let titleCleanup: (() => void) | null = null;
61
45
  let loadGeneration = 0;
62
46
 
63
- function trackTitle(h: DocHandle<D>): void {
47
+ function trackTitle(h: FrameDocHandle<D>): void {
64
48
  if (titleCleanup) {
65
49
  titleCleanup();
66
50
  titleCleanup = null;
@@ -87,6 +71,7 @@ export function StandaloneApp<D>(props: { tool: ToolRegistration<D> }) {
87
71
  const h = hive();
88
72
  if (!h) return true;
89
73
  try {
74
+ const { docIdFromAutomergeUrl, Identifier } = await import("@automerge/automerge-repo-keyhive");
90
75
  const docId = docIdFromAutomergeUrl(url as AutomergeUrl);
91
76
  const myId = h.active.individual.id;
92
77
  const publicId = Identifier.publicId();
@@ -124,23 +109,23 @@ export function StandaloneApp<D>(props: { tool: ToolRegistration<D> }) {
124
109
  }
125
110
  if (gen !== loadGeneration) return;
126
111
 
127
- const isDocReady = (h: DocHandle<D>) => {
112
+ const isDocReady = (h: FrameDocHandle<D>) => {
128
113
  const d = h.doc() as D;
129
114
  if (!d) return false;
130
115
  return tool.isDocReady ? tool.isDocReady(d) : !!d;
131
116
  };
132
117
 
133
- let docHandle = await repo.find<D>(url as AutomergeUrl);
118
+ let docHandle = repo.find<D>(url);
134
119
  await docHandle.whenReady();
135
120
  if (gen !== loadGeneration) return;
136
121
 
137
122
  if (!isDocReady(docHandle)) {
138
123
  console.log(`[${tool.name}] Doc incomplete, forcing re-sync for: ${url.slice(0, 30)}...`);
139
- try { repo.delete(url as AutomergeUrl); } catch { /* ignore */ }
124
+ try { repo.delete(url); } catch { /* ignore */ }
140
125
  await new Promise((r) => setTimeout(r, 100));
141
126
  if (gen !== loadGeneration) return;
142
127
 
143
- docHandle = await repo.find<D>(url as AutomergeUrl);
128
+ docHandle = repo.find<D>(url);
144
129
  await docHandle.whenReady();
145
130
  if (gen !== loadGeneration) return;
146
131
  }
@@ -177,21 +162,25 @@ export function StandaloneApp<D>(props: { tool: ToolRegistration<D> }) {
177
162
  }
178
163
 
179
164
  async function createNewDoc(title: string = tool.defaultTitle): Promise<void> {
180
- const h = hive();
181
- if (!h) return;
182
-
183
165
  const initialDoc = {} as Record<string, unknown>;
184
- tool.init(initialDoc as D, repo);
166
+ tool.init(initialDoc as D, repo as unknown as Parameters<typeof tool.init>[1]);
185
167
  if (tool.setTitle) tool.setTitle(initialDoc as D, title);
186
168
  initialDoc["@patchwork"] = { type: tool.id };
187
- const docHandle = await repo.create2<D>(initialDoc as D);
188
- console.log(`[${tool.name}] Created document: ${docHandle.url}`);
189
169
 
190
- await h.addSyncServerPullToDoc(docHandle.url);
191
- await h.keyhiveStorage.saveKeyhiveWithHash(h.keyhive);
170
+ const h = hive();
171
+ let docHandle: FrameDocHandle<D>;
172
+ if (h) {
173
+ docHandle = await (repo as unknown as { create2<T>(v: T): Promise<FrameDocHandle<T>> }).create2<D>(initialDoc as D);
174
+ console.log(`[${tool.name}] Created document (keyhive): ${docHandle.url}`);
175
+ await h.addSyncServerPullToDoc(docHandle.url as AutomergeUrl);
176
+ await h.keyhiveStorage.saveKeyhiveWithHash(h.keyhive);
177
+ } else {
178
+ docHandle = repo.create<D>(initialDoc as D);
179
+ console.log(`[${tool.name}] Created document (legacy): ${docHandle.url}`);
180
+ }
192
181
 
193
182
  setHandle(docHandle);
194
- setDocUrlInHash(docHandle.url);
183
+ setDocUrlInHash(docHandle.url as AutomergeUrl);
195
184
  localStorage.setItem(activeDocKey(tool.id, identityHexId()), docHandle.url);
196
185
  trackTitle(docHandle);
197
186
  }
@@ -232,67 +221,102 @@ export function StandaloneApp<D>(props: { tool: ToolRegistration<D> }) {
232
221
  }
233
222
 
234
223
  onMount(async () => {
235
- const keyhiveStorage = new IndexedDBStorageAdapter(`${tool.id}-keyhive`);
236
- const envSyncUrl = (import.meta as unknown as Record<string, Record<string, string>>).env?.VITE_SYNC_URL;
237
- const networkAdapter = new BrowserWebSocketClientAdapter(
238
- envSyncUrl || tool.syncUrl || "ws://localhost:3030"
239
- );
240
- const peerIdSuffix = `${tool.id}-${Math.random().toString(36).slice(2)}`;
241
-
242
- const automergeRepoKeyhive = await initializeAutomergeRepoKeyhive({
243
- storage: keyhiveStorage,
244
- peerIdSuffix,
245
- networkAdapter,
246
- automaticArchiveIngestion: true,
247
- onlyShareWithHardcodedServerPeerId: true,
248
- cacheHashes: true,
249
- });
250
- window.hive = automergeRepoKeyhive;
251
-
252
- repo = new Repo({
253
- storage: new IndexedDBStorageAdapter(),
254
- network: [automergeRepoKeyhive.networkAdapter],
255
- peerId: automergeRepoKeyhive.peerId,
256
- sharePolicy: async (peerId) => {
257
- return peerId === automergeRepoKeyhive.syncServer?.peerId;
258
- },
259
- idFactory: automergeRepoKeyhive.idFactory,
260
- });
261
-
262
- automergeRepoKeyhive.linkRepo(repo);
263
- setHive(automergeRepoKeyhive);
264
-
265
- const hexId = uint8ArrayToHex(
266
- automergeRepoKeyhive.active.individual.id.toBytes()
267
- );
268
- setIdentityHexId(hexId);
269
- setHistory(loadHistory(tool.id, hexId));
270
- setReady(true);
271
-
272
- console.log(`[${tool.name}] Keyhive initialized, identity: 0x${hexId.slice(0, 12)}...`);
273
-
274
- (automergeRepoKeyhive.networkAdapter as { on: (event: string, cb: () => void) => void }).on("ingest-remote", async () => {
275
- const pending = pendingDocUrl();
276
- if (pending) {
277
- if (await checkAccess(pending)) {
278
- console.log(`[${tool.name}] Access granted, loading: ${pending}`);
279
- await loadDoc(pending);
280
- }
281
- return;
224
+ if (props.config?.legacyMode) {
225
+ // Legacy path: use the passed-in repo, no automerge-repo or keyhive imports
226
+ repo = props.config.repo!;
227
+
228
+ // Generate a stable identity hex ID for doc history via localStorage
229
+ const storageKey = `standalone-frame-identity-${tool.id}`;
230
+ let hexId = localStorage.getItem(storageKey);
231
+ if (!hexId) {
232
+ const bytes = new Uint8Array(16);
233
+ crypto.getRandomValues(bytes);
234
+ hexId = Array.from(bytes).map(b => b.toString(16).padStart(2, "0")).join("");
235
+ localStorage.setItem(storageKey, hexId);
282
236
  }
237
+ setIdentityHexId(hexId);
238
+ setHistory(loadHistory(tool.id, hexId));
239
+ setReady(true);
240
+
241
+ console.log(`[${tool.name}] Legacy mode initialized, identity: 0x${hexId.slice(0, 12)}...`);
242
+ } else {
243
+ // Keyhive path (default): dynamic import everything
244
+ const [
245
+ { Repo },
246
+ { IndexedDBStorageAdapter },
247
+ { BrowserWebSocketClientAdapter },
248
+ keyhive,
249
+ ] = await Promise.all([
250
+ import("@automerge/automerge-repo"),
251
+ import("@automerge/automerge-repo-storage-indexeddb"),
252
+ import("@automerge/automerge-repo-network-websocket"),
253
+ import("@automerge/automerge-repo-keyhive"),
254
+ ]);
255
+ keyhive.initKeyhiveWasm();
256
+
257
+ const keyhiveStorage = new IndexedDBStorageAdapter(`${tool.id}-keyhive`);
258
+ const envSyncUrl = (import.meta as unknown as Record<string, Record<string, string>>).env?.VITE_SYNC_URL;
259
+ const networkAdapter = new BrowserWebSocketClientAdapter(
260
+ envSyncUrl || tool.syncUrl || "ws://localhost:3030"
261
+ );
262
+ const peerIdSuffix = `${tool.id}-${Math.random().toString(36).slice(2)}`;
263
+
264
+ const automergeRepoKeyhive = await keyhive.initializeAutomergeRepoKeyhive({
265
+ storage: keyhiveStorage,
266
+ peerIdSuffix,
267
+ networkAdapter,
268
+ automaticArchiveIngestion: true,
269
+ onlyShareWithHardcodedServerPeerId: true,
270
+ cacheHashes: true,
271
+ });
272
+ window.hive = automergeRepoKeyhive;
273
+
274
+ const realRepo = new Repo({
275
+ storage: new IndexedDBStorageAdapter(),
276
+ network: [automergeRepoKeyhive.networkAdapter],
277
+ peerId: automergeRepoKeyhive.peerId,
278
+ sharePolicy: async (peerId) => {
279
+ return peerId === automergeRepoKeyhive.syncServer?.peerId;
280
+ },
281
+ idFactory: automergeRepoKeyhive.idFactory,
282
+ });
283
+ repo = realRepo as unknown as FrameRepo;
284
+
285
+ automergeRepoKeyhive.linkRepo(realRepo);
286
+ setHive(automergeRepoKeyhive);
287
+
288
+ const hexId = keyhive.uint8ArrayToHex(
289
+ automergeRepoKeyhive.active.individual.id.toBytes()
290
+ );
291
+ setIdentityHexId(hexId);
292
+ setHistory(loadHistory(tool.id, hexId));
293
+ setReady(true);
294
+
295
+ console.log(`[${tool.name}] Keyhive initialized, identity: 0x${hexId.slice(0, 12)}...`);
296
+
297
+ (automergeRepoKeyhive.networkAdapter as { on: (event: string, cb: () => void) => void }).on("ingest-remote", async () => {
298
+ const pending = pendingDocUrl();
299
+ if (pending) {
300
+ if (await checkAccess(pending)) {
301
+ console.log(`[${tool.name}] Access granted, loading: ${pending}`);
302
+ await loadDoc(pending);
303
+ }
304
+ return;
305
+ }
283
306
 
284
- const h = handle();
285
- if (h) {
286
- if (!(await checkAccess(h.url))) {
287
- console.log(`[${tool.name}] Access revoked for: ${h.url}`);
288
- if (titleCleanup) { titleCleanup(); titleCleanup = null; }
289
- setHandle(null);
290
- setDocTitle("...");
291
- setDocUnavailable(true);
292
- setPendingDocUrl(h.url);
307
+ const h = handle();
308
+ if (h) {
309
+ if (!(await checkAccess(h.url))) {
310
+ console.log(`[${tool.name}] Access revoked for: ${h.url}`);
311
+ if (titleCleanup) { titleCleanup(); titleCleanup = null; }
312
+ setHandle(null);
313
+ setDocTitle("...");
314
+ setDocUnavailable(true);
315
+ setPendingDocUrl(h.url);
316
+ }
293
317
  }
294
- }
295
- });
318
+ });
319
+ }
296
320
 
297
321
  const hashUrl = getDocUrlFromHash();
298
322
  if (hashUrl) {
@@ -301,7 +325,7 @@ export function StandaloneApp<D>(props: { tool: ToolRegistration<D> }) {
301
325
  return;
302
326
  }
303
327
 
304
- const existingUrl = localStorage.getItem(activeDocKey(tool.id, hexId));
328
+ const existingUrl = localStorage.getItem(activeDocKey(tool.id, identityHexId()));
305
329
  if (existingUrl) {
306
330
  console.log(`[${tool.name}] Found existing doc: ${existingUrl}`);
307
331
  await loadDoc(existingUrl);
@@ -604,18 +628,20 @@ export function StandaloneApp<D>(props: { tool: ToolRegistration<D> }) {
604
628
  ? "Loading document..."
605
629
  : ready()
606
630
  ? "No document open"
607
- : "Initializing keyhive..."}
631
+ : props.config?.legacyMode
632
+ ? "Initializing..."
633
+ : "Initializing keyhive..."}
608
634
  </div>
609
635
  }
610
636
  >
611
637
  {(h) => {
612
638
  const toolEl = document.createElement("div") as unknown as ToolElement;
613
- toolEl.repo = repo;
639
+ toolEl.repo = repo as unknown as ToolElement["repo"];
614
640
  toolEl.style.height = "100%";
615
641
  return (
616
642
  <div style={{ height: "100%" }} ref={(el) => {
617
643
  el.appendChild(toolEl);
618
- tool.render(h as DocHandle<D>, toolEl);
644
+ tool.render(h as unknown as DocHandle<D>, toolEl);
619
645
  }} />
620
646
  );
621
647
  }}
@@ -626,7 +652,7 @@ export function StandaloneApp<D>(props: { tool: ToolRegistration<D> }) {
626
652
  <Show when={hive() && handle()}>
627
653
  <ShareModal
628
654
  isOpen={shareModalOpen()}
629
- docUrl={handle()!.url}
655
+ docUrl={handle()!.url as AutomergeUrl}
630
656
  hive={hive()!}
631
657
  onClose={() => setShareModalOpen(false)}
632
658
  />
package/src/index.ts CHANGED
@@ -18,6 +18,53 @@ export interface ToolRegistration<D = unknown> {
18
18
  render: (handle: DocHandle<D>, element: ToolElement) => (() => void);
19
19
  }
20
20
 
21
+ /** Duck-typed doc handle — structurally compatible with automerge-repo DocHandle */
22
+ export interface FrameDocHandle<D = unknown> {
23
+ url: string;
24
+ doc(): D | undefined;
25
+ whenReady(): Promise<unknown>;
26
+ on(event: string, cb: (...args: unknown[]) => void): void;
27
+ off(event: string, cb: (...args: unknown[]) => void): void;
28
+ }
29
+
30
+ /** Duck-typed repo — structurally compatible with automerge-repo Repo */
31
+ export interface FrameRepo {
32
+ find<D = unknown>(url: string): FrameDocHandle<D>;
33
+ create<D = unknown>(initialValue: D): FrameDocHandle<D>;
34
+ delete(url: string): void;
35
+ }
36
+
37
+ export interface StandaloneFrameConfig {
38
+ /** When true, use plain automerge docs without keyhive. Default: false */
39
+ legacyMode?: boolean;
40
+ /** Pre-built repo for legacy mode. Required when legacyMode is true. */
41
+ repo?: FrameRepo;
42
+ }
43
+
44
+ export interface PluginDescription {
45
+ id: string;
46
+ type: string;
47
+ name: string;
48
+ icon?: string;
49
+ }
50
+
51
+ export interface ToolPlugin<D = unknown> extends PluginDescription {
52
+ type: "patchwork:tool";
53
+ supportedDatatypes: "*" | string[];
54
+ load: () => Promise<(handle: DocHandle<D>, element: ToolElement) => (() => void)>;
55
+ }
56
+
57
+ export interface DatatypePlugin<D = unknown> extends PluginDescription {
58
+ type: "patchwork:datatype";
59
+ load: () => Promise<{
60
+ init(doc: D, repo: Repo): void;
61
+ getTitle(doc: D): string;
62
+ setTitle?(doc: D, title: string): void;
63
+ }>;
64
+ }
65
+
66
+ export type Plugin<D = unknown> = ToolPlugin<D> | DatatypePlugin<D>;
67
+
21
68
  export { mountStandaloneApp } from "./mount.tsx";
22
69
  export { ShareModal } from "./share-modal.tsx";
23
70
  export { ConfirmModal } from "./confirm-modal.tsx";
package/src/mount.tsx CHANGED
@@ -1,10 +1,48 @@
1
+ import type { Repo } from "@automerge/automerge-repo";
1
2
  import { render } from "solid-js/web";
2
3
  import { StandaloneApp } from "./frame.tsx";
3
- import type { ToolRegistration } from "./index.ts";
4
+ import type { ToolRegistration, StandaloneFrameConfig, Plugin, ToolPlugin, DatatypePlugin } from "./index.ts";
4
5
 
5
- export function mountStandaloneApp<D>(
6
+ async function resolvePlugins<D>(plugins: Plugin<D>[]): Promise<ToolRegistration<D>> {
7
+ const toolPlugin = plugins.find((p): p is ToolPlugin<D> => p.type === "patchwork:tool");
8
+ const datatypePlugin = plugins.find((p): p is DatatypePlugin<D> => p.type === "patchwork:datatype");
9
+
10
+ if (!toolPlugin) throw new Error("No patchwork:tool plugin found in plugins array");
11
+ if (!datatypePlugin) throw new Error("No patchwork:datatype plugin found in plugins array");
12
+
13
+ const [renderFn, datatype] = await Promise.all([
14
+ toolPlugin.load(),
15
+ datatypePlugin.load(),
16
+ ]);
17
+
18
+ // Derive defaultTitle by initializing a scratch doc
19
+ const scratch = {} as D;
20
+ datatype.init(scratch, {} as Repo);
21
+ const defaultTitle = datatype.getTitle(scratch);
22
+
23
+ return {
24
+ id: toolPlugin.id,
25
+ name: toolPlugin.name,
26
+ defaultTitle,
27
+ init: datatype.init,
28
+ getTitle: datatype.getTitle,
29
+ setTitle: datatype.setTitle,
30
+ render: renderFn,
31
+ };
32
+ }
33
+
34
+ export async function mountStandaloneApp<D>(
6
35
  rootElement: HTMLElement,
7
- tool: ToolRegistration<D>,
8
- ): void {
9
- render(() => <StandaloneApp tool={tool} />, rootElement);
36
+ toolOrPlugins: ToolRegistration<D> | Plugin<D>[],
37
+ config?: StandaloneFrameConfig,
38
+ ): Promise<void> {
39
+ let tool: ToolRegistration<D>;
40
+
41
+ if (Array.isArray(toolOrPlugins)) {
42
+ tool = await resolvePlugins(toolOrPlugins);
43
+ } else {
44
+ tool = toolOrPlugins;
45
+ }
46
+
47
+ render(() => <StandaloneApp tool={tool} config={config} />, rootElement);
10
48
  }
@@ -7,14 +7,7 @@ import {
7
7
  createMemo,
8
8
  } from "solid-js";
9
9
  import type { AutomergeUrl } from "@automerge/automerge-repo";
10
- import {
11
- Access,
12
- ContactCard,
13
- docIdFromAutomergeUrl,
14
- Identifier,
15
- uint8ArrayToHex,
16
- type AutomergeRepoKeyhive,
17
- } from "@automerge/automerge-repo-keyhive";
10
+ import type { AutomergeRepoKeyhive } from "@automerge/automerge-repo-keyhive";
18
11
  import { overlayStyle, cardStyle } from "./modal-styles.ts";
19
12
 
20
13
  type DocAccessList = Record<string, string>;
@@ -26,17 +19,34 @@ interface ShareModalProps {
26
19
  onClose: () => void;
27
20
  }
28
21
 
22
+ // Lazy-loaded keyhive utilities — resolved once on first use
23
+ let keyhiveModule: {
24
+ docIdFromAutomergeUrl: (url: AutomergeUrl) => unknown;
25
+ uint8ArrayToHex: (bytes: Uint8Array) => string;
26
+ ContactCard: { fromJson: (json: string) => unknown };
27
+ Identifier: { publicId: () => { toBytes: () => Uint8Array } };
28
+ Access: { tryFromString: (s: string) => unknown };
29
+ } | null = null;
30
+
31
+ async function getKeyhive() {
32
+ if (!keyhiveModule) {
33
+ keyhiveModule = await import("@automerge/automerge-repo-keyhive") as unknown as typeof keyhiveModule;
34
+ }
35
+ return keyhiveModule!;
36
+ }
37
+
29
38
  const ACCESS_LEVELS = ["Pull", "Read", "Write", "Admin"] as const;
30
39
 
31
40
  async function fetchAccessList(
32
41
  hive: AutomergeRepoKeyhive,
33
42
  docUrl: AutomergeUrl
34
43
  ): Promise<DocAccessList> {
35
- const keyhiveDocId = docIdFromAutomergeUrl(docUrl);
44
+ const kh = await getKeyhive();
45
+ const keyhiveDocId = kh.docIdFromAutomergeUrl(docUrl);
36
46
  const accessList: DocAccessList = {};
37
- const members = await hive.docMemberCapabilities(keyhiveDocId);
47
+ const members = await hive.docMemberCapabilities(keyhiveDocId as Parameters<typeof hive.docMemberCapabilities>[0]);
38
48
  members.forEach((capability) => {
39
- const hexId = uint8ArrayToHex(capability.who.id.toBytes());
49
+ const hexId = kh.uint8ArrayToHex(capability.who.id.toBytes());
40
50
  accessList[hexId] = capability.can.toString();
41
51
  });
42
52
  return accessList;
@@ -77,41 +87,56 @@ export function ShareModal(props: ShareModalProps) {
77
87
  const [isLoadingAccessList, setIsLoadingAccessList] = createSignal(true);
78
88
  const [currentUserAccess, setCurrentUserAccess] = createSignal<string | undefined>(undefined);
79
89
  const [isSubmitting, setIsSubmitting] = createSignal(false);
80
- const keyhiveDocId = createMemo(() => docIdFromAutomergeUrl(props.docUrl));
90
+ const [keyhiveDocIdVal, setKeyhiveDocIdVal] = createSignal<unknown>(null);
91
+ const [currentUserHexIdVal, setCurrentUserHexIdVal] = createSignal<string | null>(null);
92
+ const [syncServerHexIdVal, setSyncServerHexIdVal] = createSignal<string | null>(null);
93
+ const [publicHexIdVal, setPublicHexIdVal] = createSignal<string>("");
81
94
 
82
- const currentUserHexId = createMemo(() => {
83
- const id = props.hive.active.individual.id;
84
- return id ? uint8ArrayToHex(id.toBytes()) : null;
85
- });
95
+ // Load keyhive and compute derived values when modal opens
96
+ createEffect(() => {
97
+ if (!props.isOpen) return;
98
+ let cancelled = false;
99
+ (async () => {
100
+ const kh = await getKeyhive();
101
+ if (cancelled) return;
86
102
 
87
- const syncServerHexId = createMemo(() => {
88
- const syncServer = props.hive.syncServer;
89
- if (!syncServer) return null;
90
- const contactCard = ContactCard.fromJson(syncServer.contactCard.toJson());
91
- if (!contactCard) return null;
92
- return uint8ArrayToHex(contactCard.individualId.bytes);
93
- });
103
+ setKeyhiveDocIdVal(kh.docIdFromAutomergeUrl(props.docUrl));
94
104
 
95
- const publicHexId = createMemo(() => {
96
- const publicId = Identifier.publicId();
97
- return uint8ArrayToHex(publicId.toBytes());
105
+ const id = props.hive.active.individual.id;
106
+ setCurrentUserHexIdVal(id ? kh.uint8ArrayToHex(id.toBytes()) : null);
107
+
108
+ const syncServer = props.hive.syncServer;
109
+ if (syncServer) {
110
+ const contactCard = kh.ContactCard.fromJson(syncServer.contactCard.toJson());
111
+ if (contactCard) {
112
+ setSyncServerHexIdVal(kh.uint8ArrayToHex((contactCard as { individualId: { bytes: Uint8Array } }).individualId.bytes));
113
+ }
114
+ }
115
+
116
+ const publicId = kh.Identifier.publicId();
117
+ setPublicHexIdVal(kh.uint8ArrayToHex(publicId.toBytes()));
118
+ })();
119
+ onCleanup(() => { cancelled = true; });
98
120
  });
99
121
 
100
122
  const currentPublicAccess = createMemo(() => {
101
123
  const accessList = docAccessList();
102
- return accessList[publicHexId()] || null;
124
+ const pubId = publicHexIdVal();
125
+ return pubId ? (accessList[pubId] || null) : null;
103
126
  });
104
127
 
105
128
  const isAdmin = createMemo(() => currentUserAccess() === "Admin");
106
129
 
107
130
  createEffect(() => {
108
131
  if (!props.isOpen) return;
132
+ const docId = keyhiveDocIdVal();
133
+ if (!docId) return;
109
134
  let cancelled = false;
110
135
  (async () => {
111
136
  const id = props.hive.active.individual.id;
112
137
  if (!id) { if (!cancelled) setCurrentUserAccess(undefined); return; }
113
138
  try {
114
- const access = await props.hive.accessForDoc(id, keyhiveDocId());
139
+ const access = await props.hive.accessForDoc(id, docId as Parameters<typeof props.hive.accessForDoc>[1]);
115
140
  if (!cancelled) setCurrentUserAccess(access ? access.toString() : undefined);
116
141
  } catch (err) {
117
142
  if (!cancelled) { console.error("[ShareModal] Error checking access:", err); setCurrentUserAccess(undefined); }
@@ -148,11 +173,12 @@ export function ShareModal(props: ShareModalProps) {
148
173
  if (!input) return;
149
174
  setIsSubmitting(true);
150
175
  try {
151
- const contactCard = ContactCard.fromJson(input);
176
+ const kh = await getKeyhive();
177
+ const contactCard = kh.ContactCard.fromJson(input);
152
178
  if (!contactCard) throw new Error("Invalid ContactCard JSON");
153
- const access = Access.tryFromString("write");
179
+ const access = kh.Access.tryFromString("write");
154
180
  if (!access) throw new Error("Invalid access level");
155
- await props.hive.addMemberToDoc(props.docUrl, contactCard, access);
181
+ await props.hive.addMemberToDoc(props.docUrl, contactCard as Parameters<typeof props.hive.addMemberToDoc>[1], access as Parameters<typeof props.hive.addMemberToDoc>[2]);
156
182
  setContactCardInput("");
157
183
  } catch (err) {
158
184
  console.error("[ShareModal]", err);
@@ -176,9 +202,10 @@ export function ShareModal(props: ShareModalProps) {
176
202
 
177
203
  const handleMakePublic = async () => {
178
204
  try {
179
- const access = Access.tryFromString("write");
205
+ const kh = await getKeyhive();
206
+ const access = kh.Access.tryFromString("write");
180
207
  if (!access) throw new Error("Invalid access level");
181
- await props.hive.setPublicAccess(props.docUrl, access);
208
+ await props.hive.setPublicAccess(props.docUrl, access as Parameters<typeof props.hive.setPublicAccess>[1]);
182
209
  } catch (err) {
183
210
  console.error("[ShareModal]", err);
184
211
  } finally {
@@ -189,7 +216,7 @@ export function ShareModal(props: ShareModalProps) {
189
216
 
190
217
  const handleMakePrivate = async () => {
191
218
  try {
192
- await props.hive.revokeMemberFromDoc(props.docUrl, publicHexId());
219
+ await props.hive.revokeMemberFromDoc(props.docUrl, publicHexIdVal());
193
220
  } catch (err) {
194
221
  console.error("[ShareModal]", err);
195
222
  } finally {
@@ -294,9 +321,9 @@ export function ShareModal(props: ShareModalProps) {
294
321
  <div>
295
322
  <For each={sortedMembers()}>
296
323
  {([hexId, access]) => {
297
- const isCurrentUser = hexId === currentUserHexId();
298
- const isSyncServer = hexId === syncServerHexId();
299
- const isPublic = hexId === publicHexId();
324
+ const isCurrentUser = hexId === currentUserHexIdVal();
325
+ const isSyncServer = hexId === syncServerHexIdVal();
326
+ const isPublic = hexId === publicHexIdVal();
300
327
  const myAccessIdx = ACCESS_LEVELS.indexOf(currentUserAccess() as typeof ACCESS_LEVELS[number]);
301
328
  const memberAccessIdx = ACCESS_LEVELS.indexOf(access as typeof ACCESS_LEVELS[number]);
302
329
  const canRemove = myAccessIdx >= 0 && memberAccessIdx >= 0 && memberAccessIdx <= myAccessIdx && !isCurrentUser && !isSyncServer;