@inkandswitch/patchwork-bootloader 0.3.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -0
- package/dist/automerge-worker.js +124 -8
- 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 +2 -0
- package/dist/setup.js +134 -2
- package/dist/site.d.ts +1 -6
- package/dist/site.js +94 -42
- package/dist/types.d.ts +8 -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 +135 -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 +141 -7
- package/src/site.ts +116 -62
- package/src/types.ts +9 -0
- package/src/vite/service-worker-plugin.ts +4 -0
|
@@ -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
|
@@ -12,6 +12,34 @@ import debug from "debug";
|
|
|
12
12
|
const serviceWorkerDebugging = debug.enabled("patchwork:serviceworker");
|
|
13
13
|
const workerDebugging = debug.enabled("patchwork:automergeworker");
|
|
14
14
|
|
|
15
|
+
// Diagnostic [lifecycle] logging, on by default. Disable via
|
|
16
|
+
// localStorage["patchwork:lifecycle-logs"] = "off". Read live at log time.
|
|
17
|
+
const LIFECYCLE_LOG_KEY = "patchwork:lifecycle-logs";
|
|
18
|
+
export function lifecycleLoggingEnabled(): boolean {
|
|
19
|
+
try {
|
|
20
|
+
const v = globalThis.localStorage?.getItem(LIFECYCLE_LOG_KEY);
|
|
21
|
+
return v !== "off" && v !== "false" && v !== "0" && v !== "no";
|
|
22
|
+
} catch {
|
|
23
|
+
return true;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// The SW can't read localStorage, so it always emits [lifecycle] markers and
|
|
28
|
+
// forwards them as `sw-lifecycle`; gate rendering here on the live toggle.
|
|
29
|
+
let swLifecycleListenerInstalled = false;
|
|
30
|
+
function installServiceWorkerLogForwarding(): void {
|
|
31
|
+
if (swLifecycleListenerInstalled) return;
|
|
32
|
+
if (typeof navigator === "undefined" || !navigator.serviceWorker) return;
|
|
33
|
+
swLifecycleListenerInstalled = true;
|
|
34
|
+
navigator.serviceWorker.addEventListener("message", (event: MessageEvent) => {
|
|
35
|
+
const data = event.data;
|
|
36
|
+
if (data?.type !== "sw-lifecycle") return;
|
|
37
|
+
if (!lifecycleLoggingEnabled()) return;
|
|
38
|
+
const fn = (console as any)[data.level] ?? console.log;
|
|
39
|
+
fn(`[service-worker] ${data.msg}`);
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
15
43
|
const key = "patchworkServiceWorkerCacheVersion";
|
|
16
44
|
let nextRepoChannelId = 0;
|
|
17
45
|
|
|
@@ -67,7 +95,7 @@ function configureServiceWorker(sw: ServiceWorker | null) {
|
|
|
67
95
|
let automergeWorkerPath = "/automerge-worker.js";
|
|
68
96
|
let automergeWorker: SharedWorker | undefined;
|
|
69
97
|
|
|
70
|
-
function getAutomergeWorker(): SharedWorker {
|
|
98
|
+
export function getAutomergeWorker(): SharedWorker {
|
|
71
99
|
if (!automergeWorker) {
|
|
72
100
|
automergeWorker = new SharedWorker(automergeWorkerPath, {
|
|
73
101
|
name: "patchwork-automerge",
|
|
@@ -76,11 +104,107 @@ function getAutomergeWorker(): SharedWorker {
|
|
|
76
104
|
// Control replies (port-ready &c) come back on this port, so it needs
|
|
77
105
|
// start() — we listen with addEventListener, not onmessage.
|
|
78
106
|
automergeWorker.port.start();
|
|
107
|
+
// Surface the SharedWorker's console output and uncaught errors in this
|
|
108
|
+
// tab's console (it has its own console that's awkward to find otherwise).
|
|
109
|
+
automergeWorker.port.addEventListener("message", (event: MessageEvent) => {
|
|
110
|
+
if (event.data?.type !== "console") return;
|
|
111
|
+
const { level, args } = event.data;
|
|
112
|
+
// Gate forwarded [lifecycle] logs on the toggle too.
|
|
113
|
+
if (
|
|
114
|
+
!lifecycleLoggingEnabled() &&
|
|
115
|
+
typeof args?.[0] === "string" &&
|
|
116
|
+
args[0].includes("[lifecycle]")
|
|
117
|
+
) {
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
const fn = (console as any)[level] ?? console.log;
|
|
121
|
+
// The worker's logs (debug library, the worker's own log()) carry %c
|
|
122
|
+
// format directives in args[0] with CSS in the following args. Prefix
|
|
123
|
+
// the tag into the format string rather than as a separate positional,
|
|
124
|
+
// or the %c would no longer be in arg 0 and the CSS would print raw.
|
|
125
|
+
if (typeof args[0] === "string") {
|
|
126
|
+
fn(`[automerge-worker] ${args[0]}`, ...args.slice(1));
|
|
127
|
+
} else {
|
|
128
|
+
fn("[automerge-worker]", ...args);
|
|
129
|
+
}
|
|
130
|
+
});
|
|
79
131
|
automergeWorker.port.postMessage({ type: "debug", debug: workerDebugging });
|
|
132
|
+
|
|
133
|
+
installWorkerDeathDetection(automergeWorker);
|
|
80
134
|
}
|
|
81
135
|
return automergeWorker;
|
|
82
136
|
}
|
|
83
137
|
|
|
138
|
+
/**
|
|
139
|
+
* Detect when the automerge SharedWorker dies or restarts: control-port close,
|
|
140
|
+
* worker error, changed instance id, or an unanswered heartbeat while the tab
|
|
141
|
+
* is visible (a miss while hidden is more likely suspension). [lifecycle]-tagged.
|
|
142
|
+
*/
|
|
143
|
+
function installWorkerDeathDetection(worker: SharedWorker): void {
|
|
144
|
+
const stamp = () => new Date().toISOString();
|
|
145
|
+
const warn = (msg: string) => {
|
|
146
|
+
if (lifecycleLoggingEnabled()) console.warn(`[lifecycle] ${stamp()} ${msg}`);
|
|
147
|
+
};
|
|
148
|
+
const info = (msg: string) => {
|
|
149
|
+
if (lifecycleLoggingEnabled()) console.info(`[lifecycle] ${stamp()} ${msg}`);
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
let instanceId: string | undefined;
|
|
153
|
+
let lastPongAt = Date.now();
|
|
154
|
+
let warnedUnresponsive = false;
|
|
155
|
+
|
|
156
|
+
worker.port.addEventListener("message", (event: MessageEvent) => {
|
|
157
|
+
const data = event.data;
|
|
158
|
+
if (data?.type !== "hello" && data?.type !== "pong") return;
|
|
159
|
+
if (data.type === "pong") {
|
|
160
|
+
lastPongAt = Date.now();
|
|
161
|
+
warnedUnresponsive = false;
|
|
162
|
+
}
|
|
163
|
+
if (instanceId === undefined) {
|
|
164
|
+
instanceId = data.instanceId;
|
|
165
|
+
info(`automerge SharedWorker instance ${data.instanceId} (via ${data.type})`);
|
|
166
|
+
} else if (data.instanceId && data.instanceId !== instanceId) {
|
|
167
|
+
warn(
|
|
168
|
+
`automerge SharedWorker RESTARTED (instance ${data.instanceId}, ` +
|
|
169
|
+
`was ${instanceId}) — fresh peerId + cold state; docs need re-subscribe`
|
|
170
|
+
);
|
|
171
|
+
instanceId = data.instanceId;
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
// Fires when the SharedWorker is destroyed (where supported).
|
|
176
|
+
worker.port.addEventListener("close", () => {
|
|
177
|
+
warn("automerge SharedWorker control port CLOSED — worker terminated");
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
worker.addEventListener("error", event => {
|
|
181
|
+
warn(`automerge SharedWorker error: ${(event as ErrorEvent).message || event}`);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
// A missed pong while the tab is visible means the worker likely died (an
|
|
185
|
+
// active tab keeps it alive); a miss while hidden is more likely suspension.
|
|
186
|
+
const HEARTBEAT_MS = 10_000;
|
|
187
|
+
const HEARTBEAT_TIMEOUT_MS = 25_000;
|
|
188
|
+
let seq = 0;
|
|
189
|
+
setInterval(() => {
|
|
190
|
+
try {
|
|
191
|
+
worker.port.postMessage({ type: "ping", id: ++seq });
|
|
192
|
+
} catch {
|
|
193
|
+
// Port already torn down — the "close" handler covers that case.
|
|
194
|
+
}
|
|
195
|
+
const silentMs = Date.now() - lastPongAt;
|
|
196
|
+
const visible =
|
|
197
|
+
typeof document === "undefined" || document.visibilityState === "visible";
|
|
198
|
+
if (silentMs > HEARTBEAT_TIMEOUT_MS && visible && !warnedUnresponsive) {
|
|
199
|
+
warnedUnresponsive = true;
|
|
200
|
+
warn(
|
|
201
|
+
`automerge SharedWorker UNRESPONSIVE ~${Math.round(silentMs / 1000)}s ` +
|
|
202
|
+
`while tab visible — likely died/crashed`
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
}, HEARTBEAT_MS);
|
|
206
|
+
}
|
|
207
|
+
|
|
84
208
|
export function connectClassicSync(
|
|
85
209
|
server: string = readClassicSyncServer()
|
|
86
210
|
): Promise<void> {
|
|
@@ -104,11 +228,7 @@ export function connectClassicSync(
|
|
|
104
228
|
if (event.data?.type === "connect-classic-sync-ready") {
|
|
105
229
|
resolve();
|
|
106
230
|
} else {
|
|
107
|
-
reject(
|
|
108
|
-
new Error(
|
|
109
|
-
event.data?.error ?? "connect-classic-sync failed"
|
|
110
|
-
)
|
|
111
|
-
);
|
|
231
|
+
reject(new Error(event.data?.error ?? "connect-classic-sync failed"));
|
|
112
232
|
}
|
|
113
233
|
};
|
|
114
234
|
worker.port.postMessage({ type: "connect-classic-sync", server: url }, [
|
|
@@ -188,11 +308,16 @@ function getRepoChannel(): MessagePort {
|
|
|
188
308
|
export default async function setupServiceWorker(
|
|
189
309
|
options?: SetupServiceWorkerOptions
|
|
190
310
|
): Promise<SetupServiceWorkerResult> {
|
|
311
|
+
// Attach the SW→tab [lifecycle] log bridge as early as possible so boot /
|
|
312
|
+
// install / activate markers from the controlling worker are rendered here.
|
|
313
|
+
installServiceWorkerLogForwarding();
|
|
314
|
+
|
|
191
315
|
if (options?.workerPath) automergeWorkerPath = options.workerPath;
|
|
192
316
|
|
|
193
317
|
// Start the automerge worker right away so it boots (wasm, repo) while the
|
|
194
318
|
// service worker installs.
|
|
195
|
-
getAutomergeWorker();
|
|
319
|
+
const shared = getAutomergeWorker();
|
|
320
|
+
// todo delete
|
|
196
321
|
|
|
197
322
|
const path = options?.path ?? "/service-worker.js";
|
|
198
323
|
// No controller at this point means the page loaded without a service
|
|
@@ -230,7 +355,16 @@ export default async function setupServiceWorker(
|
|
|
230
355
|
"background: #fcf2f0; color: #333; border: 2px solid; border-radius: 4px"
|
|
231
356
|
);
|
|
232
357
|
|
|
358
|
+
// todon't
|
|
359
|
+
(window as any).killsw = () => {
|
|
360
|
+
if (automergeWorker) {
|
|
361
|
+
automergeWorker.port.close();
|
|
362
|
+
automergeWorker = undefined;
|
|
363
|
+
}
|
|
364
|
+
};
|
|
365
|
+
|
|
233
366
|
return {
|
|
367
|
+
shared,
|
|
234
368
|
connectClassicSync,
|
|
235
369
|
getRepoChannel,
|
|
236
370
|
async subscribeToRepoChannel(listener: ServiceWorkerRepoChannelListener) {
|
package/src/site.ts
CHANGED
|
@@ -13,7 +13,6 @@
|
|
|
13
13
|
*/
|
|
14
14
|
import {
|
|
15
15
|
type DocHandle,
|
|
16
|
-
IndexedDBStorageAdapter,
|
|
17
16
|
initializeWasm,
|
|
18
17
|
isValidAutomergeUrl,
|
|
19
18
|
isValidDocumentId,
|
|
@@ -23,9 +22,9 @@ import {
|
|
|
23
22
|
stringifyAutomergeUrl,
|
|
24
23
|
type AutomergeUrl,
|
|
25
24
|
type DocumentId,
|
|
26
|
-
type StorageId,
|
|
27
25
|
type UrlHeads,
|
|
28
26
|
} from "@automerge/vanillajs/slim";
|
|
27
|
+
import { IndexedDBWorkerStorageAdapter } from "@automerge/automerge-repo-storage-indexeddb/IndexedDBWorkerStorageAdapter";
|
|
29
28
|
import * as Automerge from "@automerge/automerge/slim";
|
|
30
29
|
import * as AutomergeRepo from "@automerge/automerge-repo/slim";
|
|
31
30
|
import {
|
|
@@ -50,6 +49,7 @@ const useKeyhiveSyncServer =
|
|
|
50
49
|
typeof __KEYHIVE_SYNC_SERVER__ !== "undefined" && __KEYHIVE_SYNC_SERVER__;
|
|
51
50
|
|
|
52
51
|
import { ModuleWatcher } from "@inkandswitch/patchwork-filesystem";
|
|
52
|
+
import { importAutomergeModuleViaWorker } from "./module-loader.js";
|
|
53
53
|
import {
|
|
54
54
|
openDocument,
|
|
55
55
|
registerPatchworkViewElement,
|
|
@@ -66,7 +66,10 @@ import {
|
|
|
66
66
|
} from "@inkandswitch/patchwork-plugins";
|
|
67
67
|
import * as plugins from "@inkandswitch/patchwork-plugins";
|
|
68
68
|
|
|
69
|
-
import setupServiceWorker
|
|
69
|
+
import setupServiceWorker, {
|
|
70
|
+
getAutomergeWorker,
|
|
71
|
+
lifecycleLoggingEnabled,
|
|
72
|
+
} from "./setup.js";
|
|
70
73
|
import type { ServiceWorkerRepoChannelListener } from "./types.js";
|
|
71
74
|
import debug from "debug";
|
|
72
75
|
const log = debug("patchwork:bootloader:site");
|
|
@@ -144,12 +147,6 @@ export interface SiteConfig {
|
|
|
144
147
|
*/
|
|
145
148
|
rootElementId?: string;
|
|
146
149
|
|
|
147
|
-
/**
|
|
148
|
-
* Storage IDs to subscribe to for remote-heads gossiping. Defaults to
|
|
149
|
-
* Ink & Switch's production Subduction storage.
|
|
150
|
-
*/
|
|
151
|
-
remoteStorageIds?: StorageId[];
|
|
152
|
-
|
|
153
150
|
/**
|
|
154
151
|
* When true, initialize keyhive for access control.
|
|
155
152
|
* The Repo will use keyhive's network adapter, peerId, and idFactory
|
|
@@ -164,9 +161,6 @@ export interface BootResult {
|
|
|
164
161
|
accountDocHandle: DocHandle<AccountDoc>;
|
|
165
162
|
}
|
|
166
163
|
|
|
167
|
-
const DEFAULT_REMOTE_STORAGE_ID =
|
|
168
|
-
"3760df37-a4c6-4f66-9ecd-732039a9385d" as StorageId;
|
|
169
|
-
|
|
170
164
|
// Legacy big-patchwork hash shape: `slug--<documentId>[?=type]`.
|
|
171
165
|
const BIG_PATCHWORK_HASH_REGEX =
|
|
172
166
|
/(?<title>[A-Za-z0-9-]+)--(?<docId>[1-9A-HJ-NP-Za-km-z]+)(?<type>\?=[^&?]+)?/;
|
|
@@ -191,59 +185,81 @@ export async function bootPatchworkSite(
|
|
|
191
185
|
const defaultModuleSources = resolveDefaultModules(config);
|
|
192
186
|
showLoadingAnimation();
|
|
193
187
|
log(`booting`, config);
|
|
188
|
+
installLifecycleLogging();
|
|
194
189
|
await initializeWasm(automergeWasm);
|
|
195
190
|
initSubductionSync(subductionWasm);
|
|
196
191
|
|
|
192
|
+
log("enabling workers");
|
|
197
193
|
const sw = await setupServiceWorker();
|
|
198
194
|
if (!sw) throw new Error("Failed to set up service worker");
|
|
195
|
+
log("workers ready");
|
|
199
196
|
|
|
200
197
|
let hive: AutomergeRepoKeyhive | undefined;
|
|
201
|
-
// Get the initial automerge-worker port via subscribeToRepoChannel,
|
|
202
|
-
// then pass it to keyhive init which wraps it in its own network adapter.
|
|
203
|
-
let resolvePort!: (port: MessagePort) => void;
|
|
204
|
-
const portPromise = new Promise<MessagePort>((r) => {
|
|
205
|
-
resolvePort = r;
|
|
206
|
-
});
|
|
207
|
-
await sw.subscribeToRepoChannel(resolvePort);
|
|
208
|
-
const workerPort = await portPromise;
|
|
209
|
-
|
|
210
198
|
let repo: Repo;
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
cachingMode: "periodic",
|
|
221
|
-
onlyShareWithHardcodedServerPeerId: false,
|
|
222
|
-
// ARK selects the relay via `syncServer` ("keyhive" | "subduction").
|
|
223
|
-
// Defaults to "subduction".
|
|
224
|
-
...(useKeyhiveSyncServer ? { syncServer: "keyhive" as const } : {}),
|
|
225
|
-
repo: {
|
|
226
|
-
storage: new IndexedDBStorageAdapter(),
|
|
227
|
-
enableRemoteHeadsGossiping: true,
|
|
228
|
-
},
|
|
229
|
-
}));
|
|
199
|
+
|
|
200
|
+
// If a Repo is already on `window` — an embedding context provided one before
|
|
201
|
+
// this entry ran — reuse it and its keyhive instead of standing up a fresh
|
|
202
|
+
// realm-local Repo, so we share the same documents and sync/keyhive context.
|
|
203
|
+
// Otherwise create our own below.
|
|
204
|
+
if (window.repo) {
|
|
205
|
+
log("using existing Repo from window");
|
|
206
|
+
repo = window.repo;
|
|
207
|
+
hive = window.hive;
|
|
230
208
|
} else {
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
},
|
|
237
|
-
enableRemoteHeadsGossiping: true,
|
|
238
|
-
peerId:
|
|
239
|
-
`${config.titleSuffix}-tab-${crypto.randomUUID()}` as AutomergeRepo.PeerId,
|
|
209
|
+
// Get the initial automerge-worker port via subscribeToRepoChannel,
|
|
210
|
+
// then pass it to keyhive init which wraps it in its own network adapter.
|
|
211
|
+
let resolvePort!: (port: MessagePort) => void;
|
|
212
|
+
const portPromise = new Promise<MessagePort>((r) => {
|
|
213
|
+
resolvePort = r;
|
|
240
214
|
});
|
|
215
|
+
log("subscribing to repo channel");
|
|
216
|
+
await sw.subscribeToRepoChannel(resolvePort);
|
|
217
|
+
log("repo channel subscribed");
|
|
218
|
+
const workerPort = await portPromise;
|
|
219
|
+
|
|
220
|
+
if (config.keyhive) {
|
|
221
|
+
log("setting up keyhive");
|
|
222
|
+
initKeyhiveWasm();
|
|
223
|
+
|
|
224
|
+
({ hive, repo } = await initializeAutomergeRepoKeyhiveWithRepo({
|
|
225
|
+
createRepo: (config) => new Repo(config),
|
|
226
|
+
storage: new IndexedDBWorkerStorageAdapter(`${siteName}-keyhive`),
|
|
227
|
+
peerIdSuffix: siteName + Math.random().toString(36).slice(2),
|
|
228
|
+
networkAdapter: new MessageChannelNetworkAdapter(workerPort),
|
|
229
|
+
automaticArchiveIngestion: true,
|
|
230
|
+
cachingMode: "periodic",
|
|
231
|
+
onlyShareWithHardcodedServerPeerId: false,
|
|
232
|
+
// ARK selects the relay via `syncServer` ("keyhive" | "subduction").
|
|
233
|
+
// Defaults to "subduction".
|
|
234
|
+
...(useKeyhiveSyncServer ? { syncServer: "keyhive" as const } : {}),
|
|
235
|
+
repo: {
|
|
236
|
+
storage: new IndexedDBWorkerStorageAdapter(),
|
|
237
|
+
enableRemoteHeadsGossiping: true,
|
|
238
|
+
},
|
|
239
|
+
}));
|
|
240
|
+
log("keyhive setup complete");
|
|
241
|
+
} else {
|
|
242
|
+
log("creating repo");
|
|
243
|
+
repo = new Repo({
|
|
244
|
+
network: [new MessageChannelNetworkAdapter(workerPort)],
|
|
245
|
+
storage: new IndexedDBWorkerStorageAdapter(),
|
|
246
|
+
async sharePolicy(peerId) {
|
|
247
|
+
return peerId.includes("automerge-worker");
|
|
248
|
+
},
|
|
249
|
+
enableRemoteHeadsGossiping: true,
|
|
250
|
+
peerId:
|
|
251
|
+
`${config.titleSuffix}-tab-${crypto.randomUUID()}` as AutomergeRepo.PeerId,
|
|
252
|
+
});
|
|
253
|
+
log("repo created");
|
|
254
|
+
}
|
|
241
255
|
}
|
|
242
|
-
repo
|
|
243
|
-
|
|
244
|
-
);
|
|
256
|
+
log("popping repo on window");
|
|
257
|
+
window.repo = repo;
|
|
258
|
+
log("await repo.networkSubsystem.whenReady()");
|
|
245
259
|
|
|
246
260
|
await repo.networkSubsystem.whenReady();
|
|
261
|
+
log("networkSubsystem ready");
|
|
262
|
+
|
|
247
263
|
if (hive) {
|
|
248
264
|
(hive.networkAdapter as any).syncKeyhive?.();
|
|
249
265
|
}
|
|
@@ -274,7 +290,10 @@ export async function bootPatchworkSite(
|
|
|
274
290
|
repo,
|
|
275
291
|
buildSystemSources(defaultModuleSources),
|
|
276
292
|
onModuleLoaded,
|
|
277
|
-
unregisterPlugins
|
|
293
|
+
unregisterPlugins,
|
|
294
|
+
// Discover an Automerge package's plugin descriptors off the main thread;
|
|
295
|
+
// each plugin's load() re-imports the package (at heads) on this thread.
|
|
296
|
+
importAutomergeModuleViaWorker
|
|
278
297
|
);
|
|
279
298
|
|
|
280
299
|
const accountDocHandle = (await resolveAccountHandle(repo, {
|
|
@@ -336,10 +355,7 @@ function isValidModuleSource(source: string): boolean {
|
|
|
336
355
|
* built-in default bundle).
|
|
337
356
|
*/
|
|
338
357
|
function resolveDefaultModules(config: SiteConfig): string[] {
|
|
339
|
-
const builtin =
|
|
340
|
-
config.defaultModules ??
|
|
341
|
-
config.defaultModulesUrl ??
|
|
342
|
-
[];
|
|
358
|
+
const builtin = config.defaultModules ?? config.defaultModulesUrl ?? [];
|
|
343
359
|
const builtinList = (Array.isArray(builtin) ? builtin : [builtin]).filter(
|
|
344
360
|
Boolean
|
|
345
361
|
);
|
|
@@ -396,6 +412,46 @@ function installDevConsoleGlobals(
|
|
|
396
412
|
window.getRepoChannel = getRepoChannel;
|
|
397
413
|
}
|
|
398
414
|
|
|
415
|
+
/**
|
|
416
|
+
* Log this tab's Page Lifecycle + connectivity transitions (visibility,
|
|
417
|
+
* freeze, bfcache, online/offline) so they line up against the SharedWorker's
|
|
418
|
+
* sync-socket reaps. [lifecycle]-tagged, on by default.
|
|
419
|
+
*/
|
|
420
|
+
function installLifecycleLogging(): void {
|
|
421
|
+
if (typeof document === "undefined") return;
|
|
422
|
+
const opts = { capture: true } as const;
|
|
423
|
+
const note = (label: string, extra?: unknown) => {
|
|
424
|
+
if (!lifecycleLoggingEnabled()) return;
|
|
425
|
+
const msg = `[lifecycle] ${new Date().toISOString()} ${label}`;
|
|
426
|
+
if (extra === undefined) console.info(msg);
|
|
427
|
+
else console.info(msg, extra);
|
|
428
|
+
};
|
|
429
|
+
|
|
430
|
+
document.addEventListener(
|
|
431
|
+
"visibilitychange",
|
|
432
|
+
() => note(`visibilitychange → ${document.visibilityState}`),
|
|
433
|
+
opts
|
|
434
|
+
);
|
|
435
|
+
document.addEventListener("freeze", () => note("freeze (tab suspended)"), opts);
|
|
436
|
+
document.addEventListener("resume", () => note("resume (tab unsuspended)"), opts);
|
|
437
|
+
window.addEventListener(
|
|
438
|
+
"pageshow",
|
|
439
|
+
e => note("pageshow", { persisted: (e as PageTransitionEvent).persisted }),
|
|
440
|
+
opts
|
|
441
|
+
);
|
|
442
|
+
window.addEventListener(
|
|
443
|
+
"pagehide",
|
|
444
|
+
e => note("pagehide", { persisted: (e as PageTransitionEvent).persisted }),
|
|
445
|
+
opts
|
|
446
|
+
);
|
|
447
|
+
window.addEventListener("online", () => note("online"), opts);
|
|
448
|
+
window.addEventListener("offline", () => note("offline"), opts);
|
|
449
|
+
|
|
450
|
+
note(
|
|
451
|
+
`lifecycle logging installed (visibilityState=${document.visibilityState}, hasFocus=${document.hasFocus()})`
|
|
452
|
+
);
|
|
453
|
+
}
|
|
454
|
+
|
|
399
455
|
function onModuleLoaded(name: string, mod: any): void {
|
|
400
456
|
if (Array.isArray(mod.plugins)) {
|
|
401
457
|
log(
|
|
@@ -637,10 +693,7 @@ function installHashRouting(params: HashRoutingParams): void {
|
|
|
637
693
|
if (isValidAutomergeUrl(hash as AutomergeUrl)) {
|
|
638
694
|
const { documentId, heads } = parseAutomergeUrl(hash as AutomergeUrl);
|
|
639
695
|
window.location.hash = "";
|
|
640
|
-
openDocument(
|
|
641
|
-
rootElement,
|
|
642
|
-
stringifyAutomergeUrl({ documentId, heads })
|
|
643
|
-
);
|
|
696
|
+
openDocument(rootElement, stringifyAutomergeUrl({ documentId, heads }));
|
|
644
697
|
return;
|
|
645
698
|
}
|
|
646
699
|
|
|
@@ -652,7 +705,8 @@ function installHashRouting(params: HashRoutingParams): void {
|
|
|
652
705
|
const type = params.get("type");
|
|
653
706
|
const frame = params.get("frame");
|
|
654
707
|
if (frame) {
|
|
655
|
-
const docUrl =
|
|
708
|
+
const docUrl =
|
|
709
|
+
params.get("doc")?.replace(/^automerge:/, "") ?? accountDocHandle.url;
|
|
656
710
|
if (
|
|
657
711
|
rootElement.getAttribute("tool-id") !== frame ||
|
|
658
712
|
rootElement.getAttribute("doc-url") !== docUrl
|