@statewalker/httpeers-member 0.1.4

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.
Files changed (95) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +203 -0
  3. package/dist/browser-platform.d.ts +30 -0
  4. package/dist/browser-platform.d.ts.map +1 -0
  5. package/dist/browser-platform.js +30 -0
  6. package/dist/browser-profile.d.ts +85 -0
  7. package/dist/browser-profile.d.ts.map +1 -0
  8. package/dist/browser-profile.js +120 -0
  9. package/dist/browser.d.ts +48 -0
  10. package/dist/browser.d.ts.map +1 -0
  11. package/dist/browser.js +51 -0
  12. package/dist/connection-kind.d.ts +36 -0
  13. package/dist/connection-kind.d.ts.map +1 -0
  14. package/dist/connection-kind.js +52 -0
  15. package/dist/describe-error.d.ts +18 -0
  16. package/dist/describe-error.d.ts.map +1 -0
  17. package/dist/describe-error.js +72 -0
  18. package/dist/edge-control.d.ts +50 -0
  19. package/dist/edge-control.d.ts.map +1 -0
  20. package/dist/edge-control.js +69 -0
  21. package/dist/edge-dispatch.d.ts +188 -0
  22. package/dist/edge-dispatch.d.ts.map +1 -0
  23. package/dist/edge-dispatch.js +142 -0
  24. package/dist/edge-guard.d.ts +45 -0
  25. package/dist/edge-guard.d.ts.map +1 -0
  26. package/dist/edge-guard.js +62 -0
  27. package/dist/edge.d.ts +97 -0
  28. package/dist/edge.d.ts.map +1 -0
  29. package/dist/edge.js +104 -0
  30. package/dist/gateway.d.ts +49 -0
  31. package/dist/gateway.d.ts.map +1 -0
  32. package/dist/gateway.js +102 -0
  33. package/dist/identity.d.ts +78 -0
  34. package/dist/identity.d.ts.map +1 -0
  35. package/dist/identity.js +117 -0
  36. package/dist/index.d.ts +39 -0
  37. package/dist/index.d.ts.map +1 -0
  38. package/dist/index.js +37 -0
  39. package/dist/join-blob.d.ts +125 -0
  40. package/dist/join-blob.d.ts.map +1 -0
  41. package/dist/join-blob.js +195 -0
  42. package/dist/join.d.ts +343 -0
  43. package/dist/join.d.ts.map +1 -0
  44. package/dist/join.js +409 -0
  45. package/dist/kv.d.ts +20 -0
  46. package/dist/kv.d.ts.map +1 -0
  47. package/dist/kv.js +10 -0
  48. package/dist/mesh-config.d.ts +26 -0
  49. package/dist/mesh-config.d.ts.map +1 -0
  50. package/dist/mesh-config.js +39 -0
  51. package/dist/mesh-memory.d.ts +55 -0
  52. package/dist/mesh-memory.d.ts.map +1 -0
  53. package/dist/mesh-memory.js +74 -0
  54. package/dist/node.d.ts +29 -0
  55. package/dist/node.d.ts.map +1 -0
  56. package/dist/node.js +42 -0
  57. package/dist/page-wake.d.ts +24 -0
  58. package/dist/page-wake.d.ts.map +1 -0
  59. package/dist/page-wake.js +31 -0
  60. package/dist/peer-request.d.ts +29 -0
  61. package/dist/peer-request.d.ts.map +1 -0
  62. package/dist/peer-request.js +11 -0
  63. package/dist/reset.d.ts +56 -0
  64. package/dist/reset.d.ts.map +1 -0
  65. package/dist/reset.js +103 -0
  66. package/dist/session.d.ts +214 -0
  67. package/dist/session.d.ts.map +1 -0
  68. package/dist/session.js +403 -0
  69. package/dist/start-member.d.ts +176 -0
  70. package/dist/start-member.d.ts.map +1 -0
  71. package/dist/start-member.js +307 -0
  72. package/package.json +120 -0
  73. package/src/browser-platform.ts +53 -0
  74. package/src/browser-profile.ts +179 -0
  75. package/src/browser.ts +103 -0
  76. package/src/connection-kind.ts +53 -0
  77. package/src/describe-error.ts +76 -0
  78. package/src/edge-control.ts +94 -0
  79. package/src/edge-dispatch.ts +295 -0
  80. package/src/edge-guard.ts +67 -0
  81. package/src/edge.ts +175 -0
  82. package/src/gateway.ts +141 -0
  83. package/src/identity.ts +146 -0
  84. package/src/index.ts +95 -0
  85. package/src/join-blob.ts +229 -0
  86. package/src/join.ts +632 -0
  87. package/src/kv.ts +21 -0
  88. package/src/mesh-config.ts +41 -0
  89. package/src/mesh-memory.ts +104 -0
  90. package/src/node.ts +49 -0
  91. package/src/page-wake.ts +59 -0
  92. package/src/peer-request.ts +39 -0
  93. package/src/reset.ts +127 -0
  94. package/src/session.ts +579 -0
  95. package/src/start-member.ts +476 -0
@@ -0,0 +1,41 @@
1
+ /**
2
+ * The one place a `MeshConfig` is validated, whatever channel it arrived on.
3
+ *
4
+ * Two channels exist and both used to check differently: `mesh-memory.ts`
5
+ * validated what it read from storage, and `session.ts` CAST what it fetched
6
+ * over HTTP (`return (await res.json()) as HttpeersConfig`). A cast checks
7
+ * nothing, and the gap showed up as soon as the hub moved into a tab: a
8
+ * hub-page deployment has no stable `hubPeerId` to publish, so its
9
+ * `httpeers.json` carries `relayAddrs` alone, and that config reached
10
+ * `startMember` with `hubPeerId: undefined` — failing far from the cause.
11
+ *
12
+ * `null` FOR ANYTHING UNUSABLE, never a throw. The session already renders
13
+ * `null` correctly ("no mesh to join — paste an invitation"), which is exactly
14
+ * the state a hub-page deployment starts every other page in. Throwing would
15
+ * take out the page, including the invitation field that fixes it.
16
+ */
17
+
18
+ import type { MeshConfig } from "@statewalker/httpeers-core";
19
+
20
+ /**
21
+ * A usable `MeshConfig`, or `null`.
22
+ *
23
+ * Returns a FRESH object carrying only the two known fields: a deployment may
24
+ * publish more, and passing unknown keys through would let an unrelated one
25
+ * look like configuration this library honours.
26
+ */
27
+ export function parseMeshConfig(value: unknown): MeshConfig | null {
28
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
29
+ const { relayAddrs, hubPeerId } = value as Partial<MeshConfig>;
30
+
31
+ // A relay list that is empty is not a mesh: `startMember` reads
32
+ // `relayAddrs[0]` and has nothing to dial.
33
+ if (!Array.isArray(relayAddrs) || relayAddrs.length === 0) return null;
34
+ if (!relayAddrs.every((addr) => typeof addr === "string" && addr !== "")) return null;
35
+
36
+ // The hub's peerId IS the mesh's name — every token's `mesh` claim restates
37
+ // it — so a config without one names no mesh at all.
38
+ if (typeof hubPeerId !== "string" || hubPeerId === "") return null;
39
+
40
+ return { relayAddrs, hubPeerId };
41
+ }
@@ -0,0 +1,104 @@
1
+ /**
2
+ * The mesh this origin last joined -- `{ relayAddrs, hubPeerId }`, kept
3
+ * beside the identity key it belongs to.
4
+ *
5
+ * WHY A PAGE HAS TO REMEMBER THIS AT ALL (Task 28). A page that resumes a
6
+ * membership needs two things: the identity that holds it
7
+ * (`./identity.ts`) and the mesh that granted it. The first has persisted
8
+ * since Task 24. The second has not, and for the Node hub it did not need
9
+ * to -- `httpeers.json` names that mesh on every origin, forever. For a
10
+ * mesh whose hub is a BROWSER PAGE it does need to: that hub's peerId
11
+ * exists only in a tab's IndexedDB and reaches other pages exactly once, in
12
+ * the join blob that admitted them (`./join-blob.ts`). Reload such a page
13
+ * without its `?join=` query and it holds a membership in a mesh it can no
14
+ * longer name. This module is that missing half -- and it is genuinely only
15
+ * a memory: nothing here is a credential, and every field in it was
16
+ * published in the blob anyway.
17
+ *
18
+ * WHAT IT DOES NOT CHANGE: WHAT A BARE INVITATION ID MEANS. `./join-blob.ts`
19
+ * is explicit that a `?invite=` id with no blob means "the mesh
20
+ * `httpeers.json` names", i.e. the Node hub's -- both deployments are
21
+ * supported deliberately. So this memory is consulted ONLY when resuming
22
+ * (no invitation in hand). The moment someone supplies an invitation, the
23
+ * invitation decides: a blob names its own mesh, and a bare id still means
24
+ * the deployment's. Reversing that would have silently pointed the Node-hub
25
+ * path at whatever mesh the page had last seen. See `./session.ts`, which
26
+ * is where the two rules meet.
27
+ *
28
+ * WRITTEN ONLY AFTER A JOIN ACTUALLY SUCCEEDS. A remembered mesh that was
29
+ * never reachable would turn one failed paste into a page that keeps
30
+ * failing the same way on every later load, with the operator's original
31
+ * mistake no longer visible anywhere.
32
+ */
33
+
34
+ // `MeshConfig` in `httpeers-core` — the shape `httpeers.json` has. It was
35
+ // `HttpeersConfig`, declared in the browser peer assembly, which meant a Node
36
+ // member had to import a browser module to name the file it reads.
37
+ import type { MeshConfig as HttpeersConfig } from "@statewalker/httpeers-core";
38
+ import type { AsyncKeyValueBackend } from "./kv.js";
39
+ import { parseMeshConfig } from "./mesh-config.js";
40
+
41
+ /** Where the remembered mesh lives, namespaced like `./identity.ts`'s key and `./snapshot-store.ts`'s snapshot. */
42
+ export const MESH_STORAGE_KEY = "httpeers:mesh";
43
+
44
+ export interface MeshMemory {
45
+ /** The mesh this origin last joined, or `null` -- also `null` when the stored value is unreadable; see `parseMesh`. */
46
+ read(): Promise<HttpeersConfig | null>;
47
+ write(config: HttpeersConfig): Promise<void>;
48
+ clear(): Promise<void>;
49
+ }
50
+
51
+ export interface CreateMeshMemoryInit {
52
+ /**
53
+ * Where this lives. REQUIRED, and deliberately so: it used to default to
54
+ * IndexedDB, which put a browser database in the default path of a module
55
+ * that a Node member also imports. The platform supplies its own store —
56
+ * `./browser`'s for a page, a file-backed one for a process.
57
+ */
58
+ backend: AsyncKeyValueBackend;
59
+ /** Defaults to `MESH_STORAGE_KEY`. */
60
+ storageKey?: string;
61
+ }
62
+
63
+ /**
64
+ * Validate rather than trust, and return `null` rather than throw.
65
+ *
66
+ * The same tolerance `./snapshot-store.ts` applies to a corrupt snapshot,
67
+ * for the same reason and with a smaller cost: a page whose remembered mesh
68
+ * is unreadable has simply not got one, which is the first-run state it
69
+ * already knows how to render (say so, offer the invitation field). Throwing
70
+ * would take out the page -- including the reset control -- over a value
71
+ * that is a convenience.
72
+ */
73
+ function parseMesh(raw: string | undefined): HttpeersConfig | null {
74
+ if (raw == null) return null;
75
+ try {
76
+ // The SAME validator the HTTP channel uses. It was a second copy here,
77
+ // which is how the two channels came to disagree in the first place.
78
+ return parseMeshConfig(JSON.parse(raw));
79
+ } catch (err) {
80
+ console.warn(
81
+ `mesh-memory: the stored mesh at "${MESH_STORAGE_KEY}" is not readable JSON -- this page ` +
82
+ "will ask for an invitation instead of resuming.",
83
+ err,
84
+ );
85
+ return null;
86
+ }
87
+ }
88
+
89
+ export function createMeshMemory(init: CreateMeshMemoryInit): MeshMemory {
90
+ const backend = init.backend;
91
+ const storageKey = init.storageKey ?? MESH_STORAGE_KEY;
92
+
93
+ return {
94
+ async read() {
95
+ return parseMesh(await backend.get(storageKey));
96
+ },
97
+ async write(config) {
98
+ await backend.set(storageKey, JSON.stringify(config));
99
+ },
100
+ async clear() {
101
+ await backend.delete(storageKey);
102
+ },
103
+ };
104
+ }
package/src/node.ts ADDED
@@ -0,0 +1,49 @@
1
+ /**
2
+ * The NODE half of `MemberPlatform` — the counterpart to
3
+ * `./browser-profile.ts`'s `createBrowserNode`.
4
+ *
5
+ * WHAT DIFFERS FROM THE BROWSER PROFILE, AND WHAT DELIBERATELY DOES NOT:
6
+ *
7
+ * - `tcp()` is added. It is the one transport a page cannot have and the
8
+ * one a Node member wants, for the direct member-to-member hop a page
9
+ * makes over WebRTC instead.
10
+ * - The LISTEN ADDRESSES ARE THE SAME — `/p2p-circuit` and `/webrtc`,
11
+ * plus a loopback TCP port. If a Node member listened differently it
12
+ * would reserve somewhere else, and the rung would be testing a
13
+ * different design rather than the same one on another platform.
14
+ * - The connection gater is permissive, which the browser profile also
15
+ * does for loopback and private addresses (`dialNeedsPermissiveGater`).
16
+ * Node has no browser default to relax, so this is the same policy
17
+ * arrived at from the other side.
18
+ * - There is no `mountEdge`: `MemberHandle.fetch` is the edge under Node.
19
+ * - There is no `watchWake`: a Node process gets no wake events, so the
20
+ * supervisor's backstop timer is the whole story.
21
+ */
22
+
23
+ import { noise } from "@chainsafe/libp2p-noise";
24
+ import { yamux } from "@chainsafe/libp2p-yamux";
25
+ import { circuitRelayTransport } from "@libp2p/circuit-relay-v2";
26
+ import { identify } from "@libp2p/identify";
27
+ import type { Ed25519PrivateKey, Libp2p } from "@libp2p/interface";
28
+ import { tcp } from "@libp2p/tcp";
29
+ import { webRTC } from "@libp2p/webrtc";
30
+ import { webSockets } from "@libp2p/websockets";
31
+ import { createLibp2p } from "libp2p";
32
+ import type { MemberPlatform } from "./start-member.js";
33
+
34
+ export async function createNodeMemberNode(init: {
35
+ privateKey?: Ed25519PrivateKey;
36
+ }): Promise<Libp2p> {
37
+ return createLibp2p({
38
+ ...(init.privateKey != null ? { privateKey: init.privateKey } : {}),
39
+ addresses: { listen: ["/ip4/127.0.0.1/tcp/0", "/p2p-circuit", "/webrtc"] },
40
+ transports: [webSockets(), circuitRelayTransport(), webRTC(), tcp()],
41
+ connectionEncrypters: [noise()],
42
+ streamMuxers: [yamux()],
43
+ connectionGater: { denyDialMultiaddr: async () => false },
44
+ services: { identify: identify() },
45
+ });
46
+ }
47
+
48
+ /** The whole Node platform: a node factory, and nothing else. */
49
+ export const nodePlatform: MemberPlatform = { createNode: createNodeMemberNode };
@@ -0,0 +1,59 @@
1
+ /**
2
+ * The page events that mean "the relay link may have died while nothing was
3
+ * watching, and may be reachable again now" -- turned into one callback.
4
+ */
5
+
6
+ /** The parts of `window` this reads. */
7
+ export type WakeWindow = Pick<EventTarget, "addEventListener" | "removeEventListener">;
8
+
9
+ /** The parts of `document` this reads. */
10
+ export type WakeDocument = Pick<EventTarget, "addEventListener" | "removeEventListener"> & {
11
+ readonly visibilityState: string;
12
+ };
13
+
14
+ export interface WatchPageWakeInit {
15
+ win?: WakeWindow;
16
+ doc?: WakeDocument;
17
+ }
18
+
19
+ /**
20
+ * The two browser globals this file names, declared LOCALLY rather than by
21
+ * adding "dom" to the package's `lib`.
22
+ *
23
+ * Same technique and same reason as `httpeers-libp2p/src/browser.ts`'s
24
+ * `indexedDB`: one browser-only module must not put `window` and `document`
25
+ * in scope for every Node module beside it, where naming them is a bug the
26
+ * compiler should catch. The types are the two `Pick`s above, so the
27
+ * declaration also states exactly how much of each global is used.
28
+ */
29
+ declare const window: WakeWindow;
30
+ declare const document: WakeDocument;
31
+
32
+ /**
33
+ * Call `onWake` whenever the page comes back; returns the unsubscribe.
34
+ *
35
+ * Why these events, and not a timer: a suspended page's timers are
36
+ * throttled or stopped, so the relay supervisor's own backoff may not fire
37
+ * for minutes after the page is usable again. See
38
+ * `tests/browser-page-wake.test.ts` for what each event means.
39
+ */
40
+ export function watchPageWake(onWake: () => void, init: WatchPageWakeInit = {}): () => void {
41
+ const win = init.win ?? window;
42
+ const doc = init.doc ?? document;
43
+ const wake = (): void => onWake();
44
+ const onVisibility = (): void => {
45
+ if (doc.visibilityState === "visible") onWake();
46
+ };
47
+
48
+ win.addEventListener("online", wake);
49
+ win.addEventListener("pageshow", wake);
50
+ doc.addEventListener("resume", wake);
51
+ doc.addEventListener("visibilitychange", onVisibility);
52
+
53
+ return () => {
54
+ win.removeEventListener("online", wake);
55
+ win.removeEventListener("pageshow", wake);
56
+ doc.removeEventListener("resume", wake);
57
+ doc.removeEventListener("visibilitychange", onVisibility);
58
+ };
59
+ }
@@ -0,0 +1,39 @@
1
+ import { setMeshToken } from "@statewalker/httpeers-core";
2
+
3
+ /**
4
+ * A `Request` addressed at a mesh path.
5
+ *
6
+ * `Peer.call(peerId, request)` takes a whole `Request`, because that is the
7
+ * contract every other seam in this system speaks — the prototype's peer took
8
+ * `(peerId, path, init)` instead, which meant it owned a second, narrower idea
9
+ * of what a call is and could not carry anything a `Request` can (a stream, an
10
+ * `AbortSignal`, a `Headers` instance a caller already had).
11
+ *
12
+ * The origin is a placeholder and is never dialled: the far side routes on the
13
+ * pathname alone. It is spelled `peer.local` rather than `localhost` so that a
14
+ * stray log line cannot be mistaken for a loopback request that really went out.
15
+ */
16
+ export interface PeerRequestInit extends RequestInit {
17
+ /**
18
+ * A membership token, placed in `MESH_TOKEN_HEADER` -- never
19
+ * `Authorization`, which stays the caller's to use for the application it
20
+ * is calling.
21
+ *
22
+ * The prototype's `peer.call` took this as its own option and built the
23
+ * header internally. Keeping it here rather than making every call site
24
+ * write the header keeps those sites reading as intent — and keeps ONE
25
+ * place that decides the scheme, which is what a token's format being
26
+ * changed later will want.
27
+ */
28
+ token?: string;
29
+ }
30
+
31
+ export function peerRequest(path: string, init: PeerRequestInit = {}): Request {
32
+ const { token, headers, ...rest } = init;
33
+ const merged = new Headers(headers);
34
+ if (token != null) setMeshToken(merged, token);
35
+ return new Request(`http://peer.local${path.startsWith("/") ? path : `/${path}`}`, {
36
+ ...rest,
37
+ headers: merged,
38
+ });
39
+ }
package/src/reset.ts ADDED
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Getting a stuck origin back to a first visit — and NOTHING ELSE IN IT.
3
+ *
4
+ * ITS OWN ENTRY POINT ON PURPOSE. This is what a rescue page loads when the
5
+ * app will not start, so it must not depend on the app starting. Importing it
6
+ * through `./browser` would drag in the session, the member lifecycle,
7
+ * `httpeers-access` and its Biscuit engine, crypto and transport code -- all to
8
+ * unregister a ServiceWorker. If any of that is what broke, the rescue page
9
+ * breaks with it.
10
+ *
11
+ * This was measured when the engine was WebAssembly: importing
12
+ * `@statewalker/httpeers-member/browser` failed outright until the wasm loader
13
+ * was wired. The engine is pure TypeScript now, but the rule stands for every
14
+ * other reason an app fails to start.
15
+ *
16
+ * So: zero imports. Everything here is a platform global.
17
+ */
18
+
19
+ /**
20
+ * The page globals a reset touches. Declared locally, like the rest of this
21
+ * file — see `./page-wake.ts` for why not `lib: dom`.
22
+ */
23
+ declare const navigator: {
24
+ serviceWorker?: { getRegistrations(): Promise<Array<{ unregister(): Promise<boolean> }>> };
25
+ };
26
+ declare const indexedDB: {
27
+ databases?(): Promise<Array<{ name?: string }>>;
28
+ deleteDatabase(name: string): {
29
+ onsuccess: (() => void) | null;
30
+ onerror: (() => void) | null;
31
+ onblocked: (() => void) | null;
32
+ };
33
+ };
34
+ declare const localStorage: { clear(): void } | undefined;
35
+ declare const sessionStorage: { clear(): void } | undefined;
36
+
37
+ /** What a reset actually removed, so a page can report it rather than guess. */
38
+ export interface ResetResult {
39
+ /** ServiceWorker registrations unregistered on this origin. */
40
+ serviceWorkers: number;
41
+ /** IndexedDB databases deleted, by name. */
42
+ databases: string[];
43
+ }
44
+
45
+ /**
46
+ * Put this origin back to a first-visit state: no worker, no databases, no
47
+ * stored values.
48
+ *
49
+ * WHY THIS EXISTS AT ALL. A ServiceWorker is the stickiest thing a static site
50
+ * can install, and the obvious remedies do not work — measured in a real
51
+ * browser, not inferred:
52
+ *
53
+ * - **A reload does not remove one.** Registration is durable state on the
54
+ * ORIGIN, independent of any page's lifetime.
55
+ * - **`unregister()` does not evict the worker already controlling this
56
+ * page.** It removes the registration; the active worker keeps serving
57
+ * open clients until they are unloaded. So a reset control that reports
58
+ * success without reloading is still being served by the thing it says it
59
+ * removed. **Reload after calling this.**
60
+ * - **Unregistering alone is a half-reset.** The identity that makes a page
61
+ * "the same peer" is in IndexedDB. Clear only the worker and the page
62
+ * comes back as the same member, which is exactly the outcome people
63
+ * report as "it did not work".
64
+ *
65
+ * EVERYTHING ON THE ORIGIN, NOT A LIST OF NAMES. The state spans idb-keyval's
66
+ * store (identity, remembered mesh), the hub's database, the libp2p key store,
67
+ * the ServiceWorker adapter's claimed-keys table and `localStorage` — five
68
+ * places across three packages today. A hand-maintained list would go stale
69
+ * the first time one moved, silently, leaving the half-reset above. An origin
70
+ * here belongs to one app, so "all of it" is both simpler and correct.
71
+ *
72
+ * NEVER THROWS. It runs on a rescue page that must work when everything else
73
+ * is broken, including on a first visit with nothing to remove.
74
+ */
75
+ export async function resetBrowserState(): Promise<ResetResult> {
76
+ let serviceWorkers = 0;
77
+ try {
78
+ const registrations = (await navigator.serviceWorker?.getRegistrations()) ?? [];
79
+ for (const registration of registrations) {
80
+ try {
81
+ await registration.unregister();
82
+ serviceWorkers += 1;
83
+ } catch {
84
+ /* one that refuses must not stop the rest */
85
+ }
86
+ }
87
+ } catch {
88
+ /* no ServiceWorker support, or an insecure context */
89
+ }
90
+
91
+ const databases: string[] = [];
92
+ try {
93
+ // `indexedDB.databases()` is unsupported in Firefox, where enumeration is
94
+ // impossible and this half is a no-op. The worker and the stores below are
95
+ // still cleared, so a reset there is partial rather than absent -- worth
96
+ // knowing, and better than refusing to run.
97
+ const found = (await indexedDB.databases?.()) ?? [];
98
+ for (const { name } of found) {
99
+ if (name == null || name === "") continue;
100
+ await new Promise<void>((resolve) => {
101
+ const request = indexedDB.deleteDatabase(name);
102
+ request.onsuccess = () => resolve();
103
+ request.onerror = () => resolve();
104
+ // A database still open in another tab blocks. Resolving anyway keeps
105
+ // the reset moving; the page reload that follows closes this tab's
106
+ // handle, and the delete completes then.
107
+ request.onblocked = () => resolve();
108
+ });
109
+ databases.push(name);
110
+ }
111
+ } catch {
112
+ /* enumeration unsupported */
113
+ }
114
+
115
+ try {
116
+ localStorage?.clear();
117
+ } catch {
118
+ /* blocked site data */
119
+ }
120
+ try {
121
+ sessionStorage?.clear();
122
+ } catch {
123
+ /* blocked site data */
124
+ }
125
+
126
+ return { serviceWorkers, databases };
127
+ }