@inkandswitch/patchwork-bootloader 0.0.4 → 0.0.5

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.
@@ -0,0 +1,366 @@
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
+ }
package/dist/types.d.ts CHANGED
@@ -1,30 +1,3 @@
1
- export interface HandoffRequest {
2
- url: string;
3
- headers: Record<string, string>;
4
- method: string;
5
- destination: RequestDestination;
6
- referrer: string;
7
- }
8
- export interface HandoffResponse {
9
- body?: string | Uint8Array<ArrayBuffer> | ReadableStream;
10
- /** defaults to 200 */
11
- status?: number;
12
- headers?: [string, string][] | Record<string, string>;
13
- cache?: boolean;
14
- }
15
- export interface HandoffRequestMessage {
16
- id: number;
17
- type: "request";
18
- /** the current name of the service worker cache */
19
- cachename: string;
20
- request: HandoffRequest;
21
- }
22
- export interface HandoffResponseMessage {
23
- id: number;
24
- type: "response";
25
- response: HandoffResponse;
26
- }
27
- export type HandoffHandler = (href: string, request: HandoffRequest) => Promise<HandoffResponse | void | string | Uint8Array<ArrayBuffer>>;
28
1
  export type SetupServiceWorkerOptions = {
29
2
  /**
30
3
  * The public path to the service worker file.
@@ -1,3 +1,6 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { createRequire } from "node:module";
3
+ const require = createRequire(import.meta.url);
1
4
  /**
2
5
  * these dependencies will be built into the outdir,
3
6
  * and injected into the importmap
@@ -19,16 +22,28 @@ export function importmap(options) {
19
22
  return {
20
23
  name: "@patchwork/vite",
21
24
  async buildStart() {
22
- if (this.environment.mode == "build") {
23
- for (const [id, fileName] of Object.entries(builtins)) {
24
- this.emitFile({
25
- type: "chunk",
26
- fileName: fileName.slice(1),
27
- id,
28
- preserveSignature: "strict",
29
- });
30
- }
25
+ for (const [id, fileName] of Object.entries(builtins)) {
26
+ this.emitFile({
27
+ type: "chunk",
28
+ fileName: fileName.slice(1),
29
+ id,
30
+ preserveSignature: "strict",
31
+ });
31
32
  }
33
+ // Emit automerge wasm so the service worker can fetch it
34
+ const wasmPath = require.resolve("@automerge/automerge/automerge.wasm");
35
+ this.emitFile({
36
+ type: "asset",
37
+ fileName: "automerge.wasm",
38
+ source: readFileSync(wasmPath),
39
+ });
40
+ // Emit subduction wasm so the service worker can fetch it
41
+ const subdWasmPath = require.resolve("@automerge/automerge-subduction/wasm");
42
+ this.emitFile({
43
+ type: "asset",
44
+ fileName: "subduction.wasm",
45
+ source: readFileSync(subdWasmPath),
46
+ });
32
47
  },
33
48
  resolveId(id) {
34
49
  if (id in importmap.imports && !(id in builtins)) {
@@ -37,21 +52,14 @@ export function importmap(options) {
37
52
  },
38
53
  transformIndexHtml: {
39
54
  order: "pre",
40
- handler(html, ctx) {
41
- const map = structuredClone(importmap);
42
- if (ctx.server) {
43
- // serve builtins from dev server in dev mode
44
- for (const id of Object.keys(builtins)) {
45
- map.imports[id] = `/@id/${id}`;
46
- }
47
- }
55
+ handler(html) {
48
56
  return {
49
57
  html,
50
58
  tags: [
51
59
  {
52
60
  tag: "script",
53
61
  attrs: { type: "importmap" },
54
- children: JSON.stringify(map, null, 2),
62
+ children: JSON.stringify(importmap, null, 2),
55
63
  },
56
64
  ],
57
65
  };
@@ -1,2 +1,2 @@
1
- import { type Plugin } from "vite";
1
+ import type { Plugin } from "vite";
2
2
  export declare function serviceworker(): Plugin;
@@ -1,38 +1,21 @@
1
- import { transformWithEsbuild } from "vite";
1
+ import { builtins } from "./importmap-plugin.js";
2
2
  export function serviceworker() {
3
- const moduleId = "service-worker.js";
4
- const path = `/${moduleId}`;
5
- const ids = [moduleId, path];
6
- const serviceWorkerExport = "@inkandswitch/patchwork-bootloader/service-worker";
7
- async function transform(resolve, fs) {
8
- const exportPath = await resolve(serviceWorkerExport);
9
- const file = await fs.readFile(exportPath.id, {
10
- encoding: "utf8",
11
- });
12
- const transformation = await transformWithEsbuild(file, serviceWorkerExport, { format: "iife" });
13
- return transformation;
14
- }
3
+ let swEntryId;
15
4
  return {
16
- name: "@patchwork/vite",
5
+ name: "@patchwork/service-worker",
6
+ enforce: "pre",
17
7
  async buildStart() {
18
- if (this.environment.mode == "build") {
19
- const trans = await transform(this.resolve.bind(this), this.fs);
20
- this.emitFile({
21
- type: "prebuilt-chunk",
22
- fileName: path.slice(1),
23
- code: trans.code,
24
- map: trans.map,
25
- });
26
- }
27
- },
28
- resolveId(id) {
29
- if (ids.includes(id)) {
30
- return moduleId;
31
- }
8
+ const resolved = await this.resolve("@inkandswitch/patchwork-bootloader/service-worker");
9
+ swEntryId = resolved.id;
10
+ this.emitFile({
11
+ type: "chunk",
12
+ id: resolved.id,
13
+ fileName: "service-worker.js",
14
+ });
32
15
  },
33
- async load(id) {
34
- if (ids.includes(id)) {
35
- return transform(this.resolve.bind(this), this.fs);
16
+ resolveId(source, importer) {
17
+ if (importer && swEntryId && importer === swEntryId && source in builtins) {
18
+ return { id: builtins[source], external: true };
36
19
  }
37
20
  },
38
21
  };
package/package.json CHANGED
@@ -1,12 +1,11 @@
1
1
  {
2
2
  "name": "@inkandswitch/patchwork-bootloader",
3
- "version": "0.0.4",
3
+ "version": "0.0.5",
4
4
  "author": "chee",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "devDependencies": {
8
- "@automerge/automerge-repo-keyhive": "0.0.0-alpha.71",
9
- "@keyhive/keyhive": "0.0.0-alpha.40",
8
+ "@automerge/automerge-repo-keyhive": "0.2.0-alpha.1d",
10
9
  "esbuild": "^0.23.1",
11
10
  "rollup": "^4.53.3"
12
11
  },
@@ -27,27 +26,41 @@
27
26
  "import": "./dist/service-worker.js",
28
27
  "types": "./dist/service-worker.d.ts"
29
28
  },
29
+ "./site": {
30
+ "import": "./dist/site.js",
31
+ "types": "./dist/site.d.ts"
32
+ },
30
33
  "./types": {
31
34
  "import": "./dist/types.js",
32
35
  "types": "./dist/types.d.ts"
36
+ },
37
+ "./sw-logger": {
38
+ "import": "./dist/sw-logger.js",
39
+ "types": "./dist/sw-logger.d.ts"
33
40
  }
34
41
  },
35
42
  "dependencies": {
36
- "@automerge/automerge": "3.2.1",
37
- "@automerge/automerge-repo": "2.5.0",
38
- "@automerge/vanillajs": "2.5.0",
43
+ "@automerge/automerge": "3.2.5",
44
+ "@automerge/automerge-repo": "2.6.0-subduction.15",
45
+ "@automerge/automerge-subduction": "0.8.1",
46
+ "@automerge/automerge-repo-network-messagechannel": "2.6.0-subduction.15",
47
+ "@automerge/automerge-repo-network-websocket": "2.6.0-subduction.15",
48
+ "@automerge/automerge-repo-storage-indexeddb": "2.6.0-subduction.15",
49
+ "@automerge/vanillajs": "2.6.0-subduction.15",
39
50
  "@types/debug": "^4.1.12",
40
51
  "debug": "^4.4.3",
41
52
  "resolve.exports": "^2.0.3",
42
53
  "service-worker-types": "npm:@types/serviceworker@^0.0.153",
43
- "tinyargs": "^0.1.4"
54
+ "tinyargs": "^0.1.4",
55
+ "@inkandswitch/patchwork-plugins": "^0.0.6",
56
+ "@inkandswitch/patchwork-elements": "^0.0.6",
57
+ "@inkandswitch/patchwork-filesystem": "^0.0.4"
44
58
  },
45
59
  "peerDependencies": {
46
- "@automerge/automerge": "3.2.1",
47
- "@automerge/automerge-repo": "2.5.0",
48
- "@automerge/automerge-repo-keyhive": "0.0.0-alpha.71",
49
- "@automerge/vanillajs": "2.5.0",
50
- "@keyhive/keyhive": "0.0.0-alpha.40"
60
+ "@automerge/automerge": "3.2.5",
61
+ "@automerge/automerge-repo": "2.6.0-subduction.15",
62
+ "@automerge/automerge-repo-keyhive": "0.2.0-alpha.1d",
63
+ "@automerge/vanillajs": "2.6.0-subduction.15"
51
64
  },
52
65
  "scripts": {
53
66
  "build": "tsc",
package/src/externals.ts CHANGED
@@ -7,6 +7,8 @@ const externals = [
7
7
  "@automerge/automerge-repo",
8
8
  "@automerge/automerge-repo/slim",
9
9
  "@automerge/automerge-repo-keyhive",
10
+ "@automerge/automerge-subduction",
11
+ "@automerge/automerge-subduction/slim",
10
12
  "@keyhive/keyhive",
11
13
  "@keyhive/keyhive/slim",
12
14
  "@inkandswitch/patchwork-bootloader",
@@ -17,5 +19,14 @@ const externals = [
17
19
  // sad
18
20
  "@codemirror/state",
19
21
  "@codemirror/view",
22
+ "@codemirror/language",
23
+
24
+ // rip
25
+ "solid-js",
26
+ "solid-js/html",
27
+ "solid-js/web",
28
+ "solid-js/h",
29
+ "solid-js/store",
30
+ "solid-js/jsx-runtime",
20
31
  ];
21
32
  export default externals;