@kehto/services 0.21.1 → 0.22.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/README.md +42 -6
- package/dist/cvm-nostr-transport.d.ts +6 -0
- package/dist/cvm-nostr-transport.js +447 -13
- package/dist/cvm-nostr-transport.js.map +1 -1
- package/dist/index.d.ts +6 -13
- package/dist/index.js +39 -31
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -251,6 +251,8 @@ export interface HostKeysBridge {
|
|
|
251
251
|
* - invoke `callback` exactly once per matching chord event (implementations
|
|
252
252
|
* are responsible for any OS-autorepeat filtering)
|
|
253
253
|
* - invoke `callback` synchronously during the event delivery
|
|
254
|
+
* - remove only this subscription when its unsubscribe handle is called;
|
|
255
|
+
* other callbacks for the same chord must remain live
|
|
254
256
|
* - accept the string chord format documented by @napplet/nap/keys
|
|
255
257
|
* (e.g. `'Ctrl+Shift+K'`, `'Cmd+P'`)
|
|
256
258
|
*/
|
|
@@ -300,16 +302,33 @@ keys.destroy();
|
|
|
300
302
|
Custom bridge path — swap in Electron's `globalShortcut`:
|
|
301
303
|
|
|
302
304
|
```ts
|
|
303
|
-
import { createKeysService, type HostKeysBridge } from '@kehto/services';
|
|
305
|
+
import { createKeysService, type HostKeysBridge, type HostKeyEvent } from '@kehto/services';
|
|
304
306
|
import { globalShortcut } from 'electron';
|
|
305
307
|
|
|
308
|
+
const subscribers = new Map<string, Set<(event: HostKeyEvent) => void>>();
|
|
306
309
|
const electronBridge: HostKeysBridge = {
|
|
307
310
|
subscribe(chord, cb) {
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
311
|
+
let callbacks = subscribers.get(chord);
|
|
312
|
+
if (!callbacks) {
|
|
313
|
+
const group = new Set<(event: HostKeyEvent) => void>();
|
|
314
|
+
const registered = globalShortcut.register(chord, () => {
|
|
315
|
+
for (const callback of group) callback({
|
|
316
|
+
key: '', code: '',
|
|
317
|
+
ctrlKey: false, altKey: false, shiftKey: false, metaKey: false,
|
|
318
|
+
});
|
|
319
|
+
});
|
|
320
|
+
if (!registered) throw new Error(`Cannot register ${chord}`);
|
|
321
|
+
callbacks = group;
|
|
322
|
+
subscribers.set(chord, callbacks);
|
|
323
|
+
}
|
|
324
|
+
callbacks.add(cb);
|
|
325
|
+
return () => {
|
|
326
|
+
if (!callbacks.delete(cb)) return;
|
|
327
|
+
if (callbacks.size === 0) {
|
|
328
|
+
globalShortcut.unregister(chord);
|
|
329
|
+
subscribers.delete(chord);
|
|
330
|
+
}
|
|
331
|
+
};
|
|
313
332
|
},
|
|
314
333
|
};
|
|
315
334
|
|
|
@@ -322,6 +341,23 @@ Plug a `HostKeysBridge` when the default document listener is insufficient: Elec
|
|
|
322
341
|
|
|
323
342
|
A napplet drives this end to end via `@napplet/sdk` — `keys.registerAction` to claim a chord and `keys.onAction` to receive dispatches against the real backend. After a successful bound registration, the service pushes a complete `keys.bindings` list for that napplet/window, using entries shaped as `{ actionId, key }`. It pushes the complete list again after `keys.unregisterAction` removes a binding, including an empty list when no bindings remain. Injected shims use that list to suppress locally-bound keydowns before forwarding.
|
|
324
343
|
|
|
344
|
+
Action IDs are local to the trusted window passed by the runtime. Two windows may
|
|
345
|
+
each register `game.a`, including with the same suggested key, without replacing
|
|
346
|
+
one another's bindings. Unregistering or destroying one window removes only its
|
|
347
|
+
actions and bridge subscriptions. Binding lists and `keys.action` messages retain
|
|
348
|
+
the original app-local action ID; callers do not need to invent window-specific IDs.
|
|
349
|
+
Re-registering an action in the same window retains the existing rebind behavior:
|
|
350
|
+
a valid available chord replaces that window's binding, a reserved chord removes
|
|
351
|
+
its previous binding, and a malformed chord or failed bridge subscription leaves
|
|
352
|
+
the previous binding intact.
|
|
353
|
+
|
|
354
|
+
This ownership scope is an explicit Kehto policy for the scope left implicit by
|
|
355
|
+
[NAP-KEYS PR #9 at `cecb642`](https://github.com/napplet/naps/blob/cecb64257e0ac29926bb746832a477c553ab307c/naps/NAP-KEYS.md).
|
|
356
|
+
The draft describes duplicate-action errors but does not mandate service-global
|
|
357
|
+
IDs or forbid same-owner updates. Window-scoped ownership preserves its per-napplet
|
|
358
|
+
binding delivery and adds no wire fields. Chord conflicts retain the existing
|
|
359
|
+
behavior: matching registered actions receive their own `keys.action` push.
|
|
360
|
+
|
|
325
361
|
### Reserved Chords
|
|
326
362
|
|
|
327
363
|
Shell-reserved chords let a host application (window manager, launcher shell, tiling WM) claim specific chords for its own dispatch regardless of what napplets subscribe to. Declare the reserved set once at service construction via the `reservedChords` option on [`KeysServiceOptions`](#keysserviceoptions):
|
|
@@ -14,6 +14,8 @@ import '@kehto/runtime';
|
|
|
14
14
|
* random key, `p`-tagged to the server. Responses arrive the same way,
|
|
15
15
|
* `p`-tagged to the client, and are correlated by the inner JSON-RPC `id`.
|
|
16
16
|
* - Discovery reads kind-11316 (server) + kind-11317 (tools) announcements.
|
|
17
|
+
* - CEP-22 oversized transfers and CEP-41 open streams ride
|
|
18
|
+
* `notifications/progress` frames addressed by the request progressToken.
|
|
17
19
|
*
|
|
18
20
|
* Shipped on a separate entry (`@kehto/services/cvm-nostr-transport`) so the
|
|
19
21
|
* `nostr-tools` dependency stays out of the core `@kehto/services` bundle.
|
|
@@ -84,6 +86,10 @@ interface NostrCvmTransportOptions {
|
|
|
84
86
|
name: string;
|
|
85
87
|
version: string;
|
|
86
88
|
};
|
|
89
|
+
/** CEP-41: per-stream idle timeout before a keepalive ping is sent. */
|
|
90
|
+
streamIdleTimeoutMs?: number;
|
|
91
|
+
/** CEP-41: probe deadline waiting for a pong after a keepalive ping. */
|
|
92
|
+
streamProbeTimeoutMs?: number;
|
|
87
93
|
}
|
|
88
94
|
/**
|
|
89
95
|
* Create a Nostr-backed ContextVM transport.
|
|
@@ -7,15 +7,35 @@ var KIND_GIFT_WRAP_EPHEMERAL = 21059;
|
|
|
7
7
|
var KIND_GIFT_WRAP_REGULAR = 1059;
|
|
8
8
|
var KIND_ANNOUNCE_SERVER = 11316;
|
|
9
9
|
var KIND_ANNOUNCE_TOOLS = 11317;
|
|
10
|
+
var SUPPORT_ENCRYPTION = "support_encryption";
|
|
11
|
+
var SUPPORT_ENCRYPTION_EPHEMERAL = "support_encryption_ephemeral";
|
|
12
|
+
var SUPPORT_OVERSIZED_TRANSFER = "support_oversized_transfer";
|
|
13
|
+
var SUPPORT_OPEN_STREAM = "support_open_stream";
|
|
10
14
|
var DEFAULT_TIMEOUT_MS = 3e4;
|
|
11
15
|
var DEFAULT_DISCOVER_TIMEOUT_MS = 6e3;
|
|
12
16
|
var MCP_PROTOCOL_VERSION = "2025-11-25";
|
|
13
17
|
var SEEN_WRAP_LIMIT = 512;
|
|
18
|
+
var DIGEST_PREFIX = "sha256:";
|
|
19
|
+
var MAX_TRANSFER_CHUNKS = 1e4;
|
|
20
|
+
var MAX_TRANSFER_BYTES = 100 * 1024 * 1024;
|
|
21
|
+
var MAX_ACTIVE_TRANSFERS = 8;
|
|
22
|
+
var MAX_TRANSFER_LIFETIME_MS = 12e4;
|
|
23
|
+
var COMPLETION_MODE_RENDER = "render";
|
|
24
|
+
var DEFAULT_STREAM_IDLE_TIMEOUT_MS = 3e4;
|
|
25
|
+
var DEFAULT_STREAM_PROBE_TIMEOUT_MS = 1e4;
|
|
26
|
+
var MAX_STREAM_BUFFERED_CHUNKS = 256;
|
|
27
|
+
var MAX_STREAM_BUFFERED_CODE_UNITS = 8 * 1024 * 1024;
|
|
28
|
+
var MAX_PING_NONCE_BYTES = 64;
|
|
14
29
|
var correlationCounter = 0;
|
|
15
30
|
function nextCorrelationId() {
|
|
16
31
|
correlationCounter += 1;
|
|
17
32
|
return `cvm-${correlationCounter}-${getPublicKey(generateSecretKey()).slice(0, 8)}`;
|
|
18
33
|
}
|
|
34
|
+
var streamNonceCounter = 0;
|
|
35
|
+
function nextStreamNonce() {
|
|
36
|
+
streamNonceCounter += 1;
|
|
37
|
+
return `cvm-ping-${streamNonceCounter}-${getPublicKey(generateSecretKey()).slice(0, 8)}`;
|
|
38
|
+
}
|
|
19
39
|
function randomizedPastTimestamp() {
|
|
20
40
|
const jitter = Math.floor(Math.random() * 172800);
|
|
21
41
|
return Math.floor(Date.now() / 1e3) - jitter;
|
|
@@ -23,6 +43,19 @@ function randomizedPastTimestamp() {
|
|
|
23
43
|
function tagValue(tags, name) {
|
|
24
44
|
return tags.find((tag) => tag[0] === name)?.[1];
|
|
25
45
|
}
|
|
46
|
+
function progressTokenKey(token) {
|
|
47
|
+
if (typeof token === "string") return token.length > 0 ? `s:${token}` : null;
|
|
48
|
+
if (typeof token === "number" && Number.isFinite(token)) return `n:${token}`;
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
function isBoundedInteger(value, min, max) {
|
|
52
|
+
return typeof value === "number" && Number.isInteger(value) && value >= min && value <= max;
|
|
53
|
+
}
|
|
54
|
+
async function sha256Hex(text) {
|
|
55
|
+
const bytes = new TextEncoder().encode(text);
|
|
56
|
+
const buf = await crypto.subtle.digest("SHA-256", bytes);
|
|
57
|
+
return [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
58
|
+
}
|
|
26
59
|
function simplePoolAdapter(sp) {
|
|
27
60
|
return {
|
|
28
61
|
subscribe(relays, filter, params) {
|
|
@@ -47,11 +80,14 @@ function createNostrCvmTransport(options = {}) {
|
|
|
47
80
|
const clientSecretKey = options.clientSecretKey ?? generateSecretKey();
|
|
48
81
|
const clientPubkey = getPublicKey(clientSecretKey);
|
|
49
82
|
const clientInfo = options.clientInfo ?? { name: "kehto-cvm", version: "1.0.0" };
|
|
83
|
+
const streamIdleTimeoutMs = options.streamIdleTimeoutMs ?? DEFAULT_STREAM_IDLE_TIMEOUT_MS;
|
|
84
|
+
const streamProbeTimeoutMs = options.streamProbeTimeoutMs ?? DEFAULT_STREAM_PROBE_TIMEOUT_MS;
|
|
50
85
|
const sessions = /* @__PURE__ */ new Map();
|
|
51
86
|
const pending = /* @__PURE__ */ new Map();
|
|
52
87
|
const eventHandlers = /* @__PURE__ */ new Set();
|
|
53
88
|
const relayRefcount = /* @__PURE__ */ new Map();
|
|
54
89
|
const seenWraps = /* @__PURE__ */ new Set();
|
|
90
|
+
const issuedTokens = /* @__PURE__ */ new Map();
|
|
55
91
|
let inbound = null;
|
|
56
92
|
let subscribedRelays = "";
|
|
57
93
|
function resolveRelays(server) {
|
|
@@ -113,23 +149,379 @@ function createNostrCvmTransport(options = {}) {
|
|
|
113
149
|
} catch {
|
|
114
150
|
return;
|
|
115
151
|
}
|
|
152
|
+
if (mcp.method === "notifications/progress") {
|
|
153
|
+
const params = mcp.params ?? {};
|
|
154
|
+
const cvm = params.cvm;
|
|
155
|
+
if (cvm && typeof cvm.type === "string") {
|
|
156
|
+
if (cvm.type === "oversized-transfer") {
|
|
157
|
+
void handleOversizedFrame(serverPubkey, params, cvm);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
if (cvm.type === "open-stream") {
|
|
161
|
+
const entry = admitFrame(serverPubkey, params.progressToken);
|
|
162
|
+
if (!entry) return;
|
|
163
|
+
handleOpenStreamFrame(serverPubkey, entry, params, cvm, mcp);
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
routeMessage(serverPubkey, mcp);
|
|
169
|
+
}
|
|
170
|
+
async function publishStreamFrame(entry, stream, frame) {
|
|
171
|
+
const session = sessions.get(entry.serverPubkey);
|
|
172
|
+
if (!session) return;
|
|
173
|
+
stream.localProgress += 1;
|
|
174
|
+
await publishMcp(
|
|
175
|
+
{ pubkey: entry.serverPubkey },
|
|
176
|
+
session.relays,
|
|
177
|
+
{
|
|
178
|
+
jsonrpc: "2.0",
|
|
179
|
+
method: "notifications/progress",
|
|
180
|
+
params: {
|
|
181
|
+
progressToken: stream.token,
|
|
182
|
+
progress: stream.localProgress,
|
|
183
|
+
cvm: { type: "open-stream", ...frame }
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
function admitFrame(serverPubkey, token) {
|
|
189
|
+
const tokenKey = progressTokenKey(token);
|
|
190
|
+
if (!tokenKey) return null;
|
|
191
|
+
const correlationId = issuedTokens.get(`${serverPubkey}:${tokenKey}`);
|
|
192
|
+
if (!correlationId) return null;
|
|
193
|
+
const entry = pending.get(correlationId);
|
|
194
|
+
if (!entry || entry.serverPubkey !== serverPubkey) return null;
|
|
195
|
+
return entry;
|
|
196
|
+
}
|
|
197
|
+
function releaseTransfer(entry) {
|
|
198
|
+
if (!entry.oversized) return;
|
|
199
|
+
clearTimeout(entry.oversized.expiryTimer);
|
|
200
|
+
entry.oversized = void 0;
|
|
201
|
+
}
|
|
202
|
+
function releaseStream(entry) {
|
|
203
|
+
if (!entry.stream) return;
|
|
204
|
+
clearTimeout(entry.stream.idleTimer);
|
|
205
|
+
if (entry.stream.probeTimer) clearTimeout(entry.stream.probeTimer);
|
|
206
|
+
entry.stream = void 0;
|
|
207
|
+
}
|
|
208
|
+
function endStream(entry) {
|
|
209
|
+
releaseStream(entry);
|
|
210
|
+
entry.streamEnded = true;
|
|
211
|
+
}
|
|
212
|
+
function failStream(entry, reason) {
|
|
213
|
+
const stream = entry.stream;
|
|
214
|
+
if (!stream || entry.streamEnded) return;
|
|
215
|
+
endStream(entry);
|
|
216
|
+
void publishStreamFrame(entry, stream, { frameType: "abort", reason }).catch(() => {
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
function resetStreamIdleTimer(entry, stream) {
|
|
220
|
+
clearTimeout(stream.idleTimer);
|
|
221
|
+
stream.idleTimer = setTimeout(() => onStreamIdle(entry), streamIdleTimeoutMs);
|
|
222
|
+
}
|
|
223
|
+
function onStreamIdle(entry) {
|
|
224
|
+
const stream = entry.stream;
|
|
225
|
+
if (!stream || entry.streamEnded) return;
|
|
226
|
+
if (stream.pendingNonce !== null) return;
|
|
227
|
+
const session = sessions.get(entry.serverPubkey);
|
|
228
|
+
if (!session) {
|
|
229
|
+
failStream(entry, "session closed");
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
stream.pendingNonce = nextStreamNonce();
|
|
233
|
+
stream.probeTimer = setTimeout(() => failStream(entry, "probe timeout"), streamProbeTimeoutMs);
|
|
234
|
+
void publishStreamFrame(entry, stream, { frameType: "ping", nonce: stream.pendingNonce }).catch(() => failStream(entry, "ping publication failed"));
|
|
235
|
+
}
|
|
236
|
+
function releaseFrameState(entry) {
|
|
237
|
+
releaseTransfer(entry);
|
|
238
|
+
releaseStream(entry);
|
|
239
|
+
if (entry.tokenKey && issuedTokens.get(entry.tokenKey) === entry.correlationId) {
|
|
240
|
+
issuedTokens.delete(entry.tokenKey);
|
|
241
|
+
}
|
|
242
|
+
entry.tokenKey = void 0;
|
|
243
|
+
}
|
|
244
|
+
function settlePending(correlationId) {
|
|
245
|
+
const entry = pending.get(correlationId);
|
|
246
|
+
if (!entry) return null;
|
|
247
|
+
clearTimeout(entry.timer);
|
|
248
|
+
pending.delete(correlationId);
|
|
249
|
+
releaseFrameState(entry);
|
|
250
|
+
return entry;
|
|
251
|
+
}
|
|
252
|
+
function countActiveTransfers() {
|
|
253
|
+
let active = 0;
|
|
254
|
+
for (const entry of pending.values()) {
|
|
255
|
+
if (entry.oversized) active += 1;
|
|
256
|
+
}
|
|
257
|
+
return active;
|
|
258
|
+
}
|
|
259
|
+
async function handleOversizedFrame(serverPubkey, params, cvm) {
|
|
260
|
+
const entry = admitFrame(serverPubkey, params.progressToken);
|
|
261
|
+
if (!entry) return;
|
|
262
|
+
const progress = Number(params.progress ?? NaN);
|
|
263
|
+
if (!Number.isSafeInteger(progress) || progress < 0) return;
|
|
264
|
+
if (cvm.frameType === "start") {
|
|
265
|
+
const digest = typeof cvm.digest === "string" ? cvm.digest : "";
|
|
266
|
+
const { totalBytes, totalChunks } = cvm;
|
|
267
|
+
if (cvm.completionMode !== COMPLETION_MODE_RENDER) return;
|
|
268
|
+
if (digest === "") return;
|
|
269
|
+
if (!isBoundedInteger(totalChunks, 1, MAX_TRANSFER_CHUNKS)) return;
|
|
270
|
+
if (!isBoundedInteger(totalBytes, 1, MAX_TRANSFER_BYTES)) return;
|
|
271
|
+
if (progress > Number.MAX_SAFE_INTEGER - totalChunks - 1) return;
|
|
272
|
+
if (!entry.oversized && countActiveTransfers() >= MAX_ACTIVE_TRANSFERS) return;
|
|
273
|
+
releaseTransfer(entry);
|
|
274
|
+
entry.oversized = {
|
|
275
|
+
digest,
|
|
276
|
+
totalBytes,
|
|
277
|
+
totalChunks,
|
|
278
|
+
startProgress: progress,
|
|
279
|
+
acceptProgress: null,
|
|
280
|
+
chunks: /* @__PURE__ */ new Map(),
|
|
281
|
+
codeUnitsReceived: 0,
|
|
282
|
+
// Hard expiry: progress frames extend the request deadline but never
|
|
283
|
+
// how long buffers may be held.
|
|
284
|
+
expiryTimer: setTimeout(() => releaseTransfer(entry), MAX_TRANSFER_LIFETIME_MS)
|
|
285
|
+
};
|
|
286
|
+
refreshPendingTimeout(entry);
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
const transfer = entry.oversized;
|
|
290
|
+
if (!transfer) return;
|
|
291
|
+
if (cvm.frameType === "accept") {
|
|
292
|
+
if (progress !== transfer.startProgress + 1) {
|
|
293
|
+
releaseTransfer(entry);
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
transfer.acceptProgress = progress;
|
|
297
|
+
refreshPendingTimeout(entry);
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
if (cvm.frameType === "chunk") {
|
|
301
|
+
if (typeof cvm.data !== "string" || progress <= transfer.startProgress) return;
|
|
302
|
+
if (transfer.chunks.has(progress)) return;
|
|
303
|
+
if (transfer.chunks.size >= transfer.totalChunks) {
|
|
304
|
+
releaseTransfer(entry);
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
transfer.codeUnitsReceived += cvm.data.length;
|
|
308
|
+
if (transfer.codeUnitsReceived > MAX_TRANSFER_BYTES) {
|
|
309
|
+
releaseTransfer(entry);
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
transfer.chunks.set(progress, cvm.data);
|
|
313
|
+
refreshPendingTimeout(entry);
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
if (cvm.frameType === "abort") {
|
|
317
|
+
releaseTransfer(entry);
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
if (cvm.frameType === "end") {
|
|
321
|
+
const message = await assembleTransfer(transfer, progress);
|
|
322
|
+
releaseTransfer(entry);
|
|
323
|
+
if (message) routeMessage(serverPubkey, message);
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
async function assembleTransfer(transfer, endProgress) {
|
|
328
|
+
const { startProgress, acceptProgress, totalChunks } = transfer;
|
|
329
|
+
const hasCompleteRange = (first) => {
|
|
330
|
+
for (let i = 0; i < totalChunks; i++) {
|
|
331
|
+
if (!transfer.chunks.has(first + i)) return false;
|
|
332
|
+
}
|
|
333
|
+
return true;
|
|
334
|
+
};
|
|
335
|
+
const directStart = startProgress + 1;
|
|
336
|
+
const acceptGatedStart = startProgress + 2;
|
|
337
|
+
const firstProgress = hasCompleteRange(directStart) && endProgress === directStart + totalChunks ? directStart : acceptProgress === startProgress + 1 && hasCompleteRange(acceptGatedStart) && endProgress === acceptGatedStart + totalChunks ? acceptGatedStart : null;
|
|
338
|
+
if (firstProgress === null) return null;
|
|
339
|
+
let payload = "";
|
|
340
|
+
for (let i = 0; i < totalChunks; i++) {
|
|
341
|
+
payload += transfer.chunks.get(firstProgress + i);
|
|
342
|
+
}
|
|
343
|
+
if (new TextEncoder().encode(payload).byteLength !== transfer.totalBytes) return null;
|
|
344
|
+
const expected = transfer.digest.startsWith(DIGEST_PREFIX) ? transfer.digest.slice(DIGEST_PREFIX.length) : transfer.digest;
|
|
345
|
+
if (await sha256Hex(payload) !== expected) return null;
|
|
346
|
+
try {
|
|
347
|
+
return JSON.parse(payload);
|
|
348
|
+
} catch {
|
|
349
|
+
return null;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
function handleOpenStreamFrame(serverPubkey, entry, params, cvm, mcp) {
|
|
353
|
+
const progress = Number(params.progress ?? NaN);
|
|
354
|
+
if (!Number.isSafeInteger(progress) || progress < 0) return;
|
|
355
|
+
if (entry.streamEnded) return;
|
|
356
|
+
const stream = entry.stream;
|
|
357
|
+
if (!stream) {
|
|
358
|
+
if (cvm.frameType !== "start") return;
|
|
359
|
+
entry.stream = {
|
|
360
|
+
token: params.progressToken,
|
|
361
|
+
lastProgress: progress,
|
|
362
|
+
localProgress: 0,
|
|
363
|
+
nextChunkIndex: 0,
|
|
364
|
+
outOfOrder: /* @__PURE__ */ new Map(),
|
|
365
|
+
outOfOrderCodeUnits: 0,
|
|
366
|
+
pendingNonce: null,
|
|
367
|
+
idleTimer: setTimeout(() => onStreamIdle(entry), streamIdleTimeoutMs),
|
|
368
|
+
probeTimer: null
|
|
369
|
+
};
|
|
370
|
+
refreshPendingTimeout(entry);
|
|
371
|
+
routeEvent(serverPubkey, mcp);
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
const admitOrderedActivity = () => {
|
|
375
|
+
if (progress <= stream.lastProgress) {
|
|
376
|
+
failStream(entry, "non-monotonic progress");
|
|
377
|
+
return false;
|
|
378
|
+
}
|
|
379
|
+
stream.lastProgress = progress;
|
|
380
|
+
resetStreamIdleTimer(entry, stream);
|
|
381
|
+
refreshPendingTimeout(entry);
|
|
382
|
+
return true;
|
|
383
|
+
};
|
|
384
|
+
switch (cvm.frameType) {
|
|
385
|
+
case "start":
|
|
386
|
+
failStream(entry, "duplicate start");
|
|
387
|
+
return;
|
|
388
|
+
case "accept":
|
|
389
|
+
if (!admitOrderedActivity()) return;
|
|
390
|
+
routeEvent(serverPubkey, mcp);
|
|
391
|
+
return;
|
|
392
|
+
case "chunk": {
|
|
393
|
+
if (typeof cvm.data !== "string" || !isBoundedInteger(cvm.chunkIndex, 0, Number.MAX_SAFE_INTEGER)) {
|
|
394
|
+
failStream(entry, "malformed chunk");
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
const index = cvm.chunkIndex;
|
|
398
|
+
if (index < stream.nextChunkIndex) return;
|
|
399
|
+
if (index > stream.nextChunkIndex) {
|
|
400
|
+
const existing = stream.outOfOrder.get(index);
|
|
401
|
+
if (existing) {
|
|
402
|
+
if (existing.progress !== progress || JSON.stringify(existing.message) !== JSON.stringify(mcp)) {
|
|
403
|
+
failStream(entry, "conflicting duplicate chunk");
|
|
404
|
+
}
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
if (progress <= stream.lastProgress || stream.outOfOrder.size >= MAX_STREAM_BUFFERED_CHUNKS || stream.outOfOrderCodeUnits + cvm.data.length > MAX_STREAM_BUFFERED_CODE_UNITS) {
|
|
408
|
+
failStream(entry, progress <= stream.lastProgress ? "non-monotonic progress" : "gap buffer exhausted");
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
stream.outOfOrder.set(index, { message: mcp, progress, codeUnits: cvm.data.length });
|
|
412
|
+
stream.outOfOrderCodeUnits += cvm.data.length;
|
|
413
|
+
resetStreamIdleTimer(entry, stream);
|
|
414
|
+
refreshPendingTimeout(entry);
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
const ready = [
|
|
418
|
+
{ message: mcp, progress, codeUnits: 0 }
|
|
419
|
+
];
|
|
420
|
+
let nextIndex = index + 1;
|
|
421
|
+
while (stream.outOfOrder.has(nextIndex)) {
|
|
422
|
+
ready.push(stream.outOfOrder.get(nextIndex));
|
|
423
|
+
nextIndex += 1;
|
|
424
|
+
}
|
|
425
|
+
let previousProgress = stream.lastProgress;
|
|
426
|
+
for (const item of ready) {
|
|
427
|
+
if (item.progress <= previousProgress) {
|
|
428
|
+
failStream(entry, "non-monotonic chunk order");
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
previousProgress = item.progress;
|
|
432
|
+
}
|
|
433
|
+
for (let bufferedIndex = index + 1; bufferedIndex < nextIndex; bufferedIndex++) {
|
|
434
|
+
const buffered = stream.outOfOrder.get(bufferedIndex);
|
|
435
|
+
stream.outOfOrder.delete(bufferedIndex);
|
|
436
|
+
stream.outOfOrderCodeUnits -= buffered.codeUnits;
|
|
437
|
+
}
|
|
438
|
+
for (const item of ready) {
|
|
439
|
+
routeEvent(serverPubkey, item.message);
|
|
440
|
+
stream.nextChunkIndex += 1;
|
|
441
|
+
}
|
|
442
|
+
stream.lastProgress = previousProgress;
|
|
443
|
+
resetStreamIdleTimer(entry, stream);
|
|
444
|
+
refreshPendingTimeout(entry);
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
case "ping": {
|
|
448
|
+
if (typeof cvm.nonce !== "string") return;
|
|
449
|
+
if (new TextEncoder().encode(cvm.nonce).byteLength > MAX_PING_NONCE_BYTES) return;
|
|
450
|
+
if (!admitOrderedActivity()) return;
|
|
451
|
+
void publishStreamFrame(entry, stream, { frameType: "pong", nonce: cvm.nonce }).catch(() => {
|
|
452
|
+
failStream(entry, "pong publication failed");
|
|
453
|
+
});
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
case "pong": {
|
|
457
|
+
if (stream.pendingNonce === null || cvm.nonce !== stream.pendingNonce) return;
|
|
458
|
+
if (!admitOrderedActivity()) return;
|
|
459
|
+
stream.pendingNonce = null;
|
|
460
|
+
if (stream.probeTimer) clearTimeout(stream.probeTimer);
|
|
461
|
+
stream.probeTimer = null;
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
case "close": {
|
|
465
|
+
if (cvm.lastChunkIndex !== void 0) {
|
|
466
|
+
const bound = cvm.lastChunkIndex;
|
|
467
|
+
const complete = isBoundedInteger(bound, 0, Number.MAX_SAFE_INTEGER) && stream.nextChunkIndex === bound + 1 && stream.outOfOrder.size === 0;
|
|
468
|
+
if (!complete) {
|
|
469
|
+
failStream(entry, "close with unresolved chunk gaps");
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
if (!admitOrderedActivity()) return;
|
|
474
|
+
routeEvent(serverPubkey, mcp);
|
|
475
|
+
endStream(entry);
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
case "abort":
|
|
479
|
+
if (!admitOrderedActivity()) return;
|
|
480
|
+
routeEvent(serverPubkey, mcp);
|
|
481
|
+
endStream(entry);
|
|
482
|
+
return;
|
|
483
|
+
default:
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
function refreshPendingTimeout(entry) {
|
|
488
|
+
clearTimeout(entry.timer);
|
|
489
|
+
entry.timer = setTimeout(() => {
|
|
490
|
+
const settled = settlePending(entry.correlationId);
|
|
491
|
+
settled?.reject(new Error("relay timeout"));
|
|
492
|
+
}, entry.timeoutMs);
|
|
493
|
+
}
|
|
494
|
+
function routeMessage(serverPubkey, mcp) {
|
|
116
495
|
const id = mcp.id;
|
|
117
496
|
if (id != null && pending.has(String(id))) {
|
|
118
497
|
const entry = pending.get(String(id));
|
|
119
498
|
if (entry.serverPubkey !== serverPubkey) return;
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
entry.resolve({ ...mcp, id: entry.originalId });
|
|
499
|
+
const settled = settlePending(String(id));
|
|
500
|
+
settled?.resolve({ ...mcp, id: entry.originalId });
|
|
123
501
|
return;
|
|
124
502
|
}
|
|
503
|
+
routeEvent(serverPubkey, mcp);
|
|
504
|
+
}
|
|
505
|
+
function routeEvent(serverPubkey, mcp) {
|
|
125
506
|
if (mcp.method !== void 0 && sessions.has(serverPubkey)) {
|
|
126
507
|
const server = { pubkey: serverPubkey };
|
|
127
508
|
for (const handler of eventHandlers) handler(server, mcp);
|
|
128
509
|
}
|
|
129
510
|
}
|
|
130
511
|
async function publishMcp(server, relays, message) {
|
|
512
|
+
const capabilityTags = [
|
|
513
|
+
...encrypt2 ? [[SUPPORT_ENCRYPTION]] : [],
|
|
514
|
+
...encrypt2 && wrapKind === KIND_GIFT_WRAP_EPHEMERAL ? [[SUPPORT_ENCRYPTION_EPHEMERAL]] : [],
|
|
515
|
+
[SUPPORT_OVERSIZED_TRANSFER],
|
|
516
|
+
[SUPPORT_OPEN_STREAM]
|
|
517
|
+
];
|
|
131
518
|
const inner = finalizeEvent(
|
|
132
|
-
{
|
|
519
|
+
{
|
|
520
|
+
kind: KIND_CVM,
|
|
521
|
+
created_at: Math.floor(Date.now() / 1e3),
|
|
522
|
+
tags: [["p", server.pubkey], ...capabilityTags],
|
|
523
|
+
content: JSON.stringify(message)
|
|
524
|
+
},
|
|
133
525
|
clientSecretKey
|
|
134
526
|
);
|
|
135
527
|
if (!encrypt2) {
|
|
@@ -153,17 +545,52 @@ function createNostrCvmTransport(options = {}) {
|
|
|
153
545
|
function sendCorrelated(server, relays, message, timeout) {
|
|
154
546
|
const correlationId = nextCorrelationId();
|
|
155
547
|
const originalId = message.id;
|
|
156
|
-
|
|
548
|
+
let outgoing = { ...message, id: correlationId };
|
|
549
|
+
let issuedToken;
|
|
550
|
+
const params = message.params && typeof message.params === "object" ? message.params : void 0;
|
|
551
|
+
const meta = params?._meta && typeof params._meta === "object" ? params._meta : void 0;
|
|
552
|
+
const explicit = meta?.progressToken;
|
|
553
|
+
if (message.method === "tools/call") {
|
|
554
|
+
issuedToken = typeof explicit === "string" || typeof explicit === "number" ? explicit : correlationId;
|
|
555
|
+
outgoing = {
|
|
556
|
+
...outgoing,
|
|
557
|
+
params: {
|
|
558
|
+
...params,
|
|
559
|
+
_meta: {
|
|
560
|
+
...meta,
|
|
561
|
+
progressToken: issuedToken
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
};
|
|
565
|
+
} else if (typeof explicit === "string" || typeof explicit === "number") {
|
|
566
|
+
issuedToken = explicit;
|
|
567
|
+
}
|
|
568
|
+
const tokenKey = progressTokenKey(issuedToken);
|
|
569
|
+
const issuedKey = tokenKey ? `${server.pubkey}:${tokenKey}` : null;
|
|
570
|
+
if (issuedKey && issuedTokens.has(issuedKey)) {
|
|
571
|
+
return Promise.reject(new Error("duplicate progress token"));
|
|
572
|
+
}
|
|
157
573
|
return new Promise((resolve, reject) => {
|
|
158
|
-
const
|
|
159
|
-
|
|
160
|
-
reject
|
|
161
|
-
|
|
162
|
-
|
|
574
|
+
const entry = {
|
|
575
|
+
resolve,
|
|
576
|
+
reject,
|
|
577
|
+
timer: setTimeout(() => {
|
|
578
|
+
const settled = settlePending(correlationId);
|
|
579
|
+
settled?.reject(new Error("relay timeout"));
|
|
580
|
+
}, timeout),
|
|
581
|
+
timeoutMs: timeout,
|
|
582
|
+
originalId,
|
|
583
|
+
serverPubkey: server.pubkey,
|
|
584
|
+
correlationId
|
|
585
|
+
};
|
|
586
|
+
if (issuedKey) {
|
|
587
|
+
entry.tokenKey = issuedKey;
|
|
588
|
+
issuedTokens.set(issuedKey, correlationId);
|
|
589
|
+
}
|
|
590
|
+
pending.set(correlationId, entry);
|
|
163
591
|
void publishMcp(server, relays, outgoing).catch((err) => {
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
reject(err instanceof Error ? err : new Error("publish failed"));
|
|
592
|
+
const settled = settlePending(correlationId);
|
|
593
|
+
settled?.reject(err instanceof Error ? err : new Error("publish failed"));
|
|
167
594
|
});
|
|
168
595
|
});
|
|
169
596
|
}
|
|
@@ -261,6 +688,10 @@ function createNostrCvmTransport(options = {}) {
|
|
|
261
688
|
if (!session) return;
|
|
262
689
|
sessions.delete(server.pubkey);
|
|
263
690
|
releaseRelays(session.relays);
|
|
691
|
+
for (const [correlationId, entry] of pending) {
|
|
692
|
+
if (entry.serverPubkey !== server.pubkey) continue;
|
|
693
|
+
settlePending(correlationId)?.reject(new Error("server closed"));
|
|
694
|
+
}
|
|
264
695
|
},
|
|
265
696
|
onEvent(handler) {
|
|
266
697
|
eventHandlers.add(handler);
|
|
@@ -275,9 +706,12 @@ function createNostrCvmTransport(options = {}) {
|
|
|
275
706
|
inbound = null;
|
|
276
707
|
for (const entry of pending.values()) {
|
|
277
708
|
clearTimeout(entry.timer);
|
|
709
|
+
releaseTransfer(entry);
|
|
710
|
+
releaseStream(entry);
|
|
278
711
|
entry.reject(new Error("transport disposed"));
|
|
279
712
|
}
|
|
280
713
|
pending.clear();
|
|
714
|
+
issuedTokens.clear();
|
|
281
715
|
sessions.clear();
|
|
282
716
|
relayRefcount.clear();
|
|
283
717
|
eventHandlers.clear();
|