@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
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# @estiva-app/platform
|
|
2
|
+
|
|
3
|
+
## 0.1.0
|
|
4
|
+
|
|
5
|
+
First release. The plumbing a second consumer of the live socket needs, taken
|
|
6
|
+
out of `peek-app/src/nostr/liveTopics.ts` and refactored to take its
|
|
7
|
+
environment injected rather than relocated — ADR 0002 §10 scores that file ❌
|
|
8
|
+
on constraint 1 and ❌ on constraint 2, so a `git mv` was never available.
|
|
9
|
+
|
|
10
|
+
- `createLiveClient` / `createLiveClientHolder` — one socket per tab, the
|
|
11
|
+
credential read per connect, and the network coming back. The holder is a
|
|
12
|
+
mechanism; the single module binding stays in the app.
|
|
13
|
+
- `watchFolders` — a whole workspace in one REQ per 128 Folders, reporting
|
|
14
|
+
which Folder changed rather than what changed. This is the capability the
|
|
15
|
+
second consumer forced: `createChannelSubscriptions` is one REQ per channel,
|
|
16
|
+
which a 34-Folder workspace pays on every connect and reconnect against a
|
|
17
|
+
budget that refuses the 51st unpaced REQ.
|
|
18
|
+
- `createFolderActivity` — from Peek's `foreignActivity.ts`, which was already
|
|
19
|
+
written as a factory for this reason and came over unchanged in substance.
|
|
20
|
+
Renamed because "foreign" was Peek's framing: a workspace reader watching its
|
|
21
|
+
own Folders is in the same position mechanically.
|
|
22
|
+
- `browserOnlineSource` / `assumeOnline` — `navigator.onLine` and the two window
|
|
23
|
+
events as an injected source, so nothing here touches a global at module
|
|
24
|
+
scope and the suite runs with no DOM.
|
|
25
|
+
|
|
26
|
+
`liveProjection.ts` and `freshness.ts` stayed in Peek on purpose: the first is
|
|
27
|
+
Convex-shaped, the second is product voice.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Estiva
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# @estiva-app/platform
|
|
2
|
+
|
|
3
|
+
The browser lifecycle two Estiva apps share.
|
|
4
|
+
|
|
5
|
+
`@estiva-app/protocol` owns the wire — event construction, ids, signatures, and
|
|
6
|
+
the two relay clients — and deliberately contains no fold. This package sits
|
|
7
|
+
above it and holds the things that are about **running in a tab**: the one
|
|
8
|
+
socket a tab should have, its credential read on every connect, and the network
|
|
9
|
+
coming back.
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npm install @estiva-app/platform
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
`@estiva-app/protocol` is a peer dependency.
|
|
16
|
+
|
|
17
|
+
## Why it exists
|
|
18
|
+
|
|
19
|
+
`createLiveRelay` and `createChannelSubscriptions` were extracted into
|
|
20
|
+
`protocol` with a single caller, and the plumbing a *second* app needs never
|
|
21
|
+
moved with them. It stayed in `peek-app/src/nostr/liveTopics.ts`, which
|
|
22
|
+
[ADR 0002](https://github.com/estiva-app/estiva-docs/blob/main/decisions/0002-foundation-packages.md)
|
|
23
|
+
§10 scores ❌ on constraint 1 (imports) and ❌ on constraint 2
|
|
24
|
+
(environment injected), and calls "the bill SHA-7 is paying".
|
|
25
|
+
|
|
26
|
+
Three behaviours, each of which fails as a healthy-looking socket that delivers
|
|
27
|
+
nothing:
|
|
28
|
+
|
|
29
|
+
- **One instance per tab.** `max_subscriptions` is per connection and the relay
|
|
30
|
+
authenticates a connection once, so a connection per component mount is wrong
|
|
31
|
+
rather than merely wasteful. React StrictMode makes it concrete: anything
|
|
32
|
+
owned by an effect is built twice.
|
|
33
|
+
- **The credential read per connect, never captured.** A token captured at
|
|
34
|
+
module load survives a silent renewal as a dead credential, and a reconnect
|
|
35
|
+
then signs its `22242` with it.
|
|
36
|
+
- **The network coming back.** Reconnection is driven by `onclose`, and a
|
|
37
|
+
socket whose peer became unreachable stays `OPEN` until TCP gives up. Without
|
|
38
|
+
an `online`/`offline` signal it reports `live`, delivers nothing, and never
|
|
39
|
+
retries.
|
|
40
|
+
|
|
41
|
+
## Usage
|
|
42
|
+
|
|
43
|
+
```ts
|
|
44
|
+
import {
|
|
45
|
+
browserOnlineSource,
|
|
46
|
+
createLiveClientHolder,
|
|
47
|
+
} from '@estiva-app/platform'
|
|
48
|
+
|
|
49
|
+
// The app holds the one module-scope binding. This package holds none — that
|
|
50
|
+
// is the constraint-2 failure it exists to stop repeating.
|
|
51
|
+
const holder = createLiveClientHolder()
|
|
52
|
+
|
|
53
|
+
export function liveClient() {
|
|
54
|
+
return holder.get({
|
|
55
|
+
relayUrl: RELAY_URL,
|
|
56
|
+
// Read fresh on every connect attempt.
|
|
57
|
+
getCredential: () => {
|
|
58
|
+
const token = validToken()
|
|
59
|
+
return token && { accessToken: token.accessToken, pubkey: token.pubkey }
|
|
60
|
+
},
|
|
61
|
+
sign: signViaEstivaId,
|
|
62
|
+
online: browserOnlineSource(window),
|
|
63
|
+
subscriptionPrefix: 'my-app-',
|
|
64
|
+
// Told what happened, never asked what to do — your freshness model stays
|
|
65
|
+
// yours.
|
|
66
|
+
observe: { onState: recordRelayState, onOnline: recordOnline },
|
|
67
|
+
})
|
|
68
|
+
}
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
### Watching one channel, or a whole workspace
|
|
72
|
+
|
|
73
|
+
Two paths, and the difference is a cost rather than a preference.
|
|
74
|
+
|
|
75
|
+
```ts
|
|
76
|
+
// Per channel, refcounted: one REQ each, every consumer sees every event.
|
|
77
|
+
// What a message projection wants.
|
|
78
|
+
client.subscriptions.subscribe(channelUuid, (event) => project(event))
|
|
79
|
+
|
|
80
|
+
// A whole workspace in one REQ per 128 Folders. You are told which Folder
|
|
81
|
+
// changed, never what changed.
|
|
82
|
+
client.watchFolders(folderIds, (folder) => refresh(folder), { kinds: [9, 1111] })
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
`REQ` is a client→server frame, so it is metered on the relay's
|
|
86
|
+
`LimitType::WsEvents`. Measured against production: **50 unpaced REQs are
|
|
87
|
+
accepted and the 51st is refused** `rate-limited: quota exceeded`. So a
|
|
88
|
+
34-Folder workspace on the per-channel path spends 34 frames on every connect
|
|
89
|
+
*and every reconnect*; `watchFolders` spends one.
|
|
90
|
+
|
|
91
|
+
Holding subscriptions, by contrast, costs nothing measurable — the probe round
|
|
92
|
+
trip was 44.2 ms whether 1, 34 or 100 were open.
|
|
93
|
+
|
|
94
|
+
### One thing to know before relying on `watchFolders`
|
|
95
|
+
|
|
96
|
+
The wide filter is measured, not assumed: one filter carrying 34 `#h` delivered
|
|
97
|
+
34 of 34 live against `wss://estiva.estiva.app`.
|
|
98
|
+
|
|
99
|
+
It is also a property of the **deployed relay branch**. `nfb-demo-kinds` carries
|
|
100
|
+
a plural `extract_channel_ids_from_filters` that returns the whole channel set;
|
|
101
|
+
on `origin/main`'s singular version the same filter registers as a *global*
|
|
102
|
+
subscription, and `fan_out_scoped` branches on the event's `channel_id` and
|
|
103
|
+
never consults it for anything carrying an `h`. That build delivers **0 of 34,
|
|
104
|
+
with no error at all** — EOSE, `live`, silence.
|
|
105
|
+
|
|
106
|
+
So anything depending on this should be able to answer *"did an event I did not
|
|
107
|
+
publish actually arrive?"*, because nothing else distinguishes the two cases.
|
|
108
|
+
Pass `kinds` while you are at it: a kindless wide filter was refused outright on
|
|
109
|
+
that other build with `restricted: p-gated events require #p matching your
|
|
110
|
+
pubkey`.
|
|
111
|
+
|
|
112
|
+
## What this deliberately does not know
|
|
113
|
+
|
|
114
|
+
Where anything is stored. Peek's version took a `LiveProjectionApi`, and
|
|
115
|
+
extracting that as-is would have baked a Convex assumption into a package two
|
|
116
|
+
apps depend on. This delivers frames and connection state and nothing else, so
|
|
117
|
+
it bets on neither outcome of "do we need Convex": if Convex stays nothing here
|
|
118
|
+
changes, and if it goes this is already right.
|
|
119
|
+
|
|
120
|
+
Your freshness states and their copy are product voice and stay in your app.
|
|
121
|
+
`observe` is how they get what they need.
|
|
122
|
+
|
|
123
|
+
## License
|
|
124
|
+
|
|
125
|
+
MIT
|
|
@@ -0,0 +1,37 @@
|
|
|
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
|
+
export interface FolderActivity {
|
|
29
|
+
/** Say that something changed in `folder`. Safe for a folder nobody watches. */
|
|
30
|
+
notify(folder: string): void;
|
|
31
|
+
/** Watch one folder. Returns an unsubscribe; calling it twice is harmless. */
|
|
32
|
+
watch(folder: string, listener: FolderListener): () => void;
|
|
33
|
+
/** Folders with at least one listener. Test and diagnostic seam. */
|
|
34
|
+
watched(): string[];
|
|
35
|
+
}
|
|
36
|
+
export declare function createFolderActivity(): FolderActivity;
|
|
37
|
+
//# sourceMappingURL=folder-activity.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"folder-activity.d.ts","sourceRoot":"","sources":["../src/folder-activity.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,MAAM,MAAM,cAAc,GAAG,MAAM,IAAI,CAAA;AAEvC,MAAM,WAAW,cAAc;IAC7B,gFAAgF;IAChF,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAAA;IAC5B,8EAA8E;IAC9E,KAAK,CAAC,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,cAAc,GAAG,MAAM,IAAI,CAAA;IAC3D,oEAAoE;IACpE,OAAO,IAAI,MAAM,EAAE,CAAA;CACpB;AAED,wBAAgB,oBAAoB,IAAI,cAAc,CA8BrD"}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export function createFolderActivity() {
|
|
2
|
+
const listeners = new Map();
|
|
3
|
+
return {
|
|
4
|
+
notify(folder) {
|
|
5
|
+
for (const listener of listeners.get(folder) ?? []) {
|
|
6
|
+
// One bad listener must not stop the others, and must not surface
|
|
7
|
+
// somewhere unrelated as a relay error.
|
|
8
|
+
try {
|
|
9
|
+
listener();
|
|
10
|
+
}
|
|
11
|
+
catch {
|
|
12
|
+
/* a listener's own problem */
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
watch(folder, listener) {
|
|
17
|
+
let forFolder = listeners.get(folder);
|
|
18
|
+
if (!forFolder) {
|
|
19
|
+
forFolder = new Set();
|
|
20
|
+
listeners.set(folder, forFolder);
|
|
21
|
+
}
|
|
22
|
+
forFolder.add(listener);
|
|
23
|
+
return () => {
|
|
24
|
+
const current = listeners.get(folder);
|
|
25
|
+
if (!current)
|
|
26
|
+
return;
|
|
27
|
+
current.delete(listener);
|
|
28
|
+
if (current.size === 0)
|
|
29
|
+
listeners.delete(folder);
|
|
30
|
+
};
|
|
31
|
+
},
|
|
32
|
+
watched: () => [...listeners.keys()],
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
//# sourceMappingURL=folder-activity.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"folder-activity.js","sourceRoot":"","sources":["../src/folder-activity.ts"],"names":[],"mappings":"AAqCA,MAAM,UAAU,oBAAoB;IAClC,MAAM,SAAS,GAAG,IAAI,GAAG,EAA+B,CAAA;IACxD,OAAO;QACL,MAAM,CAAC,MAAM;YACX,KAAK,MAAM,QAAQ,IAAI,SAAS,CAAC,GAAG,CAAC,MAAM,CAAC,IAAI,EAAE,EAAE,CAAC;gBACnD,kEAAkE;gBAClE,wCAAwC;gBACxC,IAAI,CAAC;oBACH,QAAQ,EAAE,CAAA;gBACZ,CAAC;gBAAC,MAAM,CAAC;oBACP,8BAA8B;gBAChC,CAAC;YACH,CAAC;QACH,CAAC;QACD,KAAK,CAAC,MAAM,EAAE,QAAQ;YACpB,IAAI,SAAS,GAAG,SAAS,CAAC,GAAG,CAAC,MAAM,CAAC,CAAA;YACrC,IAAI,CAAC,SAAS,EAAE,CAAC;gBACf,SAAS,GAAG,IAAI,GAAG,EAAE,CAAA;gBACrB,SAAS,CAAC,GAAG,CAAC,MAAM,EAAE,SAAS,CAAC,CAAA;YAClC,CAAC;YACD,SAAS,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAA;YACvB,OAAO,GAAG,EAAE;gBACV,MAAM,OAAO,GAAG,SAAS,CAAC,GAAG,CAAC,MAAM,CAAC,CAAA;gBACrC,IAAI,CAAC,OAAO;oBAAE,OAAM;gBACpB,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAA;gBACxB,IAAI,OAAO,CAAC,IAAI,KAAK,CAAC;oBAAE,SAAS,CAAC,MAAM,CAAC,MAAM,CAAC,CAAA;YAClD,CAAC,CAAA;QACH,CAAC;QACD,OAAO,EAAE,GAAG,EAAE,CAAC,CAAC,GAAG,SAAS,CAAC,IAAI,EAAE,CAAC;KACrC,CAAA;AACH,CAAC"}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
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 { MAX_FOLDERS_PER_SUBSCRIPTION, createLiveClient, createLiveClientHolder, type FolderWatch, type LiveClient, type LiveClientHolder, type LiveClientObservers, type LiveClientOptions, } from './live-client.js';
|
|
22
|
+
export { createFolderActivity, type FolderActivity, type FolderListener, } from './folder-activity.js';
|
|
23
|
+
export { assumeOnline, browserOnlineSource, type OnlineSource, type OnlineTarget } from './online.js';
|
|
24
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AACH,OAAO,EACL,4BAA4B,EAC5B,gBAAgB,EAChB,sBAAsB,EACtB,KAAK,WAAW,EAChB,KAAK,UAAU,EACf,KAAK,gBAAgB,EACrB,KAAK,mBAAmB,EACxB,KAAK,iBAAiB,GACvB,MAAM,kBAAkB,CAAA;AACzB,OAAO,EACL,oBAAoB,EACpB,KAAK,cAAc,EACnB,KAAK,cAAc,GACpB,MAAM,sBAAsB,CAAA;AAC7B,OAAO,EAAE,YAAY,EAAE,mBAAmB,EAAE,KAAK,YAAY,EAAE,KAAK,YAAY,EAAE,MAAM,aAAa,CAAA"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
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 { MAX_FOLDERS_PER_SUBSCRIPTION, createLiveClient, createLiveClientHolder, } from './live-client.js';
|
|
22
|
+
export { createFolderActivity, } from './folder-activity.js';
|
|
23
|
+
export { assumeOnline, browserOnlineSource } from './online.js';
|
|
24
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AACH,OAAO,EACL,4BAA4B,EAC5B,gBAAgB,EAChB,sBAAsB,GAMvB,MAAM,kBAAkB,CAAA;AACzB,OAAO,EACL,oBAAoB,GAGrB,MAAM,sBAAsB,CAAA;AAC7B,OAAO,EAAE,YAAY,EAAE,mBAAmB,EAAwC,MAAM,aAAa,CAAA"}
|
|
@@ -0,0 +1,168 @@
|
|
|
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 { type ChannelSubscriptions, type LiveRelay, type LiveRelayOptions, type RelayCredential, type RelayState, type SignedEvent, type UnsignedEvent } from '@estiva-app/protocol';
|
|
40
|
+
import { type FolderListener } from './folder-activity.js';
|
|
41
|
+
import { type OnlineSource } 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 declare const MAX_FOLDERS_PER_SUBSCRIPTION = 128;
|
|
51
|
+
/**
|
|
52
|
+
* Told what happened, never asked what to do.
|
|
53
|
+
*
|
|
54
|
+
* Peek's freshness model — four states and the copy that goes with them — is
|
|
55
|
+
* product voice and stays in Peek. These are the observations it needs, so the
|
|
56
|
+
* app can derive its own states without this package holding any of them.
|
|
57
|
+
*/
|
|
58
|
+
export interface LiveClientObservers {
|
|
59
|
+
onState?(state: RelayState): void;
|
|
60
|
+
onOnline?(online: boolean): void;
|
|
61
|
+
/** No credential at all. Not yet "signed out" — a silent renewal passes through here. */
|
|
62
|
+
onCredentialMissing?(): void;
|
|
63
|
+
onSignOk?(): void;
|
|
64
|
+
onSignFailure?(error: unknown): void;
|
|
65
|
+
}
|
|
66
|
+
export interface LiveClientOptions {
|
|
67
|
+
/** `https://…` or `wss://…`. Where it comes from is the app's decision. */
|
|
68
|
+
relayUrl: string;
|
|
69
|
+
/** Read fresh on every connect attempt, so a reconnect picks up a renewal. */
|
|
70
|
+
getCredential(): RelayCredential | null;
|
|
71
|
+
/**
|
|
72
|
+
* Injected so nothing here fetches. Peek passes `signViaEstivaId`; a keyed
|
|
73
|
+
* client passes a `signEvent` wrapper, and must set `unsigned.pubkey` itself
|
|
74
|
+
* because `createLiveRelay` leaves it empty for a remote `/sign` to fill.
|
|
75
|
+
*/
|
|
76
|
+
sign(unsigned: UnsignedEvent, token: string, expectedPubkey: string): Promise<SignedEvent>;
|
|
77
|
+
/** Defaults to {@link assumeOnline} — a package that assumed a browser would be wrong. */
|
|
78
|
+
online?: OnlineSource;
|
|
79
|
+
observe?: LiveClientObservers;
|
|
80
|
+
/** Appears in the relay operator's logs and nowhere else. Name your app. */
|
|
81
|
+
subscriptionPrefix?: string;
|
|
82
|
+
/** Never receives event bodies or credentials. */
|
|
83
|
+
log?(message: string, detail?: Record<string, unknown>): void;
|
|
84
|
+
/** Test seams, passed through untouched. */
|
|
85
|
+
socketFactory?: LiveRelayOptions['socketFactory'];
|
|
86
|
+
now?: LiveRelayOptions['now'];
|
|
87
|
+
setTimer?: LiveRelayOptions['setTimer'];
|
|
88
|
+
clearTimer?: LiveRelayOptions['clearTimer'];
|
|
89
|
+
backoff?: LiveRelayOptions['backoff'];
|
|
90
|
+
}
|
|
91
|
+
export interface FolderWatch {
|
|
92
|
+
/** Idempotent. Closes the REQs and forgets the listeners. */
|
|
93
|
+
stop(): void;
|
|
94
|
+
}
|
|
95
|
+
export interface LiveClient {
|
|
96
|
+
/** The socket. Exposed because an app with one subscription needs no more than this. */
|
|
97
|
+
readonly relay: LiveRelay;
|
|
98
|
+
/**
|
|
99
|
+
* Per-channel and refcounted: one REQ per channel, every consumer sees every
|
|
100
|
+
* event. What a message projection wants, and what Peek uses.
|
|
101
|
+
*/
|
|
102
|
+
readonly subscriptions: ChannelSubscriptions;
|
|
103
|
+
state(): RelayState;
|
|
104
|
+
onState(listener: (state: RelayState) => void): () => void;
|
|
105
|
+
/**
|
|
106
|
+
* Watch a whole workspace with **one REQ per 128 Folders**, and be told which
|
|
107
|
+
* Folder changed rather than what changed.
|
|
108
|
+
*
|
|
109
|
+
* This is the capability a second consumer forced, and it is worth saying why
|
|
110
|
+
* {@link subscriptions} is not it: that path is one REQ per channel, so a
|
|
111
|
+
* 34-Folder workspace spends 34 client→server frames on every connect and
|
|
112
|
+
* every reconnect. `REQ` is metered on `LimitType::WsEvents`, and 50 unpaced
|
|
113
|
+
* REQs are accepted before the 51st is refused `rate-limited: quota
|
|
114
|
+
* exceeded` — measured against production, LIV-1. A wide filter spends one.
|
|
115
|
+
*
|
|
116
|
+
* The wide filter is measured, not assumed: one filter carrying 34 `#h`
|
|
117
|
+
* delivered 34 of 34 live against `wss://estiva.estiva.app`. It is also a
|
|
118
|
+
* property of the *deployed relay branch* — `nfb-demo-kinds` carries the
|
|
119
|
+
* plural `extract_channel_ids_from_filters`, and on `origin/main`'s singular
|
|
120
|
+
* version the same filter files as a global subscription and `fan_out_scoped`
|
|
121
|
+
* never consults it for an event carrying an `h`. That build delivers 0 of 34
|
|
122
|
+
* with no error at all: EOSE, `live`, silence. Anything relying on this should
|
|
123
|
+
* be able to answer "did an event I did not publish arrive", because nothing
|
|
124
|
+
* else distinguishes the two.
|
|
125
|
+
*
|
|
126
|
+
* `kinds` is strongly recommended rather than optional-by-taste: a kindless
|
|
127
|
+
* wide filter was refused outright on that other build with `restricted:
|
|
128
|
+
* p-gated events require #p matching your pubkey`.
|
|
129
|
+
*/
|
|
130
|
+
watchFolders(folders: string[], listener: (folder: string) => void, options?: {
|
|
131
|
+
kinds?: number[];
|
|
132
|
+
}): FolderWatch;
|
|
133
|
+
/** Watch one Folder by name. The refcounted half of {@link watchFolders}. */
|
|
134
|
+
onFolderActivity(folder: string, listener: FolderListener): () => void;
|
|
135
|
+
/** Release everything and stop reconnecting. */
|
|
136
|
+
close(): void;
|
|
137
|
+
}
|
|
138
|
+
export declare function createLiveClient(options: LiveClientOptions): LiveClient;
|
|
139
|
+
export interface LiveClientHolder {
|
|
140
|
+
/**
|
|
141
|
+
* The tab's client, built on first use.
|
|
142
|
+
*
|
|
143
|
+
* A changed relay origin means a different deployment, so the old client is
|
|
144
|
+
* torn down rather than left pointed at it. Not expected in a page's lifetime.
|
|
145
|
+
*/
|
|
146
|
+
get(options: LiveClientOptions): LiveClient;
|
|
147
|
+
/**
|
|
148
|
+
* The client if one exists, without building it.
|
|
149
|
+
*
|
|
150
|
+
* A consumer that only wants to listen has no business holding whatever the
|
|
151
|
+
* builder needed — in Peek's case the Convex actions, which would otherwise
|
|
152
|
+
* appear in the dependency list of a component that reads the relay directly.
|
|
153
|
+
* `null` before anything has connected is a real state, not an error: the
|
|
154
|
+
* caller falls back to its timer.
|
|
155
|
+
*/
|
|
156
|
+
peek(): LiveClient | null;
|
|
157
|
+
/** Drops the instance so the next `get` builds a new one. Test seam. */
|
|
158
|
+
reset(): void;
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Somewhere to keep the one client, without this package keeping it.
|
|
162
|
+
*
|
|
163
|
+
* The app holds the single module-scope binding — `const holder =
|
|
164
|
+
* createLiveClientHolder()` — which is the one place a per-tab singleton can
|
|
165
|
+
* live without making the package un-instantiable twice.
|
|
166
|
+
*/
|
|
167
|
+
export declare function createLiveClientHolder(): LiveClientHolder;
|
|
168
|
+
//# sourceMappingURL=live-client.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"live-client.d.ts","sourceRoot":"","sources":["../src/live-client.ts"],"names":[],"mappings":"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"}
|