@inkandswitch/patchwork-bootloader 0.3.0 → 0.3.2
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 +20 -0
- package/dist/automerge-worker.js +468 -9
- package/dist/externals.js +1 -2
- package/dist/module-loader-worker.d.ts +1 -0
- package/dist/module-loader-worker.js +55 -0
- package/dist/module-loader.d.ts +13 -0
- package/dist/module-loader.js +72 -0
- package/dist/service-worker.js +58 -6
- package/dist/setup.d.ts +6 -1
- package/dist/setup.js +177 -2
- package/dist/site.d.ts +7 -7
- package/dist/site.js +194 -71
- package/dist/types.d.ts +72 -0
- package/dist/types.js +6 -0
- package/dist/vite/service-worker-plugin.js +4 -0
- package/package.json +18 -14
- package/src/automerge-worker.ts +512 -9
- package/src/externals.ts +1 -2
- package/src/module-loader-worker.ts +68 -0
- package/src/module-loader.ts +85 -0
- package/src/service-worker.ts +78 -6
- package/src/setup.ts +194 -7
- package/src/site.ts +230 -92
- package/src/types.ts +97 -0
- package/src/vite/service-worker-plugin.ts +4 -0
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// Dedicated module worker for plugin-descriptor discovery.
|
|
2
|
+
//
|
|
3
|
+
// A module-settings doc lists Automerge folder-doc packages. To register the
|
|
4
|
+
// plugins a package provides we only need their *descriptions* (id, type,
|
|
5
|
+
// name, icon…), not their implementations. This worker imports a package's
|
|
6
|
+
// entry point off the main thread purely to read its exported `plugins` array,
|
|
7
|
+
// strips the non-cloneable `load()` / `import` machinery, and posts the plain
|
|
8
|
+
// descriptors back. The main thread re-imports the package (at the same heads)
|
|
9
|
+
// only when a plugin is actually loaded — see `importPluginFromFolderDocUrl`.
|
|
10
|
+
//
|
|
11
|
+
// Created with type:"module"; its dynamic `import()` of `/<automergeUrl>/…`
|
|
12
|
+
// entry points is served by the service worker that controls this worker.
|
|
13
|
+
|
|
14
|
+
import { importModuleFromFolderDocUrl } from "@inkandswitch/patchwork-filesystem";
|
|
15
|
+
import type { AutomergeUrl } from "@automerge/automerge-repo/slim";
|
|
16
|
+
|
|
17
|
+
type DiscoverRequest = {
|
|
18
|
+
type: "discover";
|
|
19
|
+
id: number;
|
|
20
|
+
url: AutomergeUrl;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
// Keep only the structured-cloneable description fields. `load` is a closure
|
|
24
|
+
// and `module` is the (possibly already-loaded) implementation — neither can
|
|
25
|
+
// cross the worker boundary. `import` is droppable too: the main thread
|
|
26
|
+
// rebuilds loading by re-importing the package and calling the live plugin.
|
|
27
|
+
function toDescriptor(plugin: any): Record<string, unknown> {
|
|
28
|
+
if (!plugin || typeof plugin !== "object") return {};
|
|
29
|
+
const { load, import: _import, module, ...description } = plugin;
|
|
30
|
+
return description;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function isDiscoverRequest(data: unknown): data is DiscoverRequest {
|
|
34
|
+
return (
|
|
35
|
+
typeof data === "object" &&
|
|
36
|
+
data !== null &&
|
|
37
|
+
(data as any).type === "discover" &&
|
|
38
|
+
typeof (data as any).id === "number" &&
|
|
39
|
+
typeof (data as any).url === "string"
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
self.addEventListener("message", (event: MessageEvent) => {
|
|
44
|
+
const data = event.data;
|
|
45
|
+
if (!isDiscoverRequest(data)) return;
|
|
46
|
+
const { id, url } = data;
|
|
47
|
+
|
|
48
|
+
importModuleFromFolderDocUrl(url)
|
|
49
|
+
.then((mod) => {
|
|
50
|
+
const plugins: any[] = Array.isArray(mod?.plugins) ? mod.plugins : [];
|
|
51
|
+
const descriptors = plugins.map(toDescriptor);
|
|
52
|
+
(self as unknown as Worker).postMessage({
|
|
53
|
+
type: "descriptors",
|
|
54
|
+
id,
|
|
55
|
+
descriptors,
|
|
56
|
+
});
|
|
57
|
+
})
|
|
58
|
+
.catch((error) => {
|
|
59
|
+
(self as unknown as Worker).postMessage({
|
|
60
|
+
type: "error",
|
|
61
|
+
id,
|
|
62
|
+
error:
|
|
63
|
+
error instanceof Error
|
|
64
|
+
? (error.stack ?? error.message)
|
|
65
|
+
: String(error),
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// Main-thread client for the module-loader worker (see module-loader-worker.ts).
|
|
2
|
+
//
|
|
3
|
+
// `importAutomergeModuleViaWorker` is wired into the ModuleWatcher in place of
|
|
4
|
+
// its default (direct, main-thread) package import. It asks the worker to
|
|
5
|
+
// import the package entry point and report which plugins it exports, then
|
|
6
|
+
// returns the same `{ plugins }` shape the watcher already feeds to
|
|
7
|
+
// `registerPlugins` — except each plugin's `load()` re-imports the package
|
|
8
|
+
// (pinned to the same heads) on this thread and runs the real plugin loader.
|
|
9
|
+
|
|
10
|
+
import { importPluginFromFolderDocUrl } from "@inkandswitch/patchwork-filesystem";
|
|
11
|
+
import type { AutomergeUrl } from "@automerge/automerge-repo/slim";
|
|
12
|
+
|
|
13
|
+
type Descriptor = Record<string, unknown> & { id?: string; type?: string };
|
|
14
|
+
|
|
15
|
+
type WorkerReply =
|
|
16
|
+
| { type: "descriptors"; id: number; descriptors: Descriptor[] }
|
|
17
|
+
| { type: "error"; id: number; error: string };
|
|
18
|
+
|
|
19
|
+
const WORKER_PATH = "/module-loader-worker.js";
|
|
20
|
+
|
|
21
|
+
let worker: Worker | undefined;
|
|
22
|
+
let nextRequestId = 1;
|
|
23
|
+
const pending = new Map<
|
|
24
|
+
number,
|
|
25
|
+
{ resolve: (d: Descriptor[]) => void; reject: (e: Error) => void }
|
|
26
|
+
>();
|
|
27
|
+
|
|
28
|
+
function getWorker(): Worker {
|
|
29
|
+
if (worker) return worker;
|
|
30
|
+
worker = new Worker(WORKER_PATH, {
|
|
31
|
+
type: "module",
|
|
32
|
+
name: "patchwork-module-loader",
|
|
33
|
+
});
|
|
34
|
+
worker.addEventListener("message", (event: MessageEvent<WorkerReply>) => {
|
|
35
|
+
const data = event.data;
|
|
36
|
+
if (!data || (data.type !== "descriptors" && data.type !== "error")) return;
|
|
37
|
+
const entry = pending.get(data.id);
|
|
38
|
+
if (!entry) return;
|
|
39
|
+
pending.delete(data.id);
|
|
40
|
+
if (data.type === "descriptors") entry.resolve(data.descriptors);
|
|
41
|
+
else entry.reject(new Error(data.error));
|
|
42
|
+
});
|
|
43
|
+
worker.addEventListener("error", (event) => {
|
|
44
|
+
// An uncaught worker error can't be tied to a single request — fail every
|
|
45
|
+
// outstanding one so callers don't hang.
|
|
46
|
+
const error = new Error(
|
|
47
|
+
`module-loader worker error: ${event.message ?? "unknown"}`
|
|
48
|
+
);
|
|
49
|
+
for (const [, entry] of pending) entry.reject(error);
|
|
50
|
+
pending.clear();
|
|
51
|
+
});
|
|
52
|
+
return worker;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Ask the worker which plugins the package at `urlAtHeads` exports. */
|
|
56
|
+
function discoverDescriptors(urlAtHeads: AutomergeUrl): Promise<Descriptor[]> {
|
|
57
|
+
const id = nextRequestId++;
|
|
58
|
+
return new Promise<Descriptor[]>((resolve, reject) => {
|
|
59
|
+
pending.set(id, { resolve, reject });
|
|
60
|
+
getWorker().postMessage({ type: "discover", id, url: urlAtHeads });
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* ModuleWatcher `importAutomergeModule` hook: discover descriptors in the
|
|
66
|
+
* worker, then return the `{ plugins }` shape with a main-thread `load()` per
|
|
67
|
+
* plugin that imports the package at heads and calls its real loader.
|
|
68
|
+
*/
|
|
69
|
+
export async function importAutomergeModuleViaWorker(
|
|
70
|
+
urlAtHeads: string
|
|
71
|
+
): Promise<{ plugins: Descriptor[] }> {
|
|
72
|
+
const url = urlAtHeads as AutomergeUrl;
|
|
73
|
+
const descriptors = await discoverDescriptors(url);
|
|
74
|
+
const plugins = descriptors.map((descriptor) => {
|
|
75
|
+
const { id, type } = descriptor;
|
|
76
|
+
// A plugin id is only unique within a plugin type, so both are needed to
|
|
77
|
+
// re-select the right plugin when its load() re-imports the package.
|
|
78
|
+
if (typeof id !== "string" || typeof type !== "string") return descriptor;
|
|
79
|
+
return {
|
|
80
|
+
...descriptor,
|
|
81
|
+
load: () => importPluginFromFolderDocUrl(url, type, id),
|
|
82
|
+
};
|
|
83
|
+
});
|
|
84
|
+
return { plugins };
|
|
85
|
+
}
|
package/src/service-worker.ts
CHANGED
|
@@ -33,7 +33,49 @@ function log(...args: any[]) {
|
|
|
33
33
|
);
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
// ── Lifecycle diagnostics ──────────────────────────────────────────────
|
|
37
|
+
// [lifecycle] markers for SW (re)boots, install/activate, crashes, and stranded
|
|
38
|
+
// handoffs. The SW can't read localStorage, so it always emits and forwards to
|
|
39
|
+
// the tab, which gates rendering on the live toggle. The SW holds no sync
|
|
40
|
+
// socket — observability only.
|
|
41
|
+
|
|
42
|
+
async function postToClients(message: unknown) {
|
|
43
|
+
const clients = await self.clients.matchAll({
|
|
44
|
+
type: "window",
|
|
45
|
+
includeUncontrolled: true,
|
|
46
|
+
});
|
|
47
|
+
for (const client of clients) client.postMessage(message);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function lifecycle(level: "info" | "warn", text: string) {
|
|
51
|
+
const msg = `[lifecycle] ${new Date().toISOString()} ${text}`;
|
|
52
|
+
console[level](msg);
|
|
53
|
+
void postToClients({ type: "sw-lifecycle", level, msg });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
lifecycle("info", `booted (scope ${self.registration?.scope ?? "?"})`);
|
|
57
|
+
|
|
58
|
+
self.addEventListener("error", (event) => {
|
|
59
|
+
const e = event as ErrorEvent;
|
|
60
|
+
lifecycle(
|
|
61
|
+
"warn",
|
|
62
|
+
`uncaught error: ${e.message}` +
|
|
63
|
+
(e.filename ? ` @ ${e.filename}:${e.lineno}:${e.colno}` : "")
|
|
64
|
+
);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
self.addEventListener("unhandledrejection", (event) => {
|
|
68
|
+
const reason = (event as PromiseRejectionEvent).reason;
|
|
69
|
+
lifecycle(
|
|
70
|
+
"warn",
|
|
71
|
+
`unhandled rejection: ${
|
|
72
|
+
reason instanceof Error ? reason.stack || reason.message : String(reason)
|
|
73
|
+
}`
|
|
74
|
+
);
|
|
75
|
+
});
|
|
76
|
+
|
|
36
77
|
self.addEventListener("install", (event) => {
|
|
78
|
+
lifecycle("info", "install (skipWaiting)");
|
|
37
79
|
// waitUntil keeps the worker alive until skipWaiting resolves, so a freshly
|
|
38
80
|
// installed SW reliably jumps the "waiting" queue instead of stalling until
|
|
39
81
|
// every old tab closes.
|
|
@@ -52,13 +94,30 @@ async function clearOldCaches() {
|
|
|
52
94
|
}
|
|
53
95
|
|
|
54
96
|
self.addEventListener("activate", (event) => {
|
|
55
|
-
|
|
56
|
-
// runs detached — the new worker can be killed before it takes control, so
|
|
57
|
-
// existing tabs keep talking to the old SW. Extend the event instead.
|
|
97
|
+
lifecycle("info", "activate (claiming clients)");
|
|
58
98
|
(event as ExtendableEvent).waitUntil(
|
|
59
99
|
(async () => {
|
|
60
100
|
await clearOldCaches();
|
|
61
101
|
await self.clients.claim();
|
|
102
|
+
// Pre-cache pages of already-open clients so they survive going offline
|
|
103
|
+
// before the next navigation.
|
|
104
|
+
const allClients = await self.clients.matchAll({ type: "window" });
|
|
105
|
+
const cache = await caches.open(cachename);
|
|
106
|
+
await Promise.all(
|
|
107
|
+
allClients.map(async (client) => {
|
|
108
|
+
try {
|
|
109
|
+
const existing = await cache.match(client.url);
|
|
110
|
+
if (!existing) {
|
|
111
|
+
const response = await fetch(client.url);
|
|
112
|
+
if (cacheableStatuses.includes(response.status)) {
|
|
113
|
+
await cache.put(client.url, response);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
} catch {
|
|
117
|
+
// Network may be unavailable during activation
|
|
118
|
+
}
|
|
119
|
+
})
|
|
120
|
+
);
|
|
62
121
|
})()
|
|
63
122
|
);
|
|
64
123
|
});
|
|
@@ -102,7 +161,15 @@ handoffChannel.addEventListener("message", (event) => {
|
|
|
102
161
|
} else if (data?.type === "online") {
|
|
103
162
|
// The automerge worker (re)started — re-broadcast anything still in
|
|
104
163
|
// flight so requests that raced its boot aren't stranded.
|
|
105
|
-
|
|
164
|
+
const stranded = [...pendingHandoffs.values()];
|
|
165
|
+
if (stranded.length > 0) {
|
|
166
|
+
lifecycle(
|
|
167
|
+
"info",
|
|
168
|
+
`automerge worker (re)started; re-broadcasting ${stranded.length} ` +
|
|
169
|
+
`in-flight asset handoff(s)`
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
for (const { message } of stranded) {
|
|
106
173
|
log(`re-broadcasting handoff ${message.id} to the fresh worker`);
|
|
107
174
|
handoffChannel.postMessage(message);
|
|
108
175
|
}
|
|
@@ -132,6 +199,11 @@ function handoff(
|
|
|
132
199
|
log(`broadcasting handoff request for cache ${cachename}`, message);
|
|
133
200
|
handoffChannel.postMessage(message);
|
|
134
201
|
const timeout = setTimeout(() => {
|
|
202
|
+
lifecycle(
|
|
203
|
+
"warn",
|
|
204
|
+
`asset handoff ${id} stranded: no reply from the automerge worker after ` +
|
|
205
|
+
`${HANDOFF_TIMEOUT_MS}ms (${handoffURL.href})`
|
|
206
|
+
);
|
|
135
207
|
resolvers.reject(
|
|
136
208
|
new Error(
|
|
137
209
|
`no reply from the automerge worker after ${HANDOFF_TIMEOUT_MS}ms`
|
|
@@ -208,7 +280,7 @@ self.addEventListener("fetch", (fetchEvent: FetchEvent) => {
|
|
|
208
280
|
if (!cached) {
|
|
209
281
|
return new Response(
|
|
210
282
|
`the automerge worker reported ${handoffURL} cached, but it has no match in ${cachename}`,
|
|
211
|
-
{ status:
|
|
283
|
+
{ status: 555 }
|
|
212
284
|
);
|
|
213
285
|
}
|
|
214
286
|
log(`serving ${handoffURL} from cache ${cachename} after handoff`);
|
|
@@ -245,7 +317,7 @@ self.addEventListener("fetch", (fetchEvent: FetchEvent) => {
|
|
|
245
317
|
if (match) return match;
|
|
246
318
|
|
|
247
319
|
return new Response(message, {
|
|
248
|
-
status:
|
|
320
|
+
status: 556,
|
|
249
321
|
headers: { "content-type": "text/plain" },
|
|
250
322
|
});
|
|
251
323
|
}
|
package/src/setup.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type {
|
|
|
2
2
|
ServiceWorkerRepoChannelListener,
|
|
3
3
|
SetupServiceWorkerOptions,
|
|
4
4
|
SetupServiceWorkerResult,
|
|
5
|
+
SyncStateDocMessage,
|
|
5
6
|
} from "./types.js";
|
|
6
7
|
import {
|
|
7
8
|
readClassicSyncServer,
|
|
@@ -12,6 +13,34 @@ import debug from "debug";
|
|
|
12
13
|
const serviceWorkerDebugging = debug.enabled("patchwork:serviceworker");
|
|
13
14
|
const workerDebugging = debug.enabled("patchwork:automergeworker");
|
|
14
15
|
|
|
16
|
+
// Diagnostic [lifecycle] logging, on by default. Disable via
|
|
17
|
+
// localStorage["patchwork:lifecycle-logs"] = "off". Read live at log time.
|
|
18
|
+
const LIFECYCLE_LOG_KEY = "patchwork:lifecycle-logs";
|
|
19
|
+
export function lifecycleLoggingEnabled(): boolean {
|
|
20
|
+
try {
|
|
21
|
+
const v = globalThis.localStorage?.getItem(LIFECYCLE_LOG_KEY);
|
|
22
|
+
return v !== "off" && v !== "false" && v !== "0" && v !== "no";
|
|
23
|
+
} catch {
|
|
24
|
+
return true;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// The SW can't read localStorage, so it always emits [lifecycle] markers and
|
|
29
|
+
// forwards them as `sw-lifecycle`; gate rendering here on the live toggle.
|
|
30
|
+
let swLifecycleListenerInstalled = false;
|
|
31
|
+
function installServiceWorkerLogForwarding(): void {
|
|
32
|
+
if (swLifecycleListenerInstalled) return;
|
|
33
|
+
if (typeof navigator === "undefined" || !navigator.serviceWorker) return;
|
|
34
|
+
swLifecycleListenerInstalled = true;
|
|
35
|
+
navigator.serviceWorker.addEventListener("message", (event: MessageEvent) => {
|
|
36
|
+
const data = event.data;
|
|
37
|
+
if (data?.type !== "sw-lifecycle") return;
|
|
38
|
+
if (!lifecycleLoggingEnabled()) return;
|
|
39
|
+
const fn = (console as any)[data.level] ?? console.log;
|
|
40
|
+
fn(`[service-worker] ${data.msg}`);
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
15
44
|
const key = "patchworkServiceWorkerCacheVersion";
|
|
16
45
|
let nextRepoChannelId = 0;
|
|
17
46
|
|
|
@@ -67,7 +96,7 @@ function configureServiceWorker(sw: ServiceWorker | null) {
|
|
|
67
96
|
let automergeWorkerPath = "/automerge-worker.js";
|
|
68
97
|
let automergeWorker: SharedWorker | undefined;
|
|
69
98
|
|
|
70
|
-
function getAutomergeWorker(): SharedWorker {
|
|
99
|
+
export function getAutomergeWorker(): SharedWorker {
|
|
71
100
|
if (!automergeWorker) {
|
|
72
101
|
automergeWorker = new SharedWorker(automergeWorkerPath, {
|
|
73
102
|
name: "patchwork-automerge",
|
|
@@ -76,11 +105,158 @@ function getAutomergeWorker(): SharedWorker {
|
|
|
76
105
|
// Control replies (port-ready &c) come back on this port, so it needs
|
|
77
106
|
// start() — we listen with addEventListener, not onmessage.
|
|
78
107
|
automergeWorker.port.start();
|
|
108
|
+
// Surface the SharedWorker's console output and uncaught errors in this
|
|
109
|
+
// tab's console (it has its own console that's awkward to find otherwise).
|
|
110
|
+
automergeWorker.port.addEventListener("message", (event: MessageEvent) => {
|
|
111
|
+
if (event.data?.type === "sync-state") {
|
|
112
|
+
dispatchSyncState(event.data as SyncStateDocMessage);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
if (event.data?.type !== "console") return;
|
|
116
|
+
const { level, args } = event.data;
|
|
117
|
+
// Gate forwarded [lifecycle] logs on the toggle too.
|
|
118
|
+
if (
|
|
119
|
+
!lifecycleLoggingEnabled() &&
|
|
120
|
+
typeof args?.[0] === "string" &&
|
|
121
|
+
args[0].includes("[lifecycle]")
|
|
122
|
+
) {
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
const fn = (console as any)[level] ?? console.log;
|
|
126
|
+
// The worker's logs (debug library, the worker's own log()) carry %c
|
|
127
|
+
// format directives in args[0] with CSS in the following args. Prefix
|
|
128
|
+
// the tag into the format string rather than as a separate positional,
|
|
129
|
+
// or the %c would no longer be in arg 0 and the CSS would print raw.
|
|
130
|
+
if (typeof args[0] === "string") {
|
|
131
|
+
fn(`[automerge-worker] ${args[0]}`, ...args.slice(1));
|
|
132
|
+
} else {
|
|
133
|
+
fn("[automerge-worker]", ...args);
|
|
134
|
+
}
|
|
135
|
+
});
|
|
79
136
|
automergeWorker.port.postMessage({ type: "debug", debug: workerDebugging });
|
|
137
|
+
|
|
138
|
+
installWorkerDeathDetection(automergeWorker);
|
|
80
139
|
}
|
|
81
140
|
return automergeWorker;
|
|
82
141
|
}
|
|
83
142
|
|
|
143
|
+
/**
|
|
144
|
+
* Detect when the automerge SharedWorker dies or restarts: control-port close,
|
|
145
|
+
* worker error, changed instance id, or an unanswered heartbeat while the tab
|
|
146
|
+
* is visible (a miss while hidden is more likely suspension). [lifecycle]-tagged.
|
|
147
|
+
*/
|
|
148
|
+
function installWorkerDeathDetection(worker: SharedWorker): void {
|
|
149
|
+
const stamp = () => new Date().toISOString();
|
|
150
|
+
const warn = (msg: string) => {
|
|
151
|
+
if (lifecycleLoggingEnabled()) console.warn(`[lifecycle] ${stamp()} ${msg}`);
|
|
152
|
+
};
|
|
153
|
+
const info = (msg: string) => {
|
|
154
|
+
if (lifecycleLoggingEnabled()) console.info(`[lifecycle] ${stamp()} ${msg}`);
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
let instanceId: string | undefined;
|
|
158
|
+
let lastPongAt = Date.now();
|
|
159
|
+
let warnedUnresponsive = false;
|
|
160
|
+
|
|
161
|
+
worker.port.addEventListener("message", (event: MessageEvent) => {
|
|
162
|
+
const data = event.data;
|
|
163
|
+
if (data?.type !== "hello" && data?.type !== "pong") return;
|
|
164
|
+
if (data.type === "pong") {
|
|
165
|
+
lastPongAt = Date.now();
|
|
166
|
+
warnedUnresponsive = false;
|
|
167
|
+
}
|
|
168
|
+
if (instanceId === undefined) {
|
|
169
|
+
instanceId = data.instanceId;
|
|
170
|
+
info(`automerge SharedWorker instance ${data.instanceId} (via ${data.type})`);
|
|
171
|
+
} else if (data.instanceId && data.instanceId !== instanceId) {
|
|
172
|
+
warn(
|
|
173
|
+
`automerge SharedWorker RESTARTED (instance ${data.instanceId}, ` +
|
|
174
|
+
`was ${instanceId}) — fresh peerId + cold state; docs need re-subscribe`
|
|
175
|
+
);
|
|
176
|
+
instanceId = data.instanceId;
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
// Fires when the SharedWorker is destroyed (where supported).
|
|
181
|
+
worker.port.addEventListener("close", () => {
|
|
182
|
+
warn("automerge SharedWorker control port CLOSED — worker terminated");
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
worker.addEventListener("error", event => {
|
|
186
|
+
warn(`automerge SharedWorker error: ${(event as ErrorEvent).message || event}`);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
// A missed pong while the tab is visible means the worker likely died (an
|
|
190
|
+
// active tab keeps it alive); a miss while hidden is more likely suspension.
|
|
191
|
+
const HEARTBEAT_MS = 10_000;
|
|
192
|
+
const HEARTBEAT_TIMEOUT_MS = 25_000;
|
|
193
|
+
let seq = 0;
|
|
194
|
+
setInterval(() => {
|
|
195
|
+
try {
|
|
196
|
+
worker.port.postMessage({ type: "ping", id: ++seq });
|
|
197
|
+
} catch {
|
|
198
|
+
// Port already torn down — the "close" handler covers that case.
|
|
199
|
+
}
|
|
200
|
+
const silentMs = Date.now() - lastPongAt;
|
|
201
|
+
const visible =
|
|
202
|
+
typeof document === "undefined" || document.visibilityState === "visible";
|
|
203
|
+
if (silentMs > HEARTBEAT_TIMEOUT_MS && visible && !warnedUnresponsive) {
|
|
204
|
+
warnedUnresponsive = true;
|
|
205
|
+
warn(
|
|
206
|
+
`automerge SharedWorker UNRESPONSIVE ~${Math.round(silentMs / 1000)}s ` +
|
|
207
|
+
`while tab visible — likely died/crashed`
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
}, HEARTBEAT_MS);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ── Sync-state subscriptions ────────────────────────────────────────────
|
|
214
|
+
// The automerge worker pushes per-document heads only to the tabs that ask for
|
|
215
|
+
// them (see SyncStateDocMessage). We ref-count locally so several callers in
|
|
216
|
+
// this tab can watch the same doc with a single worker subscription, and tear
|
|
217
|
+
// the worker subscription down when the last local watcher drops.
|
|
218
|
+
type SyncStateListener = (update: SyncStateDocMessage) => void;
|
|
219
|
+
const syncStateListeners = new Map<string, Set<SyncStateListener>>();
|
|
220
|
+
|
|
221
|
+
function dispatchSyncState(update: SyncStateDocMessage): void {
|
|
222
|
+
const listeners = syncStateListeners.get(update.documentId);
|
|
223
|
+
if (!listeners) return;
|
|
224
|
+
for (const listener of listeners) {
|
|
225
|
+
try {
|
|
226
|
+
listener(update);
|
|
227
|
+
} catch (err) {
|
|
228
|
+
console.error("sync-state listener threw", err);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export function subscribeSyncState(
|
|
234
|
+
documentId: string,
|
|
235
|
+
listener: SyncStateListener
|
|
236
|
+
): () => void {
|
|
237
|
+
const worker = getAutomergeWorker();
|
|
238
|
+
let listeners = syncStateListeners.get(documentId);
|
|
239
|
+
if (!listeners) {
|
|
240
|
+
syncStateListeners.set(documentId, (listeners = new Set()));
|
|
241
|
+
// First local watcher for this doc — ask the worker to start pushing it.
|
|
242
|
+
worker.port.postMessage({ type: "sync-sub", documentId });
|
|
243
|
+
}
|
|
244
|
+
listeners.add(listener);
|
|
245
|
+
|
|
246
|
+
let active = true;
|
|
247
|
+
return () => {
|
|
248
|
+
if (!active) return; // idempotent
|
|
249
|
+
active = false;
|
|
250
|
+
const set = syncStateListeners.get(documentId);
|
|
251
|
+
if (!set) return;
|
|
252
|
+
set.delete(listener);
|
|
253
|
+
if (set.size === 0) {
|
|
254
|
+
syncStateListeners.delete(documentId);
|
|
255
|
+
worker.port.postMessage({ type: "sync-unsub", documentId });
|
|
256
|
+
}
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
84
260
|
export function connectClassicSync(
|
|
85
261
|
server: string = readClassicSyncServer()
|
|
86
262
|
): Promise<void> {
|
|
@@ -104,11 +280,7 @@ export function connectClassicSync(
|
|
|
104
280
|
if (event.data?.type === "connect-classic-sync-ready") {
|
|
105
281
|
resolve();
|
|
106
282
|
} else {
|
|
107
|
-
reject(
|
|
108
|
-
new Error(
|
|
109
|
-
event.data?.error ?? "connect-classic-sync failed"
|
|
110
|
-
)
|
|
111
|
-
);
|
|
283
|
+
reject(new Error(event.data?.error ?? "connect-classic-sync failed"));
|
|
112
284
|
}
|
|
113
285
|
};
|
|
114
286
|
worker.port.postMessage({ type: "connect-classic-sync", server: url }, [
|
|
@@ -188,11 +360,16 @@ function getRepoChannel(): MessagePort {
|
|
|
188
360
|
export default async function setupServiceWorker(
|
|
189
361
|
options?: SetupServiceWorkerOptions
|
|
190
362
|
): Promise<SetupServiceWorkerResult> {
|
|
363
|
+
// Attach the SW→tab [lifecycle] log bridge as early as possible so boot /
|
|
364
|
+
// install / activate markers from the controlling worker are rendered here.
|
|
365
|
+
installServiceWorkerLogForwarding();
|
|
366
|
+
|
|
191
367
|
if (options?.workerPath) automergeWorkerPath = options.workerPath;
|
|
192
368
|
|
|
193
369
|
// Start the automerge worker right away so it boots (wasm, repo) while the
|
|
194
370
|
// service worker installs.
|
|
195
|
-
getAutomergeWorker();
|
|
371
|
+
const shared = getAutomergeWorker();
|
|
372
|
+
// todo delete
|
|
196
373
|
|
|
197
374
|
const path = options?.path ?? "/service-worker.js";
|
|
198
375
|
// No controller at this point means the page loaded without a service
|
|
@@ -230,9 +407,19 @@ export default async function setupServiceWorker(
|
|
|
230
407
|
"background: #fcf2f0; color: #333; border: 2px solid; border-radius: 4px"
|
|
231
408
|
);
|
|
232
409
|
|
|
410
|
+
// todon't
|
|
411
|
+
(window as any).killsw = () => {
|
|
412
|
+
if (automergeWorker) {
|
|
413
|
+
automergeWorker.port.close();
|
|
414
|
+
automergeWorker = undefined;
|
|
415
|
+
}
|
|
416
|
+
};
|
|
417
|
+
|
|
233
418
|
return {
|
|
419
|
+
shared,
|
|
234
420
|
connectClassicSync,
|
|
235
421
|
getRepoChannel,
|
|
422
|
+
subscribeSyncState,
|
|
236
423
|
async subscribeToRepoChannel(listener: ServiceWorkerRepoChannelListener) {
|
|
237
424
|
// The automerge worker outlives the page, so unlike the old in-service-
|
|
238
425
|
// worker repo there's nothing to reconnect: one port, handed over once.
|