@inkandswitch/patchwork-bootloader 0.2.5 → 0.2.7

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/src/types.ts CHANGED
@@ -1,9 +1,103 @@
1
+ /**
2
+ * The BroadcastChannel the service worker and the automerge shared worker
3
+ * use to hand requests off to each other. Broadcast (rather than a
4
+ * MessagePort handed from one to the other) so the two never need to be
5
+ * reintroduced when either of them restarts — and so tabs can listen in.
6
+ */
7
+ export const HANDOFF_CHANNEL = "@patchwork/handoff";
8
+
9
+ /**
10
+ * The special URL to resolve, plus enough of the {@link Request} the service
11
+ * worker is holding that the automerge worker can construct one that
12
+ * `cache.match`es it.
13
+ *
14
+ * Stale workers on either side of the channel can outlive a deploy, so the
15
+ * shape can only ever change additively: `url` must stay the http request
16
+ * URL old automerge workers decode the special URL out of, and new meaning
17
+ * goes in new fields old receivers ignore.
18
+ */
19
+ export interface HandoffRequest {
20
+ /**
21
+ * The URL of the request the service worker is holding (the encoded
22
+ * `https://…/automerge%3Aabc/…` form) — the cache key.
23
+ */
24
+ url: string;
25
+ /** the decoded special URL, e.g. `automerge:abc/some/path` */
26
+ handoffURL: string;
27
+ /**
28
+ * @deprecated A briefly-deployed shape put the special URL in `url` and
29
+ * the cache key here. Only read, never sent.
30
+ */
31
+ cacheKey?: string;
32
+ headers: Record<string, string>;
33
+ method: string;
34
+ destination: RequestDestination;
35
+ referrer: string;
36
+ }
37
+
38
+ /**
39
+ * Service worker → automerge worker: please resolve this request and put
40
+ * the response in my cache.
41
+ */
42
+ export interface HandoffRequestMessage {
43
+ id: string;
44
+ type: "request";
45
+ /** the current name of the service worker cache */
46
+ cachename: string;
47
+ request: HandoffRequest;
48
+ }
49
+
50
+ /**
51
+ * Automerge worker → service worker: the response is stored in the cache
52
+ * under the request you're holding. Serve `cache.match`.
53
+ */
54
+ export interface HandoffCachedMessage {
55
+ id: string;
56
+ type: "cached";
57
+ }
58
+
59
+ /**
60
+ * An inline response for things that shouldn't be cached: errors, redirects
61
+ * &c.
62
+ */
63
+ export interface HandoffResponse {
64
+ body?: string | Uint8Array<ArrayBuffer>;
65
+ /** defaults to 200 */
66
+ status?: number;
67
+ headers?: Record<string, string>;
68
+ }
69
+
70
+ /**
71
+ * Automerge worker → service worker: don't cache anything, serve this
72
+ * response directly.
73
+ */
74
+ export interface HandoffResponseMessage {
75
+ id: string;
76
+ type: "response";
77
+ response: HandoffResponse;
78
+ }
79
+
80
+ export type HandoffReplyMessage = HandoffCachedMessage | HandoffResponseMessage;
81
+
82
+ /**
83
+ * Automerge worker → world: broadcast once on startup so the service worker
84
+ * can re-send any handoff requests that raced the worker's boot.
85
+ */
86
+ export interface HandoffOnlineMessage {
87
+ type: "online";
88
+ }
89
+
1
90
  export type SetupServiceWorkerOptions = {
2
91
  /**
3
92
  * The public path to the service worker file.
4
93
  * Defaults to `/service-worker.js`
5
94
  */
6
95
  path?: string;
96
+ /**
97
+ * The public path to the automerge shared worker file.
98
+ * Defaults to `/automerge-worker.js`
99
+ */
100
+ workerPath?: string;
7
101
  };
8
102
 
9
103
  export type ServiceWorkerRepoChannelListener = (
@@ -11,7 +105,11 @@ export type ServiceWorkerRepoChannelListener = (
11
105
  ) => void | Promise<void>;
12
106
 
13
107
  export type SetupServiceWorkerResult = {
108
+ /** Open a classic Automerge sync WebSocket from the automerge worker. */
109
+ connectClassicSync: (server?: string) => Promise<void>;
14
110
  subscribeToRepoChannel: (
15
111
  listener: ServiceWorkerRepoChannelListener
16
112
  ) => Promise<() => void>;
113
+ /** Open a fresh repo sync port to the automerge worker (dev console). */
114
+ getRepoChannel: () => MessagePort;
17
115
  };
@@ -45,12 +45,22 @@ export function importmap(options?: PatchworkVitePluginOptions): Plugin {
45
45
  });
46
46
  }
47
47
 
48
- // Emit automerge wasm so the service worker can fetch it
49
- const wasmPath = require.resolve("@automerge/automerge/automerge.wasm");
48
+ // Emit automerge, keyhive, and subduction wasm so the service worker can fetch them
49
+ const automergeWasmPath = require.resolve(
50
+ "@automerge/automerge/automerge.wasm"
51
+ );
50
52
  this.emitFile({
51
53
  type: "asset",
52
54
  fileName: "automerge.wasm",
53
- source: readFileSync(wasmPath),
55
+ source: readFileSync(automergeWasmPath),
56
+ });
57
+ const keyhiveWasmPath = require.resolve(
58
+ "@keyhive/keyhive/keyhive_wasm.wasm"
59
+ );
60
+ this.emitFile({
61
+ type: "asset",
62
+ fileName: "keyhive_wasm.wasm",
63
+ source: readFileSync(keyhiveWasmPath),
54
64
  });
55
65
 
56
66
  // Emit subduction wasm so the service worker can fetch it
@@ -1,25 +1,40 @@
1
1
  import type { Plugin } from "vite";
2
2
  import { builtins } from "./importmap-plugin.js";
3
3
 
4
+ // The service worker and the automerge shared worker are emitted as their
5
+ // own chunks. Their heavy imports are marked external and resolved to
6
+ // /packages/... URLs (both workers are created with type:"module", so the
7
+ // browser fetches those as regular network requests).
8
+ const workers = [
9
+ {
10
+ specifier: "@inkandswitch/patchwork-bootloader/service-worker",
11
+ fileName: "service-worker.js",
12
+ },
13
+ {
14
+ specifier: "@inkandswitch/patchwork-bootloader/automerge-worker",
15
+ fileName: "automerge-worker.js",
16
+ },
17
+ ];
18
+
4
19
  export function serviceworker(): Plugin {
5
- let swEntryId: string | undefined;
20
+ const entryIds = new Set<string>();
6
21
 
7
22
  return {
8
23
  name: "@patchwork/service-worker",
9
24
  enforce: "pre",
10
25
  async buildStart() {
11
- const resolved = await this.resolve(
12
- "@inkandswitch/patchwork-bootloader/service-worker"
13
- );
14
- swEntryId = resolved!.id;
15
- this.emitFile({
16
- type: "chunk",
17
- id: resolved!.id,
18
- fileName: "service-worker.js",
19
- });
26
+ for (const { specifier, fileName } of workers) {
27
+ const resolved = await this.resolve(specifier);
28
+ entryIds.add(resolved!.id);
29
+ this.emitFile({
30
+ type: "chunk",
31
+ id: resolved!.id,
32
+ fileName,
33
+ });
34
+ }
20
35
  },
21
36
  resolveId(source, importer) {
22
- if (importer && swEntryId && importer === swEntryId && source in builtins) {
37
+ if (importer && entryIds.has(importer) && source in builtins) {
23
38
  return { id: builtins[source], external: true };
24
39
  }
25
40
  },
package/tsconfig.json CHANGED
@@ -9,5 +9,5 @@
9
9
  "skipLibCheck": true,
10
10
  "isolatedModules": true
11
11
  },
12
- "include": ["src", "../../sites/tiny-patchwork/shared-worker.ts"]
12
+ "include": ["src"]
13
13
  }
@@ -1,105 +0,0 @@
1
- /**
2
- * Persistent ring-buffer logger for service workers.
3
- *
4
- * Stores log entries in a dedicated IndexedDB database (`sw-logs`) that is
5
- * completely separate from the `automerge` database used by the Repo, so
6
- * writes here never contend with storage or hydration transactions.
7
- *
8
- * Entries are accumulated in memory and batch-flushed to IDB periodically
9
- * (every {@link FLUSH_INTERVAL_MS}) or when the buffer reaches
10
- * {@link FLUSH_THRESHOLD} entries — whichever comes first.
11
- *
12
- * The on-disk store is a ring buffer capped at {@link MAX_ENTRIES}. Oldest
13
- * entries are pruned on each flush when the cap is exceeded.
14
- *
15
- * ## Usage
16
- *
17
- * ```ts
18
- * import { SwLogger } from "./sw-logger.js"
19
- *
20
- * const log = await SwLogger.open()
21
- * log.info("repo initialized")
22
- * log.warn("connection dropped", { url })
23
- * log.error("sync threw", error)
24
- *
25
- * // From the SW inspector console:
26
- * self.printLogs() // prints last 200 entries
27
- * self.printLogs(5000) // prints last 5 000 entries
28
- * self.tailLogs(100) // returns last 100 entries as an array
29
- * self.exportLogs() // returns all entries as JSON string
30
- * self.clearLogs() // wipes the log database
31
- * ```
32
- */
33
- export interface LogEntry {
34
- /** Auto-incremented IDB key (doubles as ordering index). */
35
- id?: number;
36
- /** ISO-8601 timestamp */
37
- ts: string;
38
- /** Monotonic high-res timestamp (ms since SW start) */
39
- hrt: number;
40
- /** Log level */
41
- level: "debug" | "info" | "warn" | "error";
42
- /** Log message */
43
- msg: string;
44
- /** Optional structured data (must be cloneable) */
45
- data?: unknown;
46
- }
47
- export interface SwLoggerInterface {
48
- debug(msg: string, data?: unknown): void;
49
- info(msg: string, data?: unknown): void;
50
- warn(msg: string, data?: unknown): void;
51
- error(msg: string, data?: unknown): void;
52
- flush(): Promise<void>;
53
- tail(n?: number): Promise<LogEntry[]>;
54
- exportAll(): Promise<string>;
55
- clear(): Promise<void>;
56
- dispose(): void;
57
- }
58
- export declare class SwLogger implements SwLoggerInterface {
59
- #private;
60
- private constructor();
61
- /**
62
- * Open (or create) the log database and return a ready logger.
63
- * If the database cannot be opened (quota, permissions, etc.),
64
- * returns a {@link NoopLogger} that writes to the console only.
65
- */
66
- static open(): Promise<SwLoggerInterface>;
67
- debug(msg: string, data?: unknown): void;
68
- info(msg: string, data?: unknown): void;
69
- warn(msg: string, data?: unknown): void;
70
- error(msg: string, data?: unknown): void;
71
- /** Force an immediate flush of the in-memory buffer to IDB. */
72
- flush(): Promise<void>;
73
- /** Read the last `n` entries (default 200). */
74
- tail(n?: number): Promise<LogEntry[]>;
75
- /** Return all entries as a JSON string (for copy-paste from console). */
76
- exportAll(): Promise<string>;
77
- /** Delete all log entries. */
78
- clear(): Promise<void>;
79
- /** Stop the periodic flush timer. */
80
- dispose(): void;
81
- }
82
- /**
83
- * Read-only accessor for the SW log database.
84
- *
85
- * Unlike {@link SwLogger}, this class does not hold a persistent IDB
86
- * connection — each method opens a fresh connection and closes it after
87
- * use. This avoids interfering with the SW's write transactions.
88
- *
89
- * @example
90
- * ```ts
91
- * import { SwLogReader } from "@inkandswitch/patchwork-bootloader/sw-logger"
92
- *
93
- * const last100 = await SwLogReader.tail(100)
94
- * const json = await SwLogReader.exportAll()
95
- * await SwLogReader.clear()
96
- * ```
97
- */
98
- export declare class SwLogReader {
99
- /** Read the last `n` entries (default 200), oldest-first. */
100
- static tail(n?: number): Promise<LogEntry[]>;
101
- /** Return all entries as a JSON string. */
102
- static exportAll(): Promise<string>;
103
- /** Delete all log entries. */
104
- static clear(): Promise<void>;
105
- }
package/dist/sw-logger.js DELETED
@@ -1,366 +0,0 @@
1
- /**
2
- * Persistent ring-buffer logger for service workers.
3
- *
4
- * Stores log entries in a dedicated IndexedDB database (`sw-logs`) that is
5
- * completely separate from the `automerge` database used by the Repo, so
6
- * writes here never contend with storage or hydration transactions.
7
- *
8
- * Entries are accumulated in memory and batch-flushed to IDB periodically
9
- * (every {@link FLUSH_INTERVAL_MS}) or when the buffer reaches
10
- * {@link FLUSH_THRESHOLD} entries — whichever comes first.
11
- *
12
- * The on-disk store is a ring buffer capped at {@link MAX_ENTRIES}. Oldest
13
- * entries are pruned on each flush when the cap is exceeded.
14
- *
15
- * ## Usage
16
- *
17
- * ```ts
18
- * import { SwLogger } from "./sw-logger.js"
19
- *
20
- * const log = await SwLogger.open()
21
- * log.info("repo initialized")
22
- * log.warn("connection dropped", { url })
23
- * log.error("sync threw", error)
24
- *
25
- * // From the SW inspector console:
26
- * self.printLogs() // prints last 200 entries
27
- * self.printLogs(5000) // prints last 5 000 entries
28
- * self.tailLogs(100) // returns last 100 entries as an array
29
- * self.exportLogs() // returns all entries as JSON string
30
- * self.clearLogs() // wipes the log database
31
- * ```
32
- */
33
- // ── Configuration ───────────────────────────────────────────────────────
34
- const DB_NAME = "sw-logs";
35
- const DB_VERSION = 1;
36
- const STORE_NAME = "entries";
37
- const MAX_ENTRIES = 50_000;
38
- const FLUSH_INTERVAL_MS = 1_000;
39
- const FLUSH_THRESHOLD = 128;
40
- // ── Console method lookup ───────────────────────────────────────────────
41
- const consoleMethods = {
42
- debug: console.debug.bind(console),
43
- info: console.info.bind(console),
44
- warn: console.warn.bind(console),
45
- error: console.error.bind(console),
46
- };
47
- // ── No-op fallback (used when IDB is unavailable) ───────────────────────
48
- class NoopLogger {
49
- debug(msg, data) {
50
- consoleMethods.debug(`[sw:debug]`, msg, ...(data !== undefined ? [data] : []));
51
- }
52
- info(msg, data) {
53
- consoleMethods.info(`[sw:info]`, msg, ...(data !== undefined ? [data] : []));
54
- }
55
- warn(msg, data) {
56
- consoleMethods.warn(`[sw:warn]`, msg, ...(data !== undefined ? [data] : []));
57
- }
58
- error(msg, data) {
59
- consoleMethods.error(`[sw:error]`, msg, ...(data !== undefined ? [data] : []));
60
- }
61
- async flush() { }
62
- async tail() {
63
- return [];
64
- }
65
- async exportAll() {
66
- return "[]";
67
- }
68
- async clear() { }
69
- dispose() { }
70
- }
71
- // ── Implementation ──────────────────────────────────────────────────────
72
- export class SwLogger {
73
- #db;
74
- #buffer = [];
75
- #flushTimer = null;
76
- constructor(db) {
77
- this.#db = db;
78
- this.#flushTimer = setInterval(() => this.flush(), FLUSH_INTERVAL_MS);
79
- }
80
- /**
81
- * Open (or create) the log database and return a ready logger.
82
- * If the database cannot be opened (quota, permissions, etc.),
83
- * returns a {@link NoopLogger} that writes to the console only.
84
- */
85
- static async open() {
86
- try {
87
- const db = await new Promise((resolve, reject) => {
88
- const req = indexedDB.open(DB_NAME, DB_VERSION);
89
- req.onupgradeneeded = () => {
90
- const db = req.result;
91
- if (!db.objectStoreNames.contains(STORE_NAME)) {
92
- db.createObjectStore(STORE_NAME, {
93
- keyPath: "id",
94
- autoIncrement: true,
95
- });
96
- }
97
- };
98
- req.onsuccess = () => resolve(req.result);
99
- req.onerror = () => reject(req.error);
100
- });
101
- return new SwLogger(db);
102
- }
103
- catch (e) {
104
- console.warn("[sw-logger] failed to open IDB, falling back to console-only:", e);
105
- return new NoopLogger();
106
- }
107
- }
108
- // ── Public API ──────────────────────────────────────────────────────
109
- debug(msg, data) {
110
- this.#append("debug", msg, data);
111
- }
112
- info(msg, data) {
113
- this.#append("info", msg, data);
114
- }
115
- warn(msg, data) {
116
- this.#append("warn", msg, data);
117
- }
118
- error(msg, data) {
119
- this.#append("error", msg, data);
120
- }
121
- /** Force an immediate flush of the in-memory buffer to IDB. */
122
- async flush() {
123
- if (this.#buffer.length === 0)
124
- return;
125
- const batch = this.#buffer.splice(0);
126
- try {
127
- const tx = this.#db.transaction(STORE_NAME, "readwrite");
128
- const store = tx.objectStore(STORE_NAME);
129
- for (const entry of batch) {
130
- store.add(entry);
131
- }
132
- await txComplete(tx);
133
- }
134
- catch (e) {
135
- // If the write fails, put the entries back so the next flush retries.
136
- this.#buffer.unshift(...batch);
137
- console.warn("[sw-logger] flush failed:", e);
138
- return;
139
- }
140
- try {
141
- await this.#prune();
142
- }
143
- catch (e) {
144
- // Prune failures are non-fatal — entries are already committed.
145
- console.warn("[sw-logger] prune failed:", e);
146
- }
147
- }
148
- /** Read the last `n` entries (default 200). */
149
- async tail(n = 200) {
150
- // Flush pending entries first so the tail is up to date.
151
- await this.flush();
152
- const tx = this.#db.transaction(STORE_NAME, "readonly");
153
- const store = tx.objectStore(STORE_NAME);
154
- return new Promise((resolve, reject) => {
155
- const entries = [];
156
- const req = store.openCursor(null, "prev");
157
- req.onsuccess = () => {
158
- const cursor = req.result;
159
- if (cursor && entries.length < n) {
160
- entries.push(cursor.value);
161
- cursor.continue();
162
- }
163
- else {
164
- resolve(entries.reverse());
165
- }
166
- };
167
- req.onerror = () => reject(req.error);
168
- });
169
- }
170
- /** Return all entries as a JSON string (for copy-paste from console). */
171
- async exportAll() {
172
- await this.flush();
173
- const tx = this.#db.transaction(STORE_NAME, "readonly");
174
- const store = tx.objectStore(STORE_NAME);
175
- return new Promise((resolve, reject) => {
176
- const req = store.getAll();
177
- req.onsuccess = () => resolve(JSON.stringify(req.result, null, 2));
178
- req.onerror = () => reject(req.error);
179
- });
180
- }
181
- /** Delete all log entries. */
182
- async clear() {
183
- const tx = this.#db.transaction(STORE_NAME, "readwrite");
184
- tx.objectStore(STORE_NAME).clear();
185
- await txComplete(tx);
186
- }
187
- /** Stop the periodic flush timer. */
188
- dispose() {
189
- if (this.#flushTimer) {
190
- clearInterval(this.#flushTimer);
191
- this.#flushTimer = null;
192
- }
193
- }
194
- // ── Internals ─────────────────────────────────────────────────────
195
- #append(level, msg, data) {
196
- this.#buffer.push({
197
- ts: new Date().toISOString(),
198
- hrt: performance.now(),
199
- level,
200
- msg,
201
- data: data !== undefined ? safeClone(data) : undefined,
202
- });
203
- // Mirror to console using the appropriate severity method.
204
- const log = consoleMethods[level];
205
- const tag = `[sw:${level}]`;
206
- if (data !== undefined) {
207
- log(tag, msg, data);
208
- }
209
- else {
210
- log(tag, msg);
211
- }
212
- if (this.#buffer.length >= FLUSH_THRESHOLD) {
213
- this.flush();
214
- }
215
- }
216
- async #prune() {
217
- // Count in a separate readonly transaction to avoid
218
- // TransactionInactiveError from awaiting within a single transaction.
219
- const count = await new Promise((resolve, reject) => {
220
- const tx = this.#db.transaction(STORE_NAME, "readonly");
221
- const store = tx.objectStore(STORE_NAME);
222
- const req = store.count();
223
- req.onsuccess = () => resolve(req.result);
224
- req.onerror = () => reject(req.error);
225
- tx.onabort = () => reject(tx.error ?? new Error("sw-logs count transaction aborted"));
226
- });
227
- if (count <= MAX_ENTRIES)
228
- return;
229
- // Delete the oldest entries in a separate readwrite transaction.
230
- const excess = count - MAX_ENTRIES;
231
- await new Promise((resolve, reject) => {
232
- const tx = this.#db.transaction(STORE_NAME, "readwrite");
233
- const store = tx.objectStore(STORE_NAME);
234
- let deleted = 0;
235
- const req = store.openCursor();
236
- req.onsuccess = () => {
237
- const cursor = req.result;
238
- if (cursor && deleted < excess) {
239
- cursor.delete();
240
- deleted++;
241
- cursor.continue();
242
- }
243
- };
244
- req.onerror = () => reject(req.error);
245
- tx.oncomplete = () => resolve();
246
- tx.onabort = () => reject(tx.error ?? new Error("sw-logs prune transaction aborted"));
247
- });
248
- }
249
- }
250
- // ── Helpers ──────────────────────────────────────────────────────────────
251
- function txComplete(tx) {
252
- return new Promise((resolve, reject) => {
253
- tx.oncomplete = () => resolve();
254
- tx.onerror = () => reject(tx.error);
255
- tx.onabort = () => reject(tx.error ?? new Error("transaction aborted"));
256
- });
257
- }
258
- // ── Read-only access (usable from any context, including main thread) ───
259
- /**
260
- * Read-only accessor for the SW log database.
261
- *
262
- * Unlike {@link SwLogger}, this class does not hold a persistent IDB
263
- * connection — each method opens a fresh connection and closes it after
264
- * use. This avoids interfering with the SW's write transactions.
265
- *
266
- * @example
267
- * ```ts
268
- * import { SwLogReader } from "@inkandswitch/patchwork-bootloader/sw-logger"
269
- *
270
- * const last100 = await SwLogReader.tail(100)
271
- * const json = await SwLogReader.exportAll()
272
- * await SwLogReader.clear()
273
- * ```
274
- */
275
- export class SwLogReader {
276
- /** Read the last `n` entries (default 200), oldest-first. */
277
- static async tail(n = 200) {
278
- const db = await openDb();
279
- try {
280
- const tx = db.transaction(STORE_NAME, "readonly");
281
- const store = tx.objectStore(STORE_NAME);
282
- return await new Promise((resolve, reject) => {
283
- const entries = [];
284
- const req = store.openCursor(null, "prev");
285
- req.onsuccess = () => {
286
- const cursor = req.result;
287
- if (cursor && entries.length < n) {
288
- entries.push(cursor.value);
289
- cursor.continue();
290
- }
291
- else {
292
- resolve(entries.reverse());
293
- }
294
- };
295
- req.onerror = () => reject(req.error);
296
- });
297
- }
298
- finally {
299
- db.close();
300
- }
301
- }
302
- /** Return all entries as a JSON string. */
303
- static async exportAll() {
304
- const db = await openDb();
305
- try {
306
- const tx = db.transaction(STORE_NAME, "readonly");
307
- const store = tx.objectStore(STORE_NAME);
308
- return await new Promise((resolve, reject) => {
309
- const req = store.getAll();
310
- req.onsuccess = () => resolve(JSON.stringify(req.result, null, 2));
311
- req.onerror = () => reject(req.error);
312
- });
313
- }
314
- finally {
315
- db.close();
316
- }
317
- }
318
- /** Delete all log entries. */
319
- static async clear() {
320
- const db = await openDb();
321
- try {
322
- const tx = db.transaction(STORE_NAME, "readwrite");
323
- tx.objectStore(STORE_NAME).clear();
324
- await txComplete(tx);
325
- }
326
- finally {
327
- db.close();
328
- }
329
- }
330
- }
331
- /** Open a short-lived connection to the log database. */
332
- function openDb() {
333
- return new Promise((resolve, reject) => {
334
- const req = indexedDB.open(DB_NAME, DB_VERSION);
335
- req.onupgradeneeded = () => {
336
- const db = req.result;
337
- if (!db.objectStoreNames.contains(STORE_NAME)) {
338
- db.createObjectStore(STORE_NAME, {
339
- keyPath: "id",
340
- autoIncrement: true,
341
- });
342
- }
343
- };
344
- req.onsuccess = () => resolve(req.result);
345
- req.onerror = () => reject(req.error);
346
- });
347
- }
348
- /** Best-effort structured clone for the `data` field. Falls back to string. */
349
- function safeClone(value) {
350
- if (value === null || value === undefined)
351
- return value;
352
- if (typeof value === "string" ||
353
- typeof value === "number" ||
354
- typeof value === "boolean") {
355
- return value;
356
- }
357
- if (value instanceof Error) {
358
- return { name: value.name, message: value.message, stack: value.stack };
359
- }
360
- try {
361
- return structuredClone(value);
362
- }
363
- catch {
364
- return String(value);
365
- }
366
- }