@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.
- package/CHANGELOG.md +27 -0
- package/LICENSE +21 -0
- package/README.md +125 -0
- package/dist/folder-activity.d.ts +37 -0
- package/dist/folder-activity.d.ts.map +1 -0
- package/dist/folder-activity.js +35 -0
- package/dist/folder-activity.js.map +1 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +24 -0
- package/dist/index.js.map +1 -0
- package/dist/live-client.d.ts +168 -0
- package/dist/live-client.d.ts.map +1 -0
- package/dist/live-client.js +195 -0
- package/dist/live-client.js.map +1 -0
- package/dist/online.d.ts +49 -0
- package/dist/online.d.ts.map +1 -0
- package/dist/online.js +76 -0
- package/dist/online.js.map +1 -0
- package/package.json +57 -0
- package/src/folder-activity.ts +68 -0
- package/src/index.ts +36 -0
- package/src/live-client.ts +326 -0
- package/src/online.ts +92 -0
|
@@ -0,0 +1,326 @@
|
|
|
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 {
|
|
40
|
+
createChannelSubscriptions,
|
|
41
|
+
createLiveRelay,
|
|
42
|
+
type ChannelSubscriptions,
|
|
43
|
+
type LiveRelay,
|
|
44
|
+
type LiveRelayOptions,
|
|
45
|
+
type RelayCredential,
|
|
46
|
+
type RelayState,
|
|
47
|
+
type SignedEvent,
|
|
48
|
+
type UnsignedEvent,
|
|
49
|
+
} from '@estiva-app/protocol'
|
|
50
|
+
import { createFolderActivity, type FolderActivity, type FolderListener } from './folder-activity.js'
|
|
51
|
+
import { assumeOnline, type OnlineSource } from './online.js'
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The relay's aggregate `#h` budget for one REQ, COUNT, `/query` or `/count`.
|
|
55
|
+
*
|
|
56
|
+
* `MAX_EXPLICIT_CHANNEL_VALUES` in `buzz-relay/src/handlers/req.rs`: "Explicit
|
|
57
|
+
* channels may each require an uncached membership lookup and, for a live WS
|
|
58
|
+
* subscription, a registry entry plus Redis topic retain." Exceeding it is
|
|
59
|
+
* refused, so {@link LiveClient.watchFolders} chunks rather than hoping.
|
|
60
|
+
*/
|
|
61
|
+
export const MAX_FOLDERS_PER_SUBSCRIPTION = 128
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Told what happened, never asked what to do.
|
|
65
|
+
*
|
|
66
|
+
* Peek's freshness model — four states and the copy that goes with them — is
|
|
67
|
+
* product voice and stays in Peek. These are the observations it needs, so the
|
|
68
|
+
* app can derive its own states without this package holding any of them.
|
|
69
|
+
*/
|
|
70
|
+
export interface LiveClientObservers {
|
|
71
|
+
onState?(state: RelayState): void
|
|
72
|
+
onOnline?(online: boolean): void
|
|
73
|
+
/** No credential at all. Not yet "signed out" — a silent renewal passes through here. */
|
|
74
|
+
onCredentialMissing?(): void
|
|
75
|
+
onSignOk?(): void
|
|
76
|
+
onSignFailure?(error: unknown): void
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface LiveClientOptions {
|
|
80
|
+
/** `https://…` or `wss://…`. Where it comes from is the app's decision. */
|
|
81
|
+
relayUrl: string
|
|
82
|
+
/** Read fresh on every connect attempt, so a reconnect picks up a renewal. */
|
|
83
|
+
getCredential(): RelayCredential | null
|
|
84
|
+
/**
|
|
85
|
+
* Injected so nothing here fetches. Peek passes `signViaEstivaId`; a keyed
|
|
86
|
+
* client passes a `signEvent` wrapper, and must set `unsigned.pubkey` itself
|
|
87
|
+
* because `createLiveRelay` leaves it empty for a remote `/sign` to fill.
|
|
88
|
+
*/
|
|
89
|
+
sign(unsigned: UnsignedEvent, token: string, expectedPubkey: string): Promise<SignedEvent>
|
|
90
|
+
/** Defaults to {@link assumeOnline} — a package that assumed a browser would be wrong. */
|
|
91
|
+
online?: OnlineSource
|
|
92
|
+
observe?: LiveClientObservers
|
|
93
|
+
/** Appears in the relay operator's logs and nowhere else. Name your app. */
|
|
94
|
+
subscriptionPrefix?: string
|
|
95
|
+
/** Never receives event bodies or credentials. */
|
|
96
|
+
log?(message: string, detail?: Record<string, unknown>): void
|
|
97
|
+
/** Test seams, passed through untouched. */
|
|
98
|
+
socketFactory?: LiveRelayOptions['socketFactory']
|
|
99
|
+
now?: LiveRelayOptions['now']
|
|
100
|
+
setTimer?: LiveRelayOptions['setTimer']
|
|
101
|
+
clearTimer?: LiveRelayOptions['clearTimer']
|
|
102
|
+
backoff?: LiveRelayOptions['backoff']
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface FolderWatch {
|
|
106
|
+
/** Idempotent. Closes the REQs and forgets the listeners. */
|
|
107
|
+
stop(): void
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export interface LiveClient {
|
|
111
|
+
/** The socket. Exposed because an app with one subscription needs no more than this. */
|
|
112
|
+
readonly relay: LiveRelay
|
|
113
|
+
/**
|
|
114
|
+
* Per-channel and refcounted: one REQ per channel, every consumer sees every
|
|
115
|
+
* event. What a message projection wants, and what Peek uses.
|
|
116
|
+
*/
|
|
117
|
+
readonly subscriptions: ChannelSubscriptions
|
|
118
|
+
state(): RelayState
|
|
119
|
+
onState(listener: (state: RelayState) => void): () => void
|
|
120
|
+
/**
|
|
121
|
+
* Watch a whole workspace with **one REQ per 128 Folders**, and be told which
|
|
122
|
+
* Folder changed rather than what changed.
|
|
123
|
+
*
|
|
124
|
+
* This is the capability a second consumer forced, and it is worth saying why
|
|
125
|
+
* {@link subscriptions} is not it: that path is one REQ per channel, so a
|
|
126
|
+
* 34-Folder workspace spends 34 client→server frames on every connect and
|
|
127
|
+
* every reconnect. `REQ` is metered on `LimitType::WsEvents`, and 50 unpaced
|
|
128
|
+
* REQs are accepted before the 51st is refused `rate-limited: quota
|
|
129
|
+
* exceeded` — measured against production, LIV-1. A wide filter spends one.
|
|
130
|
+
*
|
|
131
|
+
* The wide filter is measured, not assumed: one filter carrying 34 `#h`
|
|
132
|
+
* delivered 34 of 34 live against `wss://estiva.estiva.app`. It is also a
|
|
133
|
+
* property of the *deployed relay branch* — `nfb-demo-kinds` carries the
|
|
134
|
+
* plural `extract_channel_ids_from_filters`, and on `origin/main`'s singular
|
|
135
|
+
* version the same filter files as a global subscription and `fan_out_scoped`
|
|
136
|
+
* never consults it for an event carrying an `h`. That build delivers 0 of 34
|
|
137
|
+
* with no error at all: EOSE, `live`, silence. Anything relying on this should
|
|
138
|
+
* be able to answer "did an event I did not publish arrive", because nothing
|
|
139
|
+
* else distinguishes the two.
|
|
140
|
+
*
|
|
141
|
+
* `kinds` is strongly recommended rather than optional-by-taste: a kindless
|
|
142
|
+
* wide filter was refused outright on that other build with `restricted:
|
|
143
|
+
* p-gated events require #p matching your pubkey`.
|
|
144
|
+
*/
|
|
145
|
+
watchFolders(
|
|
146
|
+
folders: string[],
|
|
147
|
+
listener: (folder: string) => void,
|
|
148
|
+
options?: { kinds?: number[] },
|
|
149
|
+
): FolderWatch
|
|
150
|
+
/** Watch one Folder by name. The refcounted half of {@link watchFolders}. */
|
|
151
|
+
onFolderActivity(folder: string, listener: FolderListener): () => void
|
|
152
|
+
/** Release everything and stop reconnecting. */
|
|
153
|
+
close(): void
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Call an app's observer without letting it break the socket.
|
|
158
|
+
*
|
|
159
|
+
* These are the app's callbacks — Peek's `freshness.ts` recorders — and they run
|
|
160
|
+
* on the connect, sign and state paths. One of them throwing used to be able to
|
|
161
|
+
* skip the `relay.reconnect()` on the line after it, which leaves a socket
|
|
162
|
+
* reporting `live` and delivering nothing: the exact failure the online source
|
|
163
|
+
* exists to prevent, reintroduced by the thing observing it.
|
|
164
|
+
*/
|
|
165
|
+
function safely(run: () => void): void {
|
|
166
|
+
try {
|
|
167
|
+
run()
|
|
168
|
+
} catch {
|
|
169
|
+
/* an observer's own problem; never the socket's */
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** The `h` tag is the Folder — RFC 0.4 §5.2, measured across all production channels. */
|
|
174
|
+
function folderOf(event: SignedEvent): string | undefined {
|
|
175
|
+
return event.tags.find((tag) => tag[0] === 'h')?.[1]
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function chunk<T>(items: T[], size: number): T[][] {
|
|
179
|
+
const out: T[][] = []
|
|
180
|
+
for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size))
|
|
181
|
+
return out
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function createLiveClient(options: LiveClientOptions): LiveClient {
|
|
185
|
+
const stateListeners = new Set<(state: RelayState) => void>()
|
|
186
|
+
const activity: FolderActivity = createFolderActivity()
|
|
187
|
+
const online = options.online ?? assumeOnline()
|
|
188
|
+
let state: RelayState = 'connecting'
|
|
189
|
+
|
|
190
|
+
const relay = createLiveRelay({
|
|
191
|
+
url: options.relayUrl,
|
|
192
|
+
getCredential: () => {
|
|
193
|
+
const credential = options.getCredential()
|
|
194
|
+
if (!credential) {
|
|
195
|
+
safely(() => options.observe?.onCredentialMissing?.())
|
|
196
|
+
return null
|
|
197
|
+
}
|
|
198
|
+
return credential
|
|
199
|
+
},
|
|
200
|
+
sign: async (unsigned, token, expectedPubkey) => {
|
|
201
|
+
try {
|
|
202
|
+
const signed = await options.sign(unsigned, token, expectedPubkey)
|
|
203
|
+
safely(() => options.observe?.onSignOk?.())
|
|
204
|
+
return signed
|
|
205
|
+
} catch (error) {
|
|
206
|
+
// The status is the honest signal, and "expired" versus "this app
|
|
207
|
+
// cannot verify the token" decides whether sending somebody to sign in
|
|
208
|
+
// again helps or wastes their time.
|
|
209
|
+
safely(() => options.observe?.onSignFailure?.(error))
|
|
210
|
+
throw error
|
|
211
|
+
}
|
|
212
|
+
},
|
|
213
|
+
onState: (next) => {
|
|
214
|
+
state = next
|
|
215
|
+
safely(() => options.observe?.onState?.(next))
|
|
216
|
+
for (const listener of [...stateListeners]) safely(() => listener(next))
|
|
217
|
+
},
|
|
218
|
+
subscriptionPrefix: options.subscriptionPrefix,
|
|
219
|
+
log: options.log,
|
|
220
|
+
socketFactory: options.socketFactory,
|
|
221
|
+
now: options.now,
|
|
222
|
+
setTimer: options.setTimer,
|
|
223
|
+
clearTimer: options.clearTimer,
|
|
224
|
+
backoff: options.backoff,
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
const subscriptions = createChannelSubscriptions(relay, {
|
|
228
|
+
onListenerError: (error) => options.log?.('a channel listener threw', { error: String(error) }),
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
safely(() => options.observe?.onOnline?.(online.online()))
|
|
232
|
+
const releaseOnline = online.subscribe((isOnline) => {
|
|
233
|
+
safely(() => options.observe?.onOnline?.(isOnline))
|
|
234
|
+
// `offline` drops the socket so the reported state becomes honest at once;
|
|
235
|
+
// `online` retries immediately rather than waiting out a backoff that may
|
|
236
|
+
// have grown to half a minute while there was no network to reach.
|
|
237
|
+
relay.reconnect()
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
return {
|
|
241
|
+
relay,
|
|
242
|
+
subscriptions,
|
|
243
|
+
state: () => state,
|
|
244
|
+
onState(listener) {
|
|
245
|
+
stateListeners.add(listener)
|
|
246
|
+
return () => stateListeners.delete(listener)
|
|
247
|
+
},
|
|
248
|
+
onFolderActivity: (folder, listener) => activity.watch(folder, listener),
|
|
249
|
+
watchFolders(folders, listener, watchOptions) {
|
|
250
|
+
const unwatch = folders.map((folder) => activity.watch(folder, () => listener(folder)))
|
|
251
|
+
const subs = chunk([...new Set(folders)], MAX_FOLDERS_PER_SUBSCRIPTION).map((group) =>
|
|
252
|
+
relay.subscribe(
|
|
253
|
+
[{ ...(watchOptions?.kinds ? { kinds: watchOptions.kinds } : {}), '#h': group }],
|
|
254
|
+
(event) => {
|
|
255
|
+
const folder = folderOf(event)
|
|
256
|
+
if (folder) activity.notify(folder)
|
|
257
|
+
},
|
|
258
|
+
{
|
|
259
|
+
onClosed: (reason) => options.log?.('folder subscription closed', { reason }),
|
|
260
|
+
},
|
|
261
|
+
),
|
|
262
|
+
)
|
|
263
|
+
let stopped = false
|
|
264
|
+
return {
|
|
265
|
+
stop() {
|
|
266
|
+
if (stopped) return
|
|
267
|
+
stopped = true
|
|
268
|
+
for (const sub of subs) sub.close()
|
|
269
|
+
for (const release of unwatch) release()
|
|
270
|
+
},
|
|
271
|
+
}
|
|
272
|
+
},
|
|
273
|
+
close() {
|
|
274
|
+
releaseOnline()
|
|
275
|
+
subscriptions.close()
|
|
276
|
+
relay.close()
|
|
277
|
+
stateListeners.clear()
|
|
278
|
+
},
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
export interface LiveClientHolder {
|
|
283
|
+
/**
|
|
284
|
+
* The tab's client, built on first use.
|
|
285
|
+
*
|
|
286
|
+
* A changed relay origin means a different deployment, so the old client is
|
|
287
|
+
* torn down rather than left pointed at it. Not expected in a page's lifetime.
|
|
288
|
+
*/
|
|
289
|
+
get(options: LiveClientOptions): LiveClient
|
|
290
|
+
/**
|
|
291
|
+
* The client if one exists, without building it.
|
|
292
|
+
*
|
|
293
|
+
* A consumer that only wants to listen has no business holding whatever the
|
|
294
|
+
* builder needed — in Peek's case the Convex actions, which would otherwise
|
|
295
|
+
* appear in the dependency list of a component that reads the relay directly.
|
|
296
|
+
* `null` before anything has connected is a real state, not an error: the
|
|
297
|
+
* caller falls back to its timer.
|
|
298
|
+
*/
|
|
299
|
+
peek(): LiveClient | null
|
|
300
|
+
/** Drops the instance so the next `get` builds a new one. Test seam. */
|
|
301
|
+
reset(): void
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Somewhere to keep the one client, without this package keeping it.
|
|
306
|
+
*
|
|
307
|
+
* The app holds the single module-scope binding — `const holder =
|
|
308
|
+
* createLiveClientHolder()` — which is the one place a per-tab singleton can
|
|
309
|
+
* live without making the package un-instantiable twice.
|
|
310
|
+
*/
|
|
311
|
+
export function createLiveClientHolder(): LiveClientHolder {
|
|
312
|
+
let current: { relayUrl: string; client: LiveClient } | null = null
|
|
313
|
+
return {
|
|
314
|
+
get(options) {
|
|
315
|
+
if (current && current.relayUrl === options.relayUrl) return current.client
|
|
316
|
+
current?.client.close()
|
|
317
|
+
current = { relayUrl: options.relayUrl, client: createLiveClient(options) }
|
|
318
|
+
return current.client
|
|
319
|
+
},
|
|
320
|
+
peek: () => current?.client ?? null,
|
|
321
|
+
reset() {
|
|
322
|
+
current?.client.close()
|
|
323
|
+
current = null
|
|
324
|
+
},
|
|
325
|
+
}
|
|
326
|
+
}
|
package/src/online.ts
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
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
|
+
/** What this package needs of `window`. Deliberately tiny. */
|
|
22
|
+
export interface OnlineTarget {
|
|
23
|
+
navigator: { onLine: boolean }
|
|
24
|
+
addEventListener(type: 'online' | 'offline', listener: () => void): void
|
|
25
|
+
removeEventListener(type: 'online' | 'offline', listener: () => void): void
|
|
26
|
+
}
|
|
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
|
+
/**
|
|
36
|
+
* `navigator.onLine` and the two window events, as a source.
|
|
37
|
+
*
|
|
38
|
+
* The events are the one authority here a socket cannot second-guess, which is
|
|
39
|
+
* why this exists at all rather than a timer.
|
|
40
|
+
*/
|
|
41
|
+
export function browserOnlineSource(target: OnlineTarget): OnlineSource {
|
|
42
|
+
const listeners = new Set<(online: boolean) => void>()
|
|
43
|
+
let attached = false
|
|
44
|
+
|
|
45
|
+
const announce = (online: boolean) => {
|
|
46
|
+
for (const listener of [...listeners]) {
|
|
47
|
+
// Isolated, for the same reason `folder-activity` isolates its own: the
|
|
48
|
+
// most important subscriber here is the one that reconnects the socket,
|
|
49
|
+
// and one app listener throwing must not be what stops it running.
|
|
50
|
+
try {
|
|
51
|
+
listener(online)
|
|
52
|
+
} catch {
|
|
53
|
+
/* a listener's own problem */
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
const onOnline = () => announce(true)
|
|
58
|
+
const onOffline = () => announce(false)
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
online: () => target.navigator.onLine,
|
|
62
|
+
subscribe(listener) {
|
|
63
|
+
listeners.add(listener)
|
|
64
|
+
if (!attached) {
|
|
65
|
+
// Attached on first use and never in module scope, so importing this
|
|
66
|
+
// package registers nothing.
|
|
67
|
+
target.addEventListener('online', onOnline)
|
|
68
|
+
target.addEventListener('offline', onOffline)
|
|
69
|
+
attached = true
|
|
70
|
+
}
|
|
71
|
+
return () => {
|
|
72
|
+
listeners.delete(listener)
|
|
73
|
+
if (listeners.size === 0 && attached) {
|
|
74
|
+
target.removeEventListener('online', onOnline)
|
|
75
|
+
target.removeEventListener('offline', onOffline)
|
|
76
|
+
attached = false
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Always online, and never changes.
|
|
85
|
+
*
|
|
86
|
+
* For a consumer with no `window` — a Node probe, the agent, a server-side
|
|
87
|
+
* test. Not a stub: "this environment has no opinion" is a real answer, and it
|
|
88
|
+
* is better than a package that assumes a browser.
|
|
89
|
+
*/
|
|
90
|
+
export function assumeOnline(): OnlineSource {
|
|
91
|
+
return { online: () => true, subscribe: () => () => {} }
|
|
92
|
+
}
|