@freqhole/api-client 0.1.31 → 0.2.1

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.
@@ -1,14 +0,0 @@
1
- // automerge transport layer - iroh QUIC network adapter for automerge-repo.
2
- //
3
- // re-exports everything needed by consuming packages.
4
- export {
5
- IrohNetworkAdapter,
6
- SYNC_ALPN,
7
- } from "./IrohNetworkAdapter.js";
8
-
9
- export type {
10
- BiStreamLike,
11
- MiddenStreamNode,
12
- ConnectionSummary,
13
- IrohNetworkAdapterOptions,
14
- } from "./IrohNetworkAdapter.js";
@@ -1,272 +0,0 @@
1
- // local blob storage for images and media
2
- // metadata in IndexedDB, actual blob data in OPFS or Cache API
3
-
4
- import { openDB, type IDBPDatabase } from "idb";
5
-
6
- // module-local logger - no-op by default; wire up via setStorageLogger
7
- let logger: (scope: string, ...args: unknown[]) => void = () => {};
8
-
9
- /**
10
- * set the logger function used by blob storage operations.
11
- * call this at app startup to opt into logging.
12
- */
13
- export function setStorageLogger(
14
- fn: (scope: string, ...args: unknown[]) => void,
15
- ): void {
16
- logger = fn;
17
- }
18
-
19
- export const BLOB_DB_NAME = "freqhole_blobs";
20
- const BLOB_DB_VERSION = 1;
21
- const STORE_BLOBS = "blobs";
22
- const CACHE_NAME = "freqhole-blobs";
23
-
24
- // global blob URL cache - persists for session, cleared on page reload
25
- const BLOB_URL_CACHE = new Map<string, string>();
26
-
27
- export type BlobStorageType = "opfs" | "cache";
28
-
29
- export interface BlobRecord {
30
- blob_id: string; // sha256 hash of content (primary key)
31
- storage_type: BlobStorageType;
32
- storage_path: string; // opfs path or cache key
33
- mime_type: string;
34
- file_size: number;
35
- created_at: number;
36
- }
37
-
38
- let blobDbInstance: IDBPDatabase | null = null;
39
-
40
- async function initBlobDB(): Promise<IDBPDatabase> {
41
- if (blobDbInstance) return blobDbInstance;
42
-
43
- blobDbInstance = await openDB(BLOB_DB_NAME, BLOB_DB_VERSION, {
44
- upgrade(db) {
45
- if (!db.objectStoreNames.contains(STORE_BLOBS)) {
46
- db.createObjectStore(STORE_BLOBS, { keyPath: "blob_id" });
47
- }
48
- },
49
- });
50
-
51
- return blobDbInstance;
52
- }
53
-
54
- // close database connection (required before deletion)
55
- export function closeBlobDB(): void {
56
- if (blobDbInstance) {
57
- blobDbInstance.close();
58
- blobDbInstance = null;
59
- logger("blobs", "blob database connection closed");
60
- }
61
- }
62
-
63
- // check if OPFS is available
64
- async function isOPFSAvailable(): Promise<boolean> {
65
- try {
66
- if (!navigator.storage?.getDirectory) return false;
67
- await navigator.storage.getDirectory();
68
- return true;
69
- } catch {
70
- return false;
71
- }
72
- }
73
-
74
- // write blob to OPFS
75
- async function writeBlobToOPFS(blobId: string, data: Blob): Promise<string> {
76
- const root = await navigator.storage.getDirectory();
77
- const blobsDir = await root.getDirectoryHandle("blobs", { create: true });
78
- const fileHandle = await blobsDir.getFileHandle(blobId, { create: true });
79
- const writable = await fileHandle.createWritable();
80
- await writable.write(data);
81
- await writable.close();
82
- return `/blobs/${blobId}`;
83
- }
84
-
85
- // read blob from OPFS
86
- async function readBlobFromOPFS(storagePath: string): Promise<Blob | null> {
87
- try {
88
- const root = await navigator.storage.getDirectory();
89
- const blobsDir = await root.getDirectoryHandle("blobs");
90
- const fileName = storagePath.split("/").pop()!;
91
- const fileHandle = await blobsDir.getFileHandle(fileName);
92
- const file = await fileHandle.getFile();
93
- return file;
94
- } catch {
95
- return null;
96
- }
97
- }
98
-
99
- // write blob to Cache API
100
- async function writeBlobToCache(
101
- blobId: string,
102
- data: Blob,
103
- mimeType: string,
104
- ): Promise<string> {
105
- const cache = await caches.open(CACHE_NAME);
106
- // use synthetic HTTP URL - webkitgtk (linux) rejects non-HTTP URLs in Cache API
107
- const url = `https://blob.local/${blobId}`;
108
- const response = new Response(data, {
109
- headers: { "Content-Type": mimeType },
110
- });
111
- await cache.put(url, response);
112
- return url;
113
- }
114
-
115
- // read blob from Cache API
116
- async function readBlobFromCache(storagePath: string): Promise<Blob | null> {
117
- try {
118
- const cache = await caches.open(CACHE_NAME);
119
- const response = await cache.match(storagePath);
120
- if (!response) return null;
121
- return await response.blob();
122
- } catch {
123
- return null;
124
- }
125
- }
126
-
127
- /**
128
- * store a blob in local storage and return its ID (sha256 hash).
129
- * automatically chooses OPFS or Cache API based on availability.
130
- */
131
- export async function storeBlob(
132
- data: Blob,
133
- mimeType: string,
134
- ): Promise<string> {
135
- const db = await initBlobDB();
136
-
137
- // compute sha256 hash of blob data
138
- const arrayBuffer = await data.arrayBuffer();
139
- const hashBuffer = await crypto.subtle.digest("SHA-256", arrayBuffer);
140
- const hashArray = Array.from(new Uint8Array(hashBuffer));
141
- const blobId = hashArray.map((b) => b.toString(16).padStart(2, "0")).join("");
142
-
143
- // check if already exists
144
- const existing = await db.get(STORE_BLOBS, blobId);
145
- if (existing) {
146
- return blobId;
147
- }
148
-
149
- // determine storage type and write blob data
150
- const useOPFS = await isOPFSAvailable();
151
- const storageType: BlobStorageType = useOPFS ? "opfs" : "cache";
152
- const storagePath = useOPFS
153
- ? await writeBlobToOPFS(blobId, data)
154
- : await writeBlobToCache(blobId, data, mimeType);
155
-
156
- // store metadata
157
- const record: BlobRecord = {
158
- blob_id: blobId,
159
- storage_type: storageType,
160
- storage_path: storagePath,
161
- mime_type: mimeType,
162
- file_size: data.size,
163
- created_at: Date.now(),
164
- };
165
-
166
- await db.put(STORE_BLOBS, record);
167
- return blobId;
168
- }
169
-
170
- /**
171
- * get blob metadata by ID
172
- */
173
- export async function getBlobMetadata(
174
- blobId: string,
175
- ): Promise<BlobRecord | null> {
176
- const db = await initBlobDB();
177
- const record = await db.get(STORE_BLOBS, blobId);
178
- return record || null;
179
- }
180
-
181
- /**
182
- * get blob data by ID
183
- */
184
- export async function getBlob(blobId: string): Promise<Blob | null> {
185
- const metadata = await getBlobMetadata(blobId);
186
- if (!metadata) return null;
187
-
188
- if (metadata.storage_type === "opfs") {
189
- return await readBlobFromOPFS(metadata.storage_path);
190
- } else {
191
- return await readBlobFromCache(metadata.storage_path);
192
- }
193
- }
194
-
195
- /**
196
- * get blob object URL by ID, cached to avoid repeated OPFS reads and URL creations.
197
- * returns an object URL string usable directly in an img src.
198
- * URLs are cached for the session and revoked on page unload.
199
- */
200
- export async function getBlobObjectURL(
201
- blobId: string,
202
- ): Promise<string | null> {
203
- if (!blobId) return null;
204
-
205
- // check cache first
206
- const cachedUrl = BLOB_URL_CACHE.get(blobId);
207
- if (cachedUrl) return cachedUrl;
208
-
209
- // fetch blob and create object URL
210
- const blob = await getBlob(blobId);
211
- if (!blob) return null;
212
-
213
- const objectUrl = URL.createObjectURL(blob);
214
- BLOB_URL_CACHE.set(blobId, objectUrl);
215
-
216
- return objectUrl;
217
- }
218
-
219
- /**
220
- * synchronous blob URL cache lookup (no OPFS read).
221
- * returns cached object URL if available, null otherwise.
222
- */
223
- export function getCachedBlobObjectURL(blobId: string): string | null {
224
- if (!blobId) return null;
225
- return BLOB_URL_CACHE.get(blobId) ?? null;
226
- }
227
-
228
- /**
229
- * clear all cached blob URLs and revoke object URLs
230
- */
231
- export function clearBlobUrlCache(): void {
232
- BLOB_URL_CACHE.forEach((url) => URL.revokeObjectURL(url));
233
- BLOB_URL_CACHE.clear();
234
- }
235
-
236
- // revoke object URLs on page unload to avoid memory leaks
237
- if (typeof window !== "undefined") {
238
- window.addEventListener("beforeunload", () => {
239
- clearBlobUrlCache();
240
- });
241
- }
242
-
243
- /**
244
- * delete blob by ID from both storage and metadata db
245
- */
246
- export async function deleteBlob(blobId: string): Promise<void> {
247
- const metadata = await getBlobMetadata(blobId);
248
- if (!metadata) return;
249
-
250
- // delete from storage
251
- if (metadata.storage_type === "opfs") {
252
- try {
253
- const root = await navigator.storage.getDirectory();
254
- const blobsDir = await root.getDirectoryHandle("blobs");
255
- const fileName = metadata.storage_path.split("/").pop()!;
256
- await blobsDir.removeEntry(fileName);
257
- } catch {
258
- // ignore errors during cleanup
259
- }
260
- } else {
261
- try {
262
- const cache = await caches.open(CACHE_NAME);
263
- await cache.delete(metadata.storage_path);
264
- } catch {
265
- // ignore errors during cleanup
266
- }
267
- }
268
-
269
- // delete metadata
270
- const db = await initBlobDB();
271
- await db.delete(STORE_BLOBS, blobId);
272
- }
@@ -1,190 +0,0 @@
1
- // p2p identity resolution with read-only access to spume's freqhole_app IndexedDB.
2
- //
3
- // policy:
4
- // - never create or upgrade the "freqhole_app" database
5
- // - if freqhole_app exists and has an "app_state" store, use it as the primary store
6
- // - otherwise, use the injected IdentityStore (local, per-app)
7
- // - when both have an identity, prefer spume's db
8
-
9
- const FREQHOLE_APP_DB = "freqhole_app";
10
- const APP_STATE_STORE = "app_state";
11
- const IDENTITY_KEY = "p2p_identity";
12
-
13
- // snake_case fields are a wire/storage compatibility contract with spume
14
- export interface P2PIdentity {
15
- id: "p2p_identity";
16
- secret_key: Uint8Array;
17
- node_id: string;
18
- created_at: number;
19
- }
20
-
21
- /** per-app fallback store - apps supply their own settings persistence */
22
- export interface IdentityStore {
23
- get(): Promise<P2PIdentity | null>;
24
- set(identity: P2PIdentity): Promise<void>;
25
- }
26
-
27
- /**
28
- * check whether freqhole_app exists without creating it.
29
- *
30
- * strategy:
31
- * 1. use indexedDB.databases() when available (Chrome/Firefox 126+)
32
- * 2. fall back to a versionless open; abort the versionchange transaction
33
- * if upgrade fires (meaning the db did not exist), then close and delete.
34
- */
35
- async function freqholeAppExists(): Promise<boolean> {
36
- // prefer the non-destructive databases() API
37
- if (typeof indexedDB.databases === "function") {
38
- try {
39
- const dbs = await indexedDB.databases();
40
- return dbs.some((d) => d.name === FREQHOLE_APP_DB);
41
- } catch {
42
- // fall through to open-based detection
43
- }
44
- }
45
-
46
- // open-based detection: if upgradeneeded fires, the db did not exist -
47
- // abort immediately so IDB never persists it
48
- return new Promise<boolean>((resolve) => {
49
- const req = indexedDB.open(FREQHOLE_APP_DB);
50
- let existed = true;
51
-
52
- req.onupgradeneeded = (event) => {
53
- // upgradeneeded on a version-0 open means the db is being created now
54
- existed = false;
55
- // abort the versionchange transaction to prevent creation
56
- (event.target as IDBOpenDBRequest).transaction?.abort();
57
- };
58
-
59
- req.onsuccess = () => {
60
- req.result.close();
61
- if (!existed) {
62
- // clean up the empty database that was created before we could abort
63
- indexedDB.deleteDatabase(FREQHOLE_APP_DB);
64
- }
65
- resolve(existed);
66
- };
67
-
68
- req.onerror = () => {
69
- // the abort above causes an error - if existed is false this is expected
70
- resolve(existed);
71
- };
72
- });
73
- }
74
-
75
- /**
76
- * check whether freqhole_app has the app_state object store.
77
- * caller must have already confirmed the db exists.
78
- */
79
- async function hasAppStateStore(): Promise<boolean> {
80
- return new Promise<boolean>((resolve) => {
81
- const req = indexedDB.open(FREQHOLE_APP_DB);
82
-
83
- req.onupgradeneeded = () => {
84
- // should not fire here since we know the db exists - if it does, abort
85
- req.transaction?.abort();
86
- resolve(false);
87
- };
88
-
89
- req.onsuccess = () => {
90
- const db = req.result;
91
- const has = db.objectStoreNames.contains(APP_STATE_STORE);
92
- db.close();
93
- resolve(has);
94
- };
95
-
96
- req.onerror = () => resolve(false);
97
- });
98
- }
99
-
100
- /** read p2p_identity from spume's app_state store */
101
- function readFromFreqholeApp(): Promise<P2PIdentity | null> {
102
- return new Promise<P2PIdentity | null>((resolve) => {
103
- const req = indexedDB.open(FREQHOLE_APP_DB);
104
-
105
- req.onupgradeneeded = () => {
106
- req.transaction?.abort();
107
- resolve(null);
108
- };
109
-
110
- req.onsuccess = () => {
111
- const db = req.result;
112
- const tx = db.transaction(APP_STATE_STORE, "readonly");
113
- const getReq = tx.objectStore(APP_STATE_STORE).get(IDENTITY_KEY);
114
-
115
- getReq.onsuccess = () => {
116
- db.close();
117
- resolve((getReq.result as P2PIdentity) ?? null);
118
- };
119
- getReq.onerror = () => {
120
- db.close();
121
- resolve(null);
122
- };
123
- };
124
-
125
- req.onerror = () => resolve(null);
126
- });
127
- }
128
-
129
- /** write p2p_identity to spume's app_state store (no version bump) */
130
- function writeToFreqholeApp(identity: P2PIdentity): Promise<void> {
131
- return new Promise<void>((resolve, reject) => {
132
- const req = indexedDB.open(FREQHOLE_APP_DB);
133
-
134
- req.onupgradeneeded = () => {
135
- req.transaction?.abort();
136
- reject(new Error("freqhole_app unexpectedly absent during write"));
137
- };
138
-
139
- req.onsuccess = () => {
140
- const db = req.result;
141
- const tx = db.transaction(APP_STATE_STORE, "readwrite");
142
- const putReq = tx.objectStore(APP_STATE_STORE).put(identity, IDENTITY_KEY);
143
-
144
- putReq.onsuccess = () => {
145
- db.close();
146
- resolve();
147
- };
148
- putReq.onerror = () => {
149
- db.close();
150
- reject(putReq.error);
151
- };
152
- };
153
-
154
- req.onerror = () => reject(req.error);
155
- });
156
- }
157
-
158
- /**
159
- * resolve the p2p identity for this origin.
160
- * prefers spume's db when both sources have an identity.
161
- */
162
- export async function resolveIdentity(
163
- local: IdentityStore,
164
- ): Promise<P2PIdentity | null> {
165
- const exists = await freqholeAppExists();
166
- if (exists && (await hasAppStateStore())) {
167
- const spumeIdentity = await readFromFreqholeApp();
168
- if (spumeIdentity) return spumeIdentity;
169
- // spume db exists but has no identity - fall through to local
170
- }
171
-
172
- return local.get();
173
- }
174
-
175
- /**
176
- * persist a p2p identity.
177
- * writes to spume's db if it exists and has app_state; otherwise uses local.
178
- */
179
- export async function persistIdentity(
180
- identity: P2PIdentity,
181
- local: IdentityStore,
182
- ): Promise<void> {
183
- const exists = await freqholeAppExists();
184
- if (exists && (await hasAppStateStore())) {
185
- await writeToFreqholeApp(identity);
186
- return;
187
- }
188
-
189
- await local.set(identity);
190
- }
@@ -1,5 +0,0 @@
1
- // shared browser storage layer - re-exports all storage submodules
2
-
3
- export * from "./blobs.js";
4
- export * from "./identity.js";
5
- export * from "./webLocks.js";
@@ -1,92 +0,0 @@
1
- // web locks leader election for the iroh node.
2
- // only one tab per origin runs the midden node at a time.
3
-
4
- /** lock name used for iroh node leader election */
5
- export const LOCK_NAME = "freqhole-iroh-node";
6
-
7
- export interface AcquireNodeLeadershipOpts {
8
- /** called when this tab becomes the node leader */
9
- onAcquired: () => void | Promise<void>;
10
- /** called with state transitions; optional */
11
- onStateChange?: (state: "leader" | "waiting" | "unsupported") => void;
12
- }
13
-
14
- /**
15
- * attempt to acquire exclusive leadership of the iroh node.
16
- *
17
- * behavior:
18
- * - tries ifAvailable first; if the lock is free, holds it and calls onAcquired
19
- * - if already held, reports "waiting" and queues a normal request with an AbortController
20
- * so leadership transfers when the current leader tab closes
21
- * - if navigator.locks is absent (e.g. non-secure context), reports "unsupported"
22
- * and calls onAcquired immediately (single-tab fallback)
23
- *
24
- * returns a cancel/release function; call it to give up leadership or cancel the wait.
25
- */
26
- export function acquireNodeLeadership(
27
- opts: AcquireNodeLeadershipOpts,
28
- ): () => void {
29
- const { onAcquired, onStateChange } = opts;
30
-
31
- // single-tab fallback for environments without Web Locks
32
- if (
33
- typeof navigator === "undefined" ||
34
- !navigator.locks
35
- ) {
36
- onStateChange?.("unsupported");
37
- void Promise.resolve().then(() => onAcquired());
38
- return () => {};
39
- }
40
-
41
- let released = false;
42
- // resolving this promise releases the held lock
43
- let releaseLock: (() => void) | null = null;
44
- // used to cancel the waiting queue request
45
- const waitAbort = new AbortController();
46
-
47
- function cancel() {
48
- released = true;
49
- waitAbort.abort();
50
- releaseLock?.();
51
- }
52
-
53
- // try to grab the lock immediately without queuing
54
- navigator.locks.request(
55
- LOCK_NAME,
56
- { ifAvailable: true },
57
- async (lock: Lock | null) => {
58
- if (released) return;
59
-
60
- if (lock !== null) {
61
- // we are now the leader - call onAcquired, then hold the lock until released
62
- onStateChange?.("leader");
63
- await onAcquired();
64
- await new Promise<void>((resolve) => {
65
- releaseLock = resolve;
66
- });
67
- } else {
68
- // another tab holds the lock - queue up for leadership
69
- onStateChange?.("waiting");
70
-
71
- try {
72
- await navigator.locks.request(
73
- LOCK_NAME,
74
- { signal: waitAbort.signal },
75
- async () => {
76
- if (released) return;
77
- onStateChange?.("leader");
78
- await onAcquired();
79
- await new Promise<void>((resolve) => {
80
- releaseLock = resolve;
81
- });
82
- },
83
- );
84
- } catch {
85
- // aborted by cancel() or context destroyed - not an error
86
- }
87
- }
88
- },
89
- );
90
-
91
- return cancel;
92
- }