@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/CHANGELOG.md +14 -0
- package/dist/automerge-worker.d.ts +1 -0
- package/dist/automerge-worker.js +505 -0
- package/dist/externals.js +0 -1
- package/dist/service-worker.js +98 -205
- package/dist/setup.d.ts +1 -0
- package/dist/setup.js +89 -106
- package/dist/site.d.ts +12 -7
- package/dist/site.js +61 -53
- package/dist/sync-config.d.ts +8 -0
- package/dist/sync-config.js +13 -0
- package/dist/types.d.ts +90 -0
- package/dist/types.js +7 -1
- package/dist/vite/importmap-plugin.js +9 -3
- package/dist/vite/service-worker-plugin.js +25 -9
- package/package.json +20 -19
- package/src/automerge-worker.ts +647 -0
- package/src/externals.ts +0 -1
- package/src/service-worker.ts +123 -248
- package/src/setup.ts +105 -118
- package/src/site.ts +89 -65
- package/src/sync-config.ts +23 -0
- package/src/types.ts +98 -0
- package/src/vite/importmap-plugin.ts +13 -3
- package/src/vite/service-worker-plugin.ts +26 -11
- package/tsconfig.json +1 -1
- package/dist/sw-logger.d.ts +0 -105
- package/dist/sw-logger.js +0 -366
- package/src/sw-logger.ts +0 -463
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
|
|
49
|
-
const
|
|
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(
|
|
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
|
-
|
|
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
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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 &&
|
|
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
package/dist/sw-logger.d.ts
DELETED
|
@@ -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
|
-
}
|