@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.
- package/LICENSE +21 -0
- package/README.md +203 -0
- package/dist/browser-platform.d.ts +30 -0
- package/dist/browser-platform.d.ts.map +1 -0
- package/dist/browser-platform.js +30 -0
- package/dist/browser-profile.d.ts +85 -0
- package/dist/browser-profile.d.ts.map +1 -0
- package/dist/browser-profile.js +120 -0
- package/dist/browser.d.ts +48 -0
- package/dist/browser.d.ts.map +1 -0
- package/dist/browser.js +51 -0
- package/dist/connection-kind.d.ts +36 -0
- package/dist/connection-kind.d.ts.map +1 -0
- package/dist/connection-kind.js +52 -0
- package/dist/describe-error.d.ts +18 -0
- package/dist/describe-error.d.ts.map +1 -0
- package/dist/describe-error.js +72 -0
- package/dist/edge-control.d.ts +50 -0
- package/dist/edge-control.d.ts.map +1 -0
- package/dist/edge-control.js +69 -0
- package/dist/edge-dispatch.d.ts +188 -0
- package/dist/edge-dispatch.d.ts.map +1 -0
- package/dist/edge-dispatch.js +142 -0
- package/dist/edge-guard.d.ts +45 -0
- package/dist/edge-guard.d.ts.map +1 -0
- package/dist/edge-guard.js +62 -0
- package/dist/edge.d.ts +97 -0
- package/dist/edge.d.ts.map +1 -0
- package/dist/edge.js +104 -0
- package/dist/gateway.d.ts +49 -0
- package/dist/gateway.d.ts.map +1 -0
- package/dist/gateway.js +102 -0
- package/dist/identity.d.ts +78 -0
- package/dist/identity.d.ts.map +1 -0
- package/dist/identity.js +117 -0
- package/dist/index.d.ts +39 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +37 -0
- package/dist/join-blob.d.ts +125 -0
- package/dist/join-blob.d.ts.map +1 -0
- package/dist/join-blob.js +195 -0
- package/dist/join.d.ts +343 -0
- package/dist/join.d.ts.map +1 -0
- package/dist/join.js +409 -0
- package/dist/kv.d.ts +20 -0
- package/dist/kv.d.ts.map +1 -0
- package/dist/kv.js +10 -0
- package/dist/mesh-config.d.ts +26 -0
- package/dist/mesh-config.d.ts.map +1 -0
- package/dist/mesh-config.js +39 -0
- package/dist/mesh-memory.d.ts +55 -0
- package/dist/mesh-memory.d.ts.map +1 -0
- package/dist/mesh-memory.js +74 -0
- package/dist/node.d.ts +29 -0
- package/dist/node.d.ts.map +1 -0
- package/dist/node.js +42 -0
- package/dist/page-wake.d.ts +24 -0
- package/dist/page-wake.d.ts.map +1 -0
- package/dist/page-wake.js +31 -0
- package/dist/peer-request.d.ts +29 -0
- package/dist/peer-request.d.ts.map +1 -0
- package/dist/peer-request.js +11 -0
- package/dist/reset.d.ts +56 -0
- package/dist/reset.d.ts.map +1 -0
- package/dist/reset.js +103 -0
- package/dist/session.d.ts +214 -0
- package/dist/session.d.ts.map +1 -0
- package/dist/session.js +403 -0
- package/dist/start-member.d.ts +176 -0
- package/dist/start-member.d.ts.map +1 -0
- package/dist/start-member.js +307 -0
- package/package.json +120 -0
- package/src/browser-platform.ts +53 -0
- package/src/browser-profile.ts +179 -0
- package/src/browser.ts +103 -0
- package/src/connection-kind.ts +53 -0
- package/src/describe-error.ts +76 -0
- package/src/edge-control.ts +94 -0
- package/src/edge-dispatch.ts +295 -0
- package/src/edge-guard.ts +67 -0
- package/src/edge.ts +175 -0
- package/src/gateway.ts +141 -0
- package/src/identity.ts +146 -0
- package/src/index.ts +95 -0
- package/src/join-blob.ts +229 -0
- package/src/join.ts +632 -0
- package/src/kv.ts +21 -0
- package/src/mesh-config.ts +41 -0
- package/src/mesh-memory.ts +104 -0
- package/src/node.ts +49 -0
- package/src/page-wake.ts +59 -0
- package/src/peer-request.ts +39 -0
- package/src/reset.ts +127 -0
- package/src/session.ts +579 -0
- 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 };
|
package/src/page-wake.ts
ADDED
|
@@ -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
|
+
}
|