@estiva-app/platform 0.1.0

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.
@@ -0,0 +1,195 @@
1
+ /**
2
+ * One live relay socket per tab, with its environment injected.
3
+ *
4
+ * `@estiva-app/protocol` owns the wire: {@link createLiveRelay} is the socket
5
+ * and NIP-42 AUTH, {@link createChannelSubscriptions} is per-channel
6
+ * refcounting. Neither of them ever left Peek's building, because the *plumbing*
7
+ * around them did not move — `peek-app/src/nostr/liveTopics.ts` held a
8
+ * module-level singleton, imported the app's credential provider, and reached
9
+ * for `window`. ADR 0002 §10 scores that file ❌ on constraint 1 and ❌ on
10
+ * constraint 2, and calls it "the bill SHA-7 is paying".
11
+ *
12
+ * This is that bill paid. The three behaviours are here; none of them reaches
13
+ * for anything.
14
+ *
15
+ * 1. **One instance per tab.** `max_subscriptions` is per connection and the
16
+ * relay authenticates a connection once, so a connection per component mount
17
+ * is wrong rather than merely wasteful — and React StrictMode makes it
18
+ * concrete, mounting everything twice. The mechanism is
19
+ * {@link createLiveClientHolder}; the single module binding stays in the app,
20
+ * because a module-level global *here* is the constraint-2 failure this
21
+ * package exists to stop repeating.
22
+ * 2. **The credential is read per connect, never captured.** A token captured
23
+ * at module load survives a silent renewal as a dead credential, and a
24
+ * reconnect then signs its `22242` with it. `getCredential` is called on
25
+ * every connect attempt — that is `createLiveRelay`'s contract, and this
26
+ * passes it straight through rather than wrapping it.
27
+ * 3. **Network return drives a reconnect.** See {@link OnlineSource}.
28
+ *
29
+ * ## What this deliberately does not know
30
+ *
31
+ * Where anything is stored. Peek's `liveTopics.ts` took a `LiveProjectionApi`,
32
+ * and extracting that as-is would have baked a Convex assumption into a package
33
+ * two apps depend on — the "package shaped like one app" failure. So this
34
+ * delivers frames and connection state and nothing else, and the Convex-shaped
35
+ * projection stays in Peek. It also bets on neither outcome of "do we need
36
+ * Convex": if Convex stays nothing here changes, and if it goes this is already
37
+ * right.
38
+ */
39
+ import { createChannelSubscriptions, createLiveRelay, } from '@estiva-app/protocol';
40
+ import { createFolderActivity } from './folder-activity.js';
41
+ import { assumeOnline } from './online.js';
42
+ /**
43
+ * The relay's aggregate `#h` budget for one REQ, COUNT, `/query` or `/count`.
44
+ *
45
+ * `MAX_EXPLICIT_CHANNEL_VALUES` in `buzz-relay/src/handlers/req.rs`: "Explicit
46
+ * channels may each require an uncached membership lookup and, for a live WS
47
+ * subscription, a registry entry plus Redis topic retain." Exceeding it is
48
+ * refused, so {@link LiveClient.watchFolders} chunks rather than hoping.
49
+ */
50
+ export const MAX_FOLDERS_PER_SUBSCRIPTION = 128;
51
+ /**
52
+ * Call an app's observer without letting it break the socket.
53
+ *
54
+ * These are the app's callbacks — Peek's `freshness.ts` recorders — and they run
55
+ * on the connect, sign and state paths. One of them throwing used to be able to
56
+ * skip the `relay.reconnect()` on the line after it, which leaves a socket
57
+ * reporting `live` and delivering nothing: the exact failure the online source
58
+ * exists to prevent, reintroduced by the thing observing it.
59
+ */
60
+ function safely(run) {
61
+ try {
62
+ run();
63
+ }
64
+ catch {
65
+ /* an observer's own problem; never the socket's */
66
+ }
67
+ }
68
+ /** The `h` tag is the Folder — RFC 0.4 §5.2, measured across all production channels. */
69
+ function folderOf(event) {
70
+ return event.tags.find((tag) => tag[0] === 'h')?.[1];
71
+ }
72
+ function chunk(items, size) {
73
+ const out = [];
74
+ for (let i = 0; i < items.length; i += size)
75
+ out.push(items.slice(i, i + size));
76
+ return out;
77
+ }
78
+ export function createLiveClient(options) {
79
+ const stateListeners = new Set();
80
+ const activity = createFolderActivity();
81
+ const online = options.online ?? assumeOnline();
82
+ let state = 'connecting';
83
+ const relay = createLiveRelay({
84
+ url: options.relayUrl,
85
+ getCredential: () => {
86
+ const credential = options.getCredential();
87
+ if (!credential) {
88
+ safely(() => options.observe?.onCredentialMissing?.());
89
+ return null;
90
+ }
91
+ return credential;
92
+ },
93
+ sign: async (unsigned, token, expectedPubkey) => {
94
+ try {
95
+ const signed = await options.sign(unsigned, token, expectedPubkey);
96
+ safely(() => options.observe?.onSignOk?.());
97
+ return signed;
98
+ }
99
+ catch (error) {
100
+ // The status is the honest signal, and "expired" versus "this app
101
+ // cannot verify the token" decides whether sending somebody to sign in
102
+ // again helps or wastes their time.
103
+ safely(() => options.observe?.onSignFailure?.(error));
104
+ throw error;
105
+ }
106
+ },
107
+ onState: (next) => {
108
+ state = next;
109
+ safely(() => options.observe?.onState?.(next));
110
+ for (const listener of [...stateListeners])
111
+ safely(() => listener(next));
112
+ },
113
+ subscriptionPrefix: options.subscriptionPrefix,
114
+ log: options.log,
115
+ socketFactory: options.socketFactory,
116
+ now: options.now,
117
+ setTimer: options.setTimer,
118
+ clearTimer: options.clearTimer,
119
+ backoff: options.backoff,
120
+ });
121
+ const subscriptions = createChannelSubscriptions(relay, {
122
+ onListenerError: (error) => options.log?.('a channel listener threw', { error: String(error) }),
123
+ });
124
+ safely(() => options.observe?.onOnline?.(online.online()));
125
+ const releaseOnline = online.subscribe((isOnline) => {
126
+ safely(() => options.observe?.onOnline?.(isOnline));
127
+ // `offline` drops the socket so the reported state becomes honest at once;
128
+ // `online` retries immediately rather than waiting out a backoff that may
129
+ // have grown to half a minute while there was no network to reach.
130
+ relay.reconnect();
131
+ });
132
+ return {
133
+ relay,
134
+ subscriptions,
135
+ state: () => state,
136
+ onState(listener) {
137
+ stateListeners.add(listener);
138
+ return () => stateListeners.delete(listener);
139
+ },
140
+ onFolderActivity: (folder, listener) => activity.watch(folder, listener),
141
+ watchFolders(folders, listener, watchOptions) {
142
+ const unwatch = folders.map((folder) => activity.watch(folder, () => listener(folder)));
143
+ const subs = chunk([...new Set(folders)], MAX_FOLDERS_PER_SUBSCRIPTION).map((group) => relay.subscribe([{ ...(watchOptions?.kinds ? { kinds: watchOptions.kinds } : {}), '#h': group }], (event) => {
144
+ const folder = folderOf(event);
145
+ if (folder)
146
+ activity.notify(folder);
147
+ }, {
148
+ onClosed: (reason) => options.log?.('folder subscription closed', { reason }),
149
+ }));
150
+ let stopped = false;
151
+ return {
152
+ stop() {
153
+ if (stopped)
154
+ return;
155
+ stopped = true;
156
+ for (const sub of subs)
157
+ sub.close();
158
+ for (const release of unwatch)
159
+ release();
160
+ },
161
+ };
162
+ },
163
+ close() {
164
+ releaseOnline();
165
+ subscriptions.close();
166
+ relay.close();
167
+ stateListeners.clear();
168
+ },
169
+ };
170
+ }
171
+ /**
172
+ * Somewhere to keep the one client, without this package keeping it.
173
+ *
174
+ * The app holds the single module-scope binding — `const holder =
175
+ * createLiveClientHolder()` — which is the one place a per-tab singleton can
176
+ * live without making the package un-instantiable twice.
177
+ */
178
+ export function createLiveClientHolder() {
179
+ let current = null;
180
+ return {
181
+ get(options) {
182
+ if (current && current.relayUrl === options.relayUrl)
183
+ return current.client;
184
+ current?.client.close();
185
+ current = { relayUrl: options.relayUrl, client: createLiveClient(options) };
186
+ return current.client;
187
+ },
188
+ peek: () => current?.client ?? null,
189
+ reset() {
190
+ current?.client.close();
191
+ current = null;
192
+ },
193
+ };
194
+ }
195
+ //# sourceMappingURL=live-client.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"live-client.js","sourceRoot":"","sources":["../src/live-client.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,49 @@
1
+ /**
2
+ * "Is the network believed reachable?", as an injected dependency.
3
+ *
4
+ * A socket cannot notice the network coming back on its own. Reconnection is
5
+ * driven by `onclose`, and a connection whose peer became unreachable stays
6
+ * `OPEN` until TCP gives up — which can be minutes, and browser devtools'
7
+ * offline mode frequently never closes it at all. So the socket sits reporting
8
+ * `live`, delivering nothing, and never reconnects.
9
+ *
10
+ * Peek solved that with `window.addEventListener('offline', …)` inside the
11
+ * module that owned its relay. That is the second of the four extractability
12
+ * constraints failed (ADR 0002 §10): an environment touch reached for rather
13
+ * than received, which makes the module un-instantiable twice and untestable
14
+ * without a browser.
15
+ *
16
+ * Hence an interface. {@link browserOnlineSource} is the browser
17
+ * implementation, and it takes its window as an argument — so nothing in this
18
+ * package touches a global at module scope, and a test supplies a fake.
19
+ */
20
+ /** What this package needs of `window`. Deliberately tiny. */
21
+ export interface OnlineTarget {
22
+ navigator: {
23
+ onLine: boolean;
24
+ };
25
+ addEventListener(type: 'online' | 'offline', listener: () => void): void;
26
+ removeEventListener(type: 'online' | 'offline', listener: () => void): void;
27
+ }
28
+ export interface OnlineSource {
29
+ /** The current belief. */
30
+ online(): boolean;
31
+ /** Called on every change. Returns an unsubscribe; calling it twice is harmless. */
32
+ subscribe(listener: (online: boolean) => void): () => void;
33
+ }
34
+ /**
35
+ * `navigator.onLine` and the two window events, as a source.
36
+ *
37
+ * The events are the one authority here a socket cannot second-guess, which is
38
+ * why this exists at all rather than a timer.
39
+ */
40
+ export declare function browserOnlineSource(target: OnlineTarget): OnlineSource;
41
+ /**
42
+ * Always online, and never changes.
43
+ *
44
+ * For a consumer with no `window` — a Node probe, the agent, a server-side
45
+ * test. Not a stub: "this environment has no opinion" is a real answer, and it
46
+ * is better than a package that assumes a browser.
47
+ */
48
+ export declare function assumeOnline(): OnlineSource;
49
+ //# sourceMappingURL=online.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"online.d.ts","sourceRoot":"","sources":["../src/online.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAEH,8DAA8D;AAC9D,MAAM,WAAW,YAAY;IAC3B,SAAS,EAAE;QAAE,MAAM,EAAE,OAAO,CAAA;KAAE,CAAA;IAC9B,gBAAgB,CAAC,IAAI,EAAE,QAAQ,GAAG,SAAS,EAAE,QAAQ,EAAE,MAAM,IAAI,GAAG,IAAI,CAAA;IACxE,mBAAmB,CAAC,IAAI,EAAE,QAAQ,GAAG,SAAS,EAAE,QAAQ,EAAE,MAAM,IAAI,GAAG,IAAI,CAAA;CAC5E;AAED,MAAM,WAAW,YAAY;IAC3B,0BAA0B;IAC1B,MAAM,IAAI,OAAO,CAAA;IACjB,oFAAoF;IACpF,SAAS,CAAC,QAAQ,EAAE,CAAC,MAAM,EAAE,OAAO,KAAK,IAAI,GAAG,MAAM,IAAI,CAAA;CAC3D;AAED;;;;;GAKG;AACH,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,YAAY,GAAG,YAAY,CAwCtE;AAED;;;;;;GAMG;AACH,wBAAgB,YAAY,IAAI,YAAY,CAE3C"}
package/dist/online.js ADDED
@@ -0,0 +1,76 @@
1
+ /**
2
+ * "Is the network believed reachable?", as an injected dependency.
3
+ *
4
+ * A socket cannot notice the network coming back on its own. Reconnection is
5
+ * driven by `onclose`, and a connection whose peer became unreachable stays
6
+ * `OPEN` until TCP gives up — which can be minutes, and browser devtools'
7
+ * offline mode frequently never closes it at all. So the socket sits reporting
8
+ * `live`, delivering nothing, and never reconnects.
9
+ *
10
+ * Peek solved that with `window.addEventListener('offline', …)` inside the
11
+ * module that owned its relay. That is the second of the four extractability
12
+ * constraints failed (ADR 0002 §10): an environment touch reached for rather
13
+ * than received, which makes the module un-instantiable twice and untestable
14
+ * without a browser.
15
+ *
16
+ * Hence an interface. {@link browserOnlineSource} is the browser
17
+ * implementation, and it takes its window as an argument — so nothing in this
18
+ * package touches a global at module scope, and a test supplies a fake.
19
+ */
20
+ /**
21
+ * `navigator.onLine` and the two window events, as a source.
22
+ *
23
+ * The events are the one authority here a socket cannot second-guess, which is
24
+ * why this exists at all rather than a timer.
25
+ */
26
+ export function browserOnlineSource(target) {
27
+ const listeners = new Set();
28
+ let attached = false;
29
+ const announce = (online) => {
30
+ for (const listener of [...listeners]) {
31
+ // Isolated, for the same reason `folder-activity` isolates its own: the
32
+ // most important subscriber here is the one that reconnects the socket,
33
+ // and one app listener throwing must not be what stops it running.
34
+ try {
35
+ listener(online);
36
+ }
37
+ catch {
38
+ /* a listener's own problem */
39
+ }
40
+ }
41
+ };
42
+ const onOnline = () => announce(true);
43
+ const onOffline = () => announce(false);
44
+ return {
45
+ online: () => target.navigator.onLine,
46
+ subscribe(listener) {
47
+ listeners.add(listener);
48
+ if (!attached) {
49
+ // Attached on first use and never in module scope, so importing this
50
+ // package registers nothing.
51
+ target.addEventListener('online', onOnline);
52
+ target.addEventListener('offline', onOffline);
53
+ attached = true;
54
+ }
55
+ return () => {
56
+ listeners.delete(listener);
57
+ if (listeners.size === 0 && attached) {
58
+ target.removeEventListener('online', onOnline);
59
+ target.removeEventListener('offline', onOffline);
60
+ attached = false;
61
+ }
62
+ };
63
+ },
64
+ };
65
+ }
66
+ /**
67
+ * Always online, and never changes.
68
+ *
69
+ * For a consumer with no `window` — a Node probe, the agent, a server-side
70
+ * test. Not a stub: "this environment has no opinion" is a real answer, and it
71
+ * is better than a package that assumes a browser.
72
+ */
73
+ export function assumeOnline() {
74
+ return { online: () => true, subscribe: () => () => { } };
75
+ }
76
+ //# sourceMappingURL=online.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"online.js","sourceRoot":"","sources":["../src/online.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAgBH;;;;;GAKG;AACH,MAAM,UAAU,mBAAmB,CAAC,MAAoB;IACtD,MAAM,SAAS,GAAG,IAAI,GAAG,EAA6B,CAAA;IACtD,IAAI,QAAQ,GAAG,KAAK,CAAA;IAEpB,MAAM,QAAQ,GAAG,CAAC,MAAe,EAAE,EAAE;QACnC,KAAK,MAAM,QAAQ,IAAI,CAAC,GAAG,SAAS,CAAC,EAAE,CAAC;YACtC,wEAAwE;YACxE,wEAAwE;YACxE,mEAAmE;YACnE,IAAI,CAAC;gBACH,QAAQ,CAAC,MAAM,CAAC,CAAA;YAClB,CAAC;YAAC,MAAM,CAAC;gBACP,8BAA8B;YAChC,CAAC;QACH,CAAC;IACH,CAAC,CAAA;IACD,MAAM,QAAQ,GAAG,GAAG,EAAE,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAA;IACrC,MAAM,SAAS,GAAG,GAAG,EAAE,CAAC,QAAQ,CAAC,KAAK,CAAC,CAAA;IAEvC,OAAO;QACL,MAAM,EAAE,GAAG,EAAE,CAAC,MAAM,CAAC,SAAS,CAAC,MAAM;QACrC,SAAS,CAAC,QAAQ;YAChB,SAAS,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAA;YACvB,IAAI,CAAC,QAAQ,EAAE,CAAC;gBACd,qEAAqE;gBACrE,6BAA6B;gBAC7B,MAAM,CAAC,gBAAgB,CAAC,QAAQ,EAAE,QAAQ,CAAC,CAAA;gBAC3C,MAAM,CAAC,gBAAgB,CAAC,SAAS,EAAE,SAAS,CAAC,CAAA;gBAC7C,QAAQ,GAAG,IAAI,CAAA;YACjB,CAAC;YACD,OAAO,GAAG,EAAE;gBACV,SAAS,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAA;gBAC1B,IAAI,SAAS,CAAC,IAAI,KAAK,CAAC,IAAI,QAAQ,EAAE,CAAC;oBACrC,MAAM,CAAC,mBAAmB,CAAC,QAAQ,EAAE,QAAQ,CAAC,CAAA;oBAC9C,MAAM,CAAC,mBAAmB,CAAC,SAAS,EAAE,SAAS,CAAC,CAAA;oBAChD,QAAQ,GAAG,KAAK,CAAA;gBAClB,CAAC;YACH,CAAC,CAAA;QACH,CAAC;KACF,CAAA;AACH,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,YAAY;IAC1B,OAAO,EAAE,MAAM,EAAE,GAAG,EAAE,CAAC,IAAI,EAAE,SAAS,EAAE,GAAG,EAAE,CAAC,GAAG,EAAE,GAAE,CAAC,EAAE,CAAA;AAC1D,CAAC"}
package/package.json ADDED
@@ -0,0 +1,57 @@
1
+ {
2
+ "name": "@estiva-app/platform",
3
+ "version": "0.1.0",
4
+ "description": "Browser lifecycle two Estiva apps share: the one live relay socket a tab should hold, its credential read per connect, and the network coming back.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./dist/index.d.ts",
10
+ "default": "./dist/index.js"
11
+ },
12
+ "./package.json": "./package.json"
13
+ },
14
+ "main": "./dist/index.js",
15
+ "types": "./dist/index.d.ts",
16
+ "files": [
17
+ "dist",
18
+ "src",
19
+ "README.md",
20
+ "CHANGELOG.md",
21
+ "LICENSE"
22
+ ],
23
+ "sideEffects": false,
24
+ "engines": {
25
+ "node": ">=20"
26
+ },
27
+ "publishConfig": {
28
+ "access": "public"
29
+ },
30
+ "repository": {
31
+ "type": "git",
32
+ "url": "git+https://github.com/estiva-app/estiva-foundation.git",
33
+ "directory": "packages/platform"
34
+ },
35
+ "keywords": [
36
+ "nostr",
37
+ "relay",
38
+ "websocket",
39
+ "nip-42",
40
+ "pwa",
41
+ "estiva"
42
+ ],
43
+ "scripts": {
44
+ "prebuild": "npm run build --workspace @estiva-app/protocol",
45
+ "build": "tsc -p tsconfig.json",
46
+ "test": "npm run build && node --test test/*.test.ts",
47
+ "typecheck": "npm run build && tsc -p tsconfig.test.json",
48
+ "prepublishOnly": "npm run build"
49
+ },
50
+ "peerDependencies": {
51
+ "@estiva-app/protocol": ">=0.15.0"
52
+ },
53
+ "devDependencies": {
54
+ "@estiva-app/protocol": ">=0.15.0",
55
+ "typescript": "~5.9.3"
56
+ }
57
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * "Something changed in this Folder" — a notification, not an event.
3
+ *
4
+ * Arrived in Peek as `foreignActivity.ts`, whose header already anticipated
5
+ * this move: *"A module-level emitter is what `liveTopics.ts` did, and SHA-7 is
6
+ * the bill for it. This exports a factory; the app instantiates one and passes
7
+ * it in, and a test makes its own."* It came over unchanged in substance.
8
+ *
9
+ * ## Why it carries no event
10
+ *
11
+ * Peek does not store other apps' records and should not start — Convex holds
12
+ * Peek's things, the relay holds the shared ones (ADR 0001). The panel reads
13
+ * those records off the relay directly, so the useful signal is *"re-read
14
+ * now"*, not the bytes.
15
+ *
16
+ * That also keeps it correct for kinds nobody has invented yet. A fourth app
17
+ * publishing its own records into a shared Folder gets a live panel without any
18
+ * reader learning what those records are.
19
+ *
20
+ * ## The rename
21
+ *
22
+ * "Foreign" was Peek's framing: the records belonged to another app. A workspace
23
+ * reader watching its own Folders is in the same position mechanically and the
24
+ * word stops fitting, so the shared name is about the Folder rather than about
25
+ * whose records are in it.
26
+ */
27
+ export type FolderListener = () => void
28
+
29
+ export interface FolderActivity {
30
+ /** Say that something changed in `folder`. Safe for a folder nobody watches. */
31
+ notify(folder: string): void
32
+ /** Watch one folder. Returns an unsubscribe; calling it twice is harmless. */
33
+ watch(folder: string, listener: FolderListener): () => void
34
+ /** Folders with at least one listener. Test and diagnostic seam. */
35
+ watched(): string[]
36
+ }
37
+
38
+ export function createFolderActivity(): FolderActivity {
39
+ const listeners = new Map<string, Set<FolderListener>>()
40
+ return {
41
+ notify(folder) {
42
+ for (const listener of listeners.get(folder) ?? []) {
43
+ // One bad listener must not stop the others, and must not surface
44
+ // somewhere unrelated as a relay error.
45
+ try {
46
+ listener()
47
+ } catch {
48
+ /* a listener's own problem */
49
+ }
50
+ }
51
+ },
52
+ watch(folder, listener) {
53
+ let forFolder = listeners.get(folder)
54
+ if (!forFolder) {
55
+ forFolder = new Set()
56
+ listeners.set(folder, forFolder)
57
+ }
58
+ forFolder.add(listener)
59
+ return () => {
60
+ const current = listeners.get(folder)
61
+ if (!current) return
62
+ current.delete(listener)
63
+ if (current.size === 0) listeners.delete(folder)
64
+ }
65
+ },
66
+ watched: () => [...listeners.keys()],
67
+ }
68
+ }
package/src/index.ts ADDED
@@ -0,0 +1,36 @@
1
+ /**
2
+ * `@estiva-app/platform` — the browser lifecycle two Estiva apps share.
3
+ *
4
+ * Not the wire: `@estiva-app/protocol` owns event construction, ids, signatures
5
+ * and the two relay clients, and deliberately contains no fold. This package
6
+ * sits above it and holds the things that are about *running in a tab* — the
7
+ * one socket a tab should have, the credential read per connect, the network
8
+ * coming back, and (SHA-2) the PWA's manifest, service worker and update flow.
9
+ *
10
+ * ## The rule this package is here to keep
11
+ *
12
+ * ADR 0002 §10's four constraints, and the two that `liveTopics.ts` failed:
13
+ * nothing here imports an app's data layer, store or config, and every
14
+ * environment touch is an injected parameter rather than a module-level global.
15
+ * That is why {@link browserOnlineSource} takes its window and
16
+ * {@link createLiveClientHolder} hands the app a holder instead of keeping one.
17
+ *
18
+ * A module-level singleton is usually holding something it reached for rather
19
+ * than received, and the two failures arrive together.
20
+ */
21
+ export {
22
+ MAX_FOLDERS_PER_SUBSCRIPTION,
23
+ createLiveClient,
24
+ createLiveClientHolder,
25
+ type FolderWatch,
26
+ type LiveClient,
27
+ type LiveClientHolder,
28
+ type LiveClientObservers,
29
+ type LiveClientOptions,
30
+ } from './live-client.js'
31
+ export {
32
+ createFolderActivity,
33
+ type FolderActivity,
34
+ type FolderListener,
35
+ } from './folder-activity.js'
36
+ export { assumeOnline, browserOnlineSource, type OnlineSource, type OnlineTarget } from './online.js'