@jtfmumm/patchwork-standalone-frame 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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>;
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.2.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
  }