@did-btcr2/method 0.28.0 → 0.32.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 +13 -5
- package/dist/.tsbuildinfo +1 -1
- package/dist/browser.js +34125 -44647
- package/dist/browser.mjs +26409 -36931
- package/dist/cjs/index.js +2869 -679
- package/dist/esm/core/aggregation/beacon-strategy.js +62 -0
- package/dist/esm/core/aggregation/beacon-strategy.js.map +1 -0
- package/dist/esm/core/aggregation/cohort.js +31 -8
- package/dist/esm/core/aggregation/cohort.js.map +1 -1
- package/dist/esm/core/aggregation/logger.js +15 -0
- package/dist/esm/core/aggregation/logger.js.map +1 -0
- package/dist/esm/core/aggregation/messages/base.js +12 -1
- package/dist/esm/core/aggregation/messages/base.js.map +1 -1
- package/dist/esm/core/aggregation/messages/bodies.js +90 -0
- package/dist/esm/core/aggregation/messages/bodies.js.map +1 -0
- package/dist/esm/core/aggregation/messages/factories.js.map +1 -1
- package/dist/esm/core/aggregation/messages/index.js +1 -0
- package/dist/esm/core/aggregation/messages/index.js.map +1 -1
- package/dist/esm/core/aggregation/participant.js +39 -46
- package/dist/esm/core/aggregation/participant.js.map +1 -1
- package/dist/esm/core/aggregation/runner/participant-runner.js +33 -7
- package/dist/esm/core/aggregation/runner/participant-runner.js.map +1 -1
- package/dist/esm/core/aggregation/runner/service-runner.js +198 -19
- package/dist/esm/core/aggregation/runner/service-runner.js.map +1 -1
- package/dist/esm/core/aggregation/service.js +143 -15
- package/dist/esm/core/aggregation/service.js.map +1 -1
- package/dist/esm/core/aggregation/signing-session.js +44 -5
- package/dist/esm/core/aggregation/signing-session.js.map +1 -1
- package/dist/esm/core/aggregation/transport/didcomm.js +9 -0
- package/dist/esm/core/aggregation/transport/didcomm.js.map +1 -1
- package/dist/esm/core/aggregation/transport/factory.js +15 -6
- package/dist/esm/core/aggregation/transport/factory.js.map +1 -1
- package/dist/esm/core/aggregation/transport/http/client.js +350 -0
- package/dist/esm/core/aggregation/transport/http/client.js.map +1 -0
- package/dist/esm/core/aggregation/transport/http/envelope.js +126 -0
- package/dist/esm/core/aggregation/transport/http/envelope.js.map +1 -0
- package/dist/esm/core/aggregation/transport/http/errors.js +11 -0
- package/dist/esm/core/aggregation/transport/http/errors.js.map +1 -0
- package/dist/esm/core/aggregation/transport/http/inbox-buffer.js +45 -0
- package/dist/esm/core/aggregation/transport/http/inbox-buffer.js.map +1 -0
- package/dist/esm/core/aggregation/transport/http/index.js +12 -0
- package/dist/esm/core/aggregation/transport/http/index.js.map +1 -0
- package/dist/esm/core/aggregation/transport/http/nonce-cache.js +38 -0
- package/dist/esm/core/aggregation/transport/http/nonce-cache.js.map +1 -0
- package/dist/esm/core/aggregation/transport/http/protocol.js +28 -0
- package/dist/esm/core/aggregation/transport/http/protocol.js.map +1 -0
- package/dist/esm/core/aggregation/transport/http/rate-limiter.js +45 -0
- package/dist/esm/core/aggregation/transport/http/rate-limiter.js.map +1 -0
- package/dist/esm/core/aggregation/transport/http/request-auth.js +100 -0
- package/dist/esm/core/aggregation/transport/http/request-auth.js.map +1 -0
- package/dist/esm/core/aggregation/transport/http/server.js +481 -0
- package/dist/esm/core/aggregation/transport/http/server.js.map +1 -0
- package/dist/esm/core/aggregation/transport/http/sse-stream.js +110 -0
- package/dist/esm/core/aggregation/transport/http/sse-stream.js.map +1 -0
- package/dist/esm/core/aggregation/transport/http/sse-writer.js +25 -0
- package/dist/esm/core/aggregation/transport/http/sse-writer.js.map +1 -0
- package/dist/esm/core/aggregation/transport/index.js +1 -0
- package/dist/esm/core/aggregation/transport/index.js.map +1 -1
- package/dist/esm/core/aggregation/transport/nostr.js +245 -16
- package/dist/esm/core/aggregation/transport/nostr.js.map +1 -1
- package/dist/esm/core/beacon/beacon.js +295 -63
- package/dist/esm/core/beacon/beacon.js.map +1 -1
- package/dist/esm/core/beacon/cas-beacon.js +3 -3
- package/dist/esm/core/beacon/cas-beacon.js.map +1 -1
- package/dist/esm/core/beacon/singleton-beacon.js +3 -3
- package/dist/esm/core/beacon/singleton-beacon.js.map +1 -1
- package/dist/esm/core/beacon/smt-beacon.js +3 -3
- package/dist/esm/core/beacon/smt-beacon.js.map +1 -1
- package/dist/esm/core/beacon/utils.js +14 -9
- package/dist/esm/core/beacon/utils.js.map +1 -1
- package/dist/esm/core/updater.js +63 -55
- package/dist/esm/core/updater.js.map +1 -1
- package/dist/esm/did-btcr2.js +0 -4
- package/dist/esm/did-btcr2.js.map +1 -1
- package/dist/esm/index.js +2 -0
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/utils/did-document.js +2 -2
- package/dist/esm/utils/did-document.js.map +1 -1
- package/dist/types/core/aggregation/beacon-strategy.d.ts +52 -0
- package/dist/types/core/aggregation/beacon-strategy.d.ts.map +1 -0
- package/dist/types/core/aggregation/cohort.d.ts +20 -3
- package/dist/types/core/aggregation/cohort.d.ts.map +1 -1
- package/dist/types/core/aggregation/logger.d.ts +22 -0
- package/dist/types/core/aggregation/logger.d.ts.map +1 -0
- package/dist/types/core/aggregation/messages/base.d.ts +13 -1
- package/dist/types/core/aggregation/messages/base.d.ts.map +1 -1
- package/dist/types/core/aggregation/messages/bodies.d.ts +130 -0
- package/dist/types/core/aggregation/messages/bodies.d.ts.map +1 -0
- package/dist/types/core/aggregation/messages/factories.d.ts +1 -0
- package/dist/types/core/aggregation/messages/factories.d.ts.map +1 -1
- package/dist/types/core/aggregation/messages/index.d.ts +1 -0
- package/dist/types/core/aggregation/messages/index.d.ts.map +1 -1
- package/dist/types/core/aggregation/participant.d.ts +2 -0
- package/dist/types/core/aggregation/participant.d.ts.map +1 -1
- package/dist/types/core/aggregation/runner/events.d.ts +32 -6
- package/dist/types/core/aggregation/runner/events.d.ts.map +1 -1
- package/dist/types/core/aggregation/runner/participant-runner.d.ts +7 -5
- package/dist/types/core/aggregation/runner/participant-runner.d.ts.map +1 -1
- package/dist/types/core/aggregation/runner/service-runner.d.ts +33 -3
- package/dist/types/core/aggregation/runner/service-runner.d.ts.map +1 -1
- package/dist/types/core/aggregation/service.d.ts +33 -2
- package/dist/types/core/aggregation/service.d.ts.map +1 -1
- package/dist/types/core/aggregation/signing-session.d.ts +5 -1
- package/dist/types/core/aggregation/signing-session.d.ts.map +1 -1
- package/dist/types/core/aggregation/transport/didcomm.d.ts +3 -0
- package/dist/types/core/aggregation/transport/didcomm.d.ts.map +1 -1
- package/dist/types/core/aggregation/transport/factory.d.ts +22 -7
- package/dist/types/core/aggregation/transport/factory.d.ts.map +1 -1
- package/dist/types/core/aggregation/transport/http/client.d.ts +48 -0
- package/dist/types/core/aggregation/transport/http/client.d.ts.map +1 -0
- package/dist/types/core/aggregation/transport/http/envelope.d.ts +64 -0
- package/dist/types/core/aggregation/transport/http/envelope.d.ts.map +1 -0
- package/dist/types/core/aggregation/transport/http/errors.d.ts +9 -0
- package/dist/types/core/aggregation/transport/http/errors.d.ts.map +1 -0
- package/dist/types/core/aggregation/transport/http/inbox-buffer.d.ts +32 -0
- package/dist/types/core/aggregation/transport/http/inbox-buffer.d.ts.map +1 -0
- package/dist/types/core/aggregation/transport/http/index.d.ts +12 -0
- package/dist/types/core/aggregation/transport/http/index.d.ts.map +1 -0
- package/dist/types/core/aggregation/transport/http/nonce-cache.d.ts +26 -0
- package/dist/types/core/aggregation/transport/http/nonce-cache.d.ts.map +1 -0
- package/dist/types/core/aggregation/transport/http/protocol.d.ts +53 -0
- package/dist/types/core/aggregation/transport/http/protocol.d.ts.map +1 -0
- package/dist/types/core/aggregation/transport/http/rate-limiter.d.ts +41 -0
- package/dist/types/core/aggregation/transport/http/rate-limiter.d.ts.map +1 -0
- package/dist/types/core/aggregation/transport/http/request-auth.d.ts +50 -0
- package/dist/types/core/aggregation/transport/http/request-auth.d.ts.map +1 -0
- package/dist/types/core/aggregation/transport/http/server.d.ts +110 -0
- package/dist/types/core/aggregation/transport/http/server.d.ts.map +1 -0
- package/dist/types/core/aggregation/transport/http/sse-stream.d.ts +34 -0
- package/dist/types/core/aggregation/transport/http/sse-stream.d.ts.map +1 -0
- package/dist/types/core/aggregation/transport/http/sse-writer.d.ts +12 -0
- package/dist/types/core/aggregation/transport/http/sse-writer.d.ts.map +1 -0
- package/dist/types/core/aggregation/transport/index.d.ts +1 -0
- package/dist/types/core/aggregation/transport/index.d.ts.map +1 -1
- package/dist/types/core/aggregation/transport/nostr.d.ts +99 -1
- package/dist/types/core/aggregation/transport/nostr.d.ts.map +1 -1
- package/dist/types/core/aggregation/transport/transport.d.ts +26 -1
- package/dist/types/core/aggregation/transport/transport.d.ts.map +1 -1
- package/dist/types/core/beacon/beacon.d.ts +149 -22
- package/dist/types/core/beacon/beacon.d.ts.map +1 -1
- package/dist/types/core/beacon/cas-beacon.d.ts +3 -3
- package/dist/types/core/beacon/cas-beacon.d.ts.map +1 -1
- package/dist/types/core/beacon/singleton-beacon.d.ts +3 -3
- package/dist/types/core/beacon/singleton-beacon.d.ts.map +1 -1
- package/dist/types/core/beacon/smt-beacon.d.ts +3 -3
- package/dist/types/core/beacon/smt-beacon.d.ts.map +1 -1
- package/dist/types/core/beacon/utils.d.ts +2 -2
- package/dist/types/core/beacon/utils.d.ts.map +1 -1
- package/dist/types/core/updater.d.ts +27 -12
- package/dist/types/core/updater.d.ts.map +1 -1
- package/dist/types/did-btcr2.d.ts.map +1 -1
- package/dist/types/index.d.ts +2 -0
- package/dist/types/index.d.ts.map +1 -1
- package/package.json +5 -7
- package/src/core/aggregation/beacon-strategy.ts +123 -0
- package/src/core/aggregation/cohort.ts +34 -8
- package/src/core/aggregation/logger.ts +33 -0
- package/src/core/aggregation/messages/base.ts +20 -5
- package/src/core/aggregation/messages/bodies.ts +223 -0
- package/src/core/aggregation/messages/factories.ts +1 -0
- package/src/core/aggregation/messages/index.ts +1 -0
- package/src/core/aggregation/participant.ts +40 -46
- package/src/core/aggregation/runner/events.ts +27 -3
- package/src/core/aggregation/runner/participant-runner.ts +41 -7
- package/src/core/aggregation/runner/service-runner.ts +227 -19
- package/src/core/aggregation/service.ts +189 -20
- package/src/core/aggregation/signing-session.ts +65 -7
- package/src/core/aggregation/transport/didcomm.ts +17 -0
- package/src/core/aggregation/transport/factory.ts +48 -12
- package/src/core/aggregation/transport/http/client.ts +409 -0
- package/src/core/aggregation/transport/http/envelope.ts +204 -0
- package/src/core/aggregation/transport/http/errors.ts +11 -0
- package/src/core/aggregation/transport/http/inbox-buffer.ts +53 -0
- package/src/core/aggregation/transport/http/index.ts +11 -0
- package/src/core/aggregation/transport/http/nonce-cache.ts +43 -0
- package/src/core/aggregation/transport/http/protocol.ts +57 -0
- package/src/core/aggregation/transport/http/rate-limiter.ts +75 -0
- package/src/core/aggregation/transport/http/request-auth.ts +164 -0
- package/src/core/aggregation/transport/http/server.ts +615 -0
- package/src/core/aggregation/transport/http/sse-stream.ts +121 -0
- package/src/core/aggregation/transport/http/sse-writer.ts +23 -0
- package/src/core/aggregation/transport/index.ts +1 -0
- package/src/core/aggregation/transport/nostr.ts +266 -23
- package/src/core/aggregation/transport/transport.ts +34 -1
- package/src/core/beacon/beacon.ts +411 -79
- package/src/core/beacon/cas-beacon.ts +4 -4
- package/src/core/beacon/singleton-beacon.ts +4 -4
- package/src/core/beacon/smt-beacon.ts +4 -4
- package/src/core/beacon/utils.ts +16 -11
- package/src/core/updater.ts +113 -67
- package/src/did-btcr2.ts +0 -5
- package/src/index.ts +2 -0
- package/src/utils/did-document.ts +2 -2
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
import type { SchnorrKeyPair } from '@did-btcr2/keypair';
|
|
2
|
+
import { CompressedSecp256k1PublicKey } from '@did-btcr2/keypair';
|
|
3
|
+
|
|
4
|
+
import { Identifier } from '../../../identifier.js';
|
|
5
|
+
import type { Logger } from '../../logger.js';
|
|
6
|
+
import { CONSOLE_LOGGER } from '../../logger.js';
|
|
7
|
+
import type { BaseMessage } from '../../messages/base.js';
|
|
8
|
+
import type { MessageHandler, Transport } from '../transport.js';
|
|
9
|
+
import { reviveFromWire, signEnvelope, verifyEnvelope } from './envelope.js';
|
|
10
|
+
import { HttpTransportError } from './errors.js';
|
|
11
|
+
import {
|
|
12
|
+
DEFAULT_CLOCK_SKEW_SEC,
|
|
13
|
+
HTTP_ROUTE,
|
|
14
|
+
SSE_EVENT,
|
|
15
|
+
type SignedEnvelope,
|
|
16
|
+
} from './protocol.js';
|
|
17
|
+
import { buildRequestAuth } from './request-auth.js';
|
|
18
|
+
import { parseSseStream } from './sse-stream.js';
|
|
19
|
+
|
|
20
|
+
export interface HttpClientTransportConfig {
|
|
21
|
+
/** Base URL of the aggregation service (e.g. `https://aggregator.example.com/`). */
|
|
22
|
+
baseUrl: string | URL;
|
|
23
|
+
/** Custom `fetch` implementation (tests, Workers, React Native). Defaults to `globalThis.fetch`. */
|
|
24
|
+
fetchImpl?: typeof fetch;
|
|
25
|
+
/** Diagnostic logger. Defaults to {@link CONSOLE_LOGGER}. */
|
|
26
|
+
logger?: Logger;
|
|
27
|
+
/** Reconnect backoff (ms) given attempt count (0-based). */
|
|
28
|
+
reconnectBackoff?: (attempt: number) => number;
|
|
29
|
+
/** Envelope / request-auth clock-skew tolerance in seconds. */
|
|
30
|
+
clockSkewSec?: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Default exponential backoff: 1s, 2s, 4s, ..., capped at 30s, 20% jitter. */
|
|
34
|
+
export function defaultReconnectBackoff(attempt: number): number {
|
|
35
|
+
const base = Math.min(1000 * 2 ** attempt, 30_000);
|
|
36
|
+
const jitter = base * 0.2 * Math.random();
|
|
37
|
+
return Math.floor(base + jitter);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface ActorEntry {
|
|
41
|
+
keys: SchnorrKeyPair;
|
|
42
|
+
handlers: Map<string, MessageHandler>;
|
|
43
|
+
inboxAbort?: AbortController;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* HTTP transport client. Implements the transport-agnostic {@link Transport}
|
|
48
|
+
* interface; the wire is fetch-based SSE for incoming events and fetch-based
|
|
49
|
+
* POST for outgoing messages. All runtime I/O goes through `fetchImpl` so
|
|
50
|
+
* tests can substitute a mock without touching the network.
|
|
51
|
+
*/
|
|
52
|
+
export class HttpClientTransport implements Transport {
|
|
53
|
+
readonly name = 'http';
|
|
54
|
+
|
|
55
|
+
readonly #baseUrl: URL;
|
|
56
|
+
readonly #fetch: typeof fetch;
|
|
57
|
+
readonly #logger: Logger;
|
|
58
|
+
readonly #backoff: (attempt: number) => number;
|
|
59
|
+
readonly #clockSkewSec: number;
|
|
60
|
+
|
|
61
|
+
readonly #actors: Map<string, ActorEntry> = new Map();
|
|
62
|
+
readonly #peers: Map<string, Uint8Array> = new Map();
|
|
63
|
+
|
|
64
|
+
#started = false;
|
|
65
|
+
#broadcastAbort?: AbortController;
|
|
66
|
+
|
|
67
|
+
constructor(config: HttpClientTransportConfig) {
|
|
68
|
+
const base = typeof config.baseUrl === 'string' ? new URL(config.baseUrl) : new URL(config.baseUrl.href);
|
|
69
|
+
if(!base.pathname.endsWith('/')) base.pathname += '/';
|
|
70
|
+
this.#baseUrl = base;
|
|
71
|
+
this.#logger = config.logger ?? CONSOLE_LOGGER;
|
|
72
|
+
this.#backoff = config.reconnectBackoff ?? defaultReconnectBackoff;
|
|
73
|
+
this.#clockSkewSec = config.clockSkewSec ?? DEFAULT_CLOCK_SKEW_SEC;
|
|
74
|
+
|
|
75
|
+
const fetchImpl = config.fetchImpl ?? globalThis.fetch;
|
|
76
|
+
if(typeof fetchImpl !== 'function') {
|
|
77
|
+
throw new HttpTransportError(
|
|
78
|
+
'No fetch implementation available. Pass config.fetchImpl explicitly.',
|
|
79
|
+
'NO_FETCH_IMPL',
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
this.#fetch = fetchImpl;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
start(): void {
|
|
86
|
+
if(this.#started) return;
|
|
87
|
+
this.#started = true;
|
|
88
|
+
|
|
89
|
+
this.#broadcastAbort = new AbortController();
|
|
90
|
+
this.#runBroadcastLoop(this.#broadcastAbort.signal);
|
|
91
|
+
|
|
92
|
+
for(const [did, entry] of this.#actors) {
|
|
93
|
+
this.#openInbox(did, entry);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Tear down all SSE subscriptions and stop reconnect loops. Not part of the
|
|
99
|
+
* {@link Transport} interface, but needed in tests and whenever a client
|
|
100
|
+
* wants to cleanly disconnect without unregistering every actor.
|
|
101
|
+
*
|
|
102
|
+
* Idempotent. Actors remain registered (re-call {@link start} to resume).
|
|
103
|
+
*/
|
|
104
|
+
stop(): void {
|
|
105
|
+
this.#broadcastAbort?.abort();
|
|
106
|
+
this.#broadcastAbort = undefined;
|
|
107
|
+
for(const entry of this.#actors.values()) {
|
|
108
|
+
entry.inboxAbort?.abort();
|
|
109
|
+
entry.inboxAbort = undefined;
|
|
110
|
+
}
|
|
111
|
+
this.#started = false;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
registerActor(did: string, keys: SchnorrKeyPair): void {
|
|
115
|
+
const existing = this.#actors.get(did);
|
|
116
|
+
if(existing?.inboxAbort) existing.inboxAbort.abort();
|
|
117
|
+
|
|
118
|
+
const entry: ActorEntry = { keys, handlers: new Map() };
|
|
119
|
+
this.#actors.set(did, entry);
|
|
120
|
+
if(this.#started) this.#openInbox(did, entry);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
unregisterActor(did: string): void {
|
|
124
|
+
const entry = this.#actors.get(did);
|
|
125
|
+
if(!entry) return;
|
|
126
|
+
entry.inboxAbort?.abort();
|
|
127
|
+
this.#actors.delete(did);
|
|
128
|
+
this.#peers.delete(did);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
getActorPk(did: string): Uint8Array | undefined {
|
|
132
|
+
return this.#actors.get(did)?.keys.publicKey.compressed;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
registerPeer(did: string, communicationPk: Uint8Array): void {
|
|
136
|
+
try {
|
|
137
|
+
new CompressedSecp256k1PublicKey(communicationPk);
|
|
138
|
+
} catch {
|
|
139
|
+
throw new HttpTransportError(
|
|
140
|
+
`Invalid communication public key for peer ${did}: expected a 33-byte compressed secp256k1 key.`,
|
|
141
|
+
'INVALID_PEER_KEY',
|
|
142
|
+
{ did, keyLength: communicationPk.length },
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
this.#peers.set(did, communicationPk);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
getPeerPk(did: string): Uint8Array | undefined {
|
|
149
|
+
return this.#peers.get(did);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
registerMessageHandler(actorDid: string, messageType: string, handler: MessageHandler): void {
|
|
153
|
+
const actor = this.#actors.get(actorDid);
|
|
154
|
+
if(!actor) {
|
|
155
|
+
throw new HttpTransportError(
|
|
156
|
+
`Cannot register handler: actor ${actorDid} not registered. Call registerActor() first.`,
|
|
157
|
+
'UNKNOWN_ACTOR',
|
|
158
|
+
{ did: actorDid },
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
actor.handlers.set(messageType, handler);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
unregisterMessageHandler(actorDid: string, messageType: string): void {
|
|
165
|
+
this.#actors.get(actorDid)?.handlers.delete(messageType);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async sendMessage(message: BaseMessage, sender: string, recipient?: string): Promise<void> {
|
|
169
|
+
const actor = this.#actors.get(sender);
|
|
170
|
+
if(!actor) {
|
|
171
|
+
throw new HttpTransportError(
|
|
172
|
+
`Unknown sender: ${sender}. Call registerActor() before sending messages.`,
|
|
173
|
+
'UNKNOWN_SENDER',
|
|
174
|
+
{ did: sender },
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const envelope = signEnvelope(
|
|
179
|
+
message,
|
|
180
|
+
{ did: sender, keys: actor.keys },
|
|
181
|
+
{ to: recipient },
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
const url = this.#route(HTTP_ROUTE.MESSAGES);
|
|
185
|
+
const res = await this.#fetch(url, {
|
|
186
|
+
method : 'POST',
|
|
187
|
+
headers : { 'content-type': 'application/json' },
|
|
188
|
+
body : JSON.stringify(envelope),
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
if(!res.ok) {
|
|
192
|
+
const body = await safeText(res);
|
|
193
|
+
throw new HttpTransportError(
|
|
194
|
+
`sendMessage failed: HTTP ${res.status}`,
|
|
195
|
+
'SEND_MESSAGE_HTTP',
|
|
196
|
+
{ status: res.status, body: body.slice(0, 256), messageType: message.type },
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
publishRepeating(
|
|
202
|
+
message: BaseMessage,
|
|
203
|
+
sender: string,
|
|
204
|
+
intervalMs: number,
|
|
205
|
+
recipient?: string,
|
|
206
|
+
): () => void {
|
|
207
|
+
let stopped = false;
|
|
208
|
+
const attempt = (): void => {
|
|
209
|
+
if(stopped) return;
|
|
210
|
+
this.sendMessage(message, sender, recipient).catch((err) => {
|
|
211
|
+
this.#logger.debug('publishRepeating send failed:', err);
|
|
212
|
+
});
|
|
213
|
+
};
|
|
214
|
+
attempt();
|
|
215
|
+
const timer = setInterval(attempt, intervalMs);
|
|
216
|
+
return (): void => {
|
|
217
|
+
if(stopped) return;
|
|
218
|
+
stopped = true;
|
|
219
|
+
clearInterval(timer);
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
#route(template: string): URL {
|
|
224
|
+
// Strip the leading slash so `new URL(rel, base)` is resolved against the
|
|
225
|
+
// base's pathname instead of replacing it.
|
|
226
|
+
return new URL(template.replace(/^\//, ''), this.#baseUrl);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
#openInbox(did: string, entry: ActorEntry): void {
|
|
230
|
+
const abort = new AbortController();
|
|
231
|
+
entry.inboxAbort = abort;
|
|
232
|
+
this.#runInboxLoop(did, entry, abort.signal);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
async #runBroadcastLoop(signal: AbortSignal): Promise<void> {
|
|
236
|
+
const url = this.#route(HTTP_ROUTE.ADVERTS);
|
|
237
|
+
let attempt = 0;
|
|
238
|
+
while(!signal.aborted) {
|
|
239
|
+
try {
|
|
240
|
+
const res = await this.#fetch(url, {
|
|
241
|
+
method : 'GET',
|
|
242
|
+
headers : { accept: 'text/event-stream' },
|
|
243
|
+
signal,
|
|
244
|
+
});
|
|
245
|
+
if(!res.ok || !res.body) {
|
|
246
|
+
this.#logger.warn(`Broadcast subscribe failed: HTTP ${res.status}`);
|
|
247
|
+
await sleep(this.#backoff(attempt++), signal);
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
attempt = 0;
|
|
251
|
+
for await (const ev of parseSseStream(res.body)) {
|
|
252
|
+
if(signal.aborted) return;
|
|
253
|
+
if(ev.event !== SSE_EVENT.ADVERT) continue;
|
|
254
|
+
this.#dispatchBroadcast(ev.data);
|
|
255
|
+
}
|
|
256
|
+
} catch(err) {
|
|
257
|
+
if(signal.aborted) return;
|
|
258
|
+
this.#logger.debug('Broadcast loop error:', err);
|
|
259
|
+
try {
|
|
260
|
+
await sleep(this.#backoff(attempt++), signal);
|
|
261
|
+
} catch {
|
|
262
|
+
return; // sleep was aborted
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
async #runInboxLoop(did: string, entry: ActorEntry, signal: AbortSignal): Promise<void> {
|
|
269
|
+
const url = this.#route(HTTP_ROUTE.ACTOR_INBOX.replace('{did}', encodeURIComponent(did)));
|
|
270
|
+
let attempt = 0;
|
|
271
|
+
while(!signal.aborted) {
|
|
272
|
+
try {
|
|
273
|
+
const auth = buildRequestAuth(did, entry.keys, url.pathname);
|
|
274
|
+
const res = await this.#fetch(url, {
|
|
275
|
+
method : 'GET',
|
|
276
|
+
headers : { accept: 'text/event-stream', authorization: auth },
|
|
277
|
+
signal,
|
|
278
|
+
});
|
|
279
|
+
if(!res.ok || !res.body) {
|
|
280
|
+
this.#logger.warn(`Inbox subscribe failed for ${did}: HTTP ${res.status}`);
|
|
281
|
+
await sleep(this.#backoff(attempt++), signal);
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
attempt = 0;
|
|
285
|
+
for await (const ev of parseSseStream(res.body)) {
|
|
286
|
+
if(signal.aborted) return;
|
|
287
|
+
if(ev.event !== SSE_EVENT.MESSAGE) continue;
|
|
288
|
+
await this.#dispatchInbox(ev.data, did, entry);
|
|
289
|
+
}
|
|
290
|
+
} catch(err) {
|
|
291
|
+
if(signal.aborted) return;
|
|
292
|
+
this.#logger.debug(`Inbox loop error for ${did}:`, err);
|
|
293
|
+
try {
|
|
294
|
+
await sleep(this.#backoff(attempt++), signal);
|
|
295
|
+
} catch {
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
#dispatchBroadcast(dataJson: string): void {
|
|
303
|
+
const envelope = parseEnvelope(dataJson, this.#logger);
|
|
304
|
+
if(!envelope) return;
|
|
305
|
+
|
|
306
|
+
const senderPk = this.#resolveSenderPk(envelope.from);
|
|
307
|
+
if(!senderPk) {
|
|
308
|
+
this.#logger.debug(`Broadcast from unresolvable DID: ${envelope.from}`);
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
try {
|
|
312
|
+
verifyEnvelope(envelope, senderPk, { clockSkewSec: this.#clockSkewSec });
|
|
313
|
+
} catch(err) {
|
|
314
|
+
this.#logger.debug('Broadcast envelope verification failed:', err);
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const revived = reviveFromWire(envelope.message) as Record<string, unknown>;
|
|
319
|
+
const flat = flattenMessage(revived);
|
|
320
|
+
const messageType = typeof flat.type === 'string' ? flat.type : undefined;
|
|
321
|
+
if(!messageType) return;
|
|
322
|
+
|
|
323
|
+
for(const actor of this.#actors.values()) {
|
|
324
|
+
const handler = actor.handlers.get(messageType);
|
|
325
|
+
if(handler) void Promise.resolve(handler(flat));
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
async #dispatchInbox(dataJson: string, actorDid: string, entry: ActorEntry): Promise<void> {
|
|
330
|
+
const envelope = parseEnvelope(dataJson, this.#logger);
|
|
331
|
+
if(!envelope) return;
|
|
332
|
+
|
|
333
|
+
const senderPk = this.#resolveSenderPk(envelope.from);
|
|
334
|
+
if(!senderPk) {
|
|
335
|
+
this.#logger.debug(`Inbox message from unresolvable DID: ${envelope.from}`);
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
try {
|
|
339
|
+
verifyEnvelope(envelope, senderPk, {
|
|
340
|
+
clockSkewSec : this.#clockSkewSec,
|
|
341
|
+
expectedTo : actorDid,
|
|
342
|
+
});
|
|
343
|
+
} catch(err) {
|
|
344
|
+
this.#logger.debug(`Inbox envelope verification failed for ${actorDid}:`, err);
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const revived = reviveFromWire(envelope.message) as Record<string, unknown>;
|
|
349
|
+
const flat = flattenMessage(revived);
|
|
350
|
+
const messageType = typeof flat.type === 'string' ? flat.type : undefined;
|
|
351
|
+
if(!messageType) return;
|
|
352
|
+
|
|
353
|
+
const handler = entry.handlers.get(messageType);
|
|
354
|
+
if(handler) await handler(flat);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
#resolveSenderPk(did: string): CompressedSecp256k1PublicKey | undefined {
|
|
358
|
+
const peerBytes = this.#peers.get(did);
|
|
359
|
+
if(peerBytes) {
|
|
360
|
+
try { return new CompressedSecp256k1PublicKey(peerBytes); }
|
|
361
|
+
catch { /* fall through to DID decode */ }
|
|
362
|
+
}
|
|
363
|
+
try {
|
|
364
|
+
const components = Identifier.decode(did);
|
|
365
|
+
if(components.idType === 'KEY') {
|
|
366
|
+
return new CompressedSecp256k1PublicKey(components.genesisBytes);
|
|
367
|
+
}
|
|
368
|
+
} catch {
|
|
369
|
+
// Not a decodable did:btcr2 KEY identifier.
|
|
370
|
+
}
|
|
371
|
+
return undefined;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function sleep(ms: number, signal: AbortSignal): Promise<void> {
|
|
376
|
+
if(ms <= 0) return Promise.resolve();
|
|
377
|
+
return new Promise((resolve, reject) => {
|
|
378
|
+
const onAbort = (): void => {
|
|
379
|
+
clearTimeout(timer);
|
|
380
|
+
reject(new Error('aborted'));
|
|
381
|
+
};
|
|
382
|
+
const timer = setTimeout(() => {
|
|
383
|
+
signal.removeEventListener('abort', onAbort);
|
|
384
|
+
resolve();
|
|
385
|
+
}, ms);
|
|
386
|
+
if(signal.aborted) onAbort();
|
|
387
|
+
else signal.addEventListener('abort', onAbort, { once: true });
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
async function safeText(res: Response): Promise<string> {
|
|
392
|
+
try { return await res.text(); }
|
|
393
|
+
catch { return ''; }
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function parseEnvelope(dataJson: string, logger: Logger): SignedEnvelope | undefined {
|
|
397
|
+
try { return JSON.parse(dataJson) as SignedEnvelope; }
|
|
398
|
+
catch(err) {
|
|
399
|
+
logger.debug('SSE event: failed to parse envelope JSON:', err);
|
|
400
|
+
return undefined;
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function flattenMessage(msg: Record<string, unknown>): Record<string, unknown> {
|
|
405
|
+
if(msg.body && typeof msg.body === 'object') {
|
|
406
|
+
return { ...msg, ...(msg.body as Record<string, unknown>) };
|
|
407
|
+
}
|
|
408
|
+
return msg;
|
|
409
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { canonicalHashBytes } from '@did-btcr2/common';
|
|
2
|
+
import type { CompressedSecp256k1PublicKey, SchnorrKeyPair } from '@did-btcr2/keypair';
|
|
3
|
+
import { bytesToHex, hexToBytes, randomBytes } from '@noble/hashes/utils';
|
|
4
|
+
|
|
5
|
+
import type { BaseMessage } from '../../messages/base.js';
|
|
6
|
+
import { HttpTransportError } from './errors.js';
|
|
7
|
+
import {
|
|
8
|
+
DEFAULT_CLOCK_SKEW_SEC,
|
|
9
|
+
DEFAULT_NONCE_LEN_BYTES,
|
|
10
|
+
HTTP_ENVELOPE_VERSION,
|
|
11
|
+
type SignedEnvelope,
|
|
12
|
+
} from './protocol.js';
|
|
13
|
+
|
|
14
|
+
/** Any shape acceptable as an envelope payload. `BaseMessage` instances are
|
|
15
|
+
* `toJSON`-normalized before signing so class vs. POJO callers produce the
|
|
16
|
+
* same canonical form. */
|
|
17
|
+
export type EnvelopeMessage = BaseMessage | Record<string, unknown>;
|
|
18
|
+
|
|
19
|
+
export interface SignEnvelopeOptions {
|
|
20
|
+
/** Recipient DID. Omit for broadcasts. */
|
|
21
|
+
to?: string;
|
|
22
|
+
/** Override the random nonce (tests). */
|
|
23
|
+
nonce?: string;
|
|
24
|
+
/** Override the unix-seconds timestamp (tests). */
|
|
25
|
+
timestamp?: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface VerifyEnvelopeOptions {
|
|
29
|
+
/** Reject if `envelope.from` doesn't match. */
|
|
30
|
+
expectedFrom?: string;
|
|
31
|
+
/** Reject if `envelope.to` doesn't match. Pass `undefined` to require a broadcast. */
|
|
32
|
+
expectedTo?: string;
|
|
33
|
+
/** Clock-skew tolerance (seconds). Defaults to {@link DEFAULT_CLOCK_SKEW_SEC}. */
|
|
34
|
+
clockSkewSec?: number;
|
|
35
|
+
/** Clock override (tests). Defaults to `Date.now`. */
|
|
36
|
+
now?: () => number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Build a {@link SignedEnvelope} around `message`.
|
|
41
|
+
*
|
|
42
|
+
* Pure function — no I/O beyond `randomBytes` for nonce generation (which
|
|
43
|
+
* uses the platform's cryptographic RNG: `crypto.getRandomValues` in browsers,
|
|
44
|
+
* `node:crypto` in Node). Deterministic when both `nonce` and `timestamp` are
|
|
45
|
+
* supplied via {@link SignEnvelopeOptions}.
|
|
46
|
+
*/
|
|
47
|
+
export function signEnvelope(
|
|
48
|
+
message: EnvelopeMessage,
|
|
49
|
+
sender: { did: string; keys: SchnorrKeyPair },
|
|
50
|
+
opts: SignEnvelopeOptions = {},
|
|
51
|
+
): SignedEnvelope {
|
|
52
|
+
const timestamp = opts.timestamp ?? Math.floor(Date.now() / 1000);
|
|
53
|
+
const nonce = opts.nonce ?? bytesToHex(randomBytes(DEFAULT_NONCE_LEN_BYTES));
|
|
54
|
+
const messageJson = normalizeForWire(normalizeMessage(message)) as Record<string, unknown>;
|
|
55
|
+
|
|
56
|
+
const unsigned: Omit<SignedEnvelope, 'sig'> = {
|
|
57
|
+
v : HTTP_ENVELOPE_VERSION,
|
|
58
|
+
from : sender.did,
|
|
59
|
+
...(opts.to !== undefined ? { to: opts.to } : {}),
|
|
60
|
+
timestamp,
|
|
61
|
+
nonce,
|
|
62
|
+
message : messageJson,
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const digest = canonicalHashBytes(unsigned);
|
|
66
|
+
const sig = sender.keys.secretKey.sign(digest, { scheme: 'schnorr' });
|
|
67
|
+
|
|
68
|
+
return { ...unsigned, sig: bytesToHex(sig) };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Verify a {@link SignedEnvelope} against the sender's compressed secp256k1
|
|
73
|
+
* communication public key. Throws {@link HttpTransportError} on any failure;
|
|
74
|
+
* returns normally on success.
|
|
75
|
+
*
|
|
76
|
+
* Does NOT check nonce uniqueness — replay protection is the caller's
|
|
77
|
+
* responsibility (the server-side transport maintains an LRU cache).
|
|
78
|
+
*/
|
|
79
|
+
export function verifyEnvelope(
|
|
80
|
+
envelope: SignedEnvelope,
|
|
81
|
+
senderPk: CompressedSecp256k1PublicKey,
|
|
82
|
+
opts: VerifyEnvelopeOptions = {},
|
|
83
|
+
): void {
|
|
84
|
+
if(envelope.v !== HTTP_ENVELOPE_VERSION) {
|
|
85
|
+
throw new HttpTransportError(
|
|
86
|
+
`Unsupported envelope version: ${envelope.v}`,
|
|
87
|
+
'ENVELOPE_VERSION_MISMATCH',
|
|
88
|
+
{ version: envelope.v, expected: HTTP_ENVELOPE_VERSION },
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if(opts.expectedFrom !== undefined && envelope.from !== opts.expectedFrom) {
|
|
93
|
+
throw new HttpTransportError(
|
|
94
|
+
`Envelope from mismatch: expected ${opts.expectedFrom}, got ${envelope.from}`,
|
|
95
|
+
'ENVELOPE_FROM_MISMATCH',
|
|
96
|
+
{ expected: opts.expectedFrom, got: envelope.from },
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if('expectedTo' in opts && envelope.to !== opts.expectedTo) {
|
|
101
|
+
throw new HttpTransportError(
|
|
102
|
+
`Envelope to mismatch: expected ${opts.expectedTo ?? '<broadcast>'}, got ${envelope.to ?? '<broadcast>'}`,
|
|
103
|
+
'ENVELOPE_TO_MISMATCH',
|
|
104
|
+
{ expected: opts.expectedTo, got: envelope.to },
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const skewSec = opts.clockSkewSec ?? DEFAULT_CLOCK_SKEW_SEC;
|
|
109
|
+
const nowMs = opts.now ? opts.now() : Date.now();
|
|
110
|
+
const nowSec = Math.floor(nowMs / 1000);
|
|
111
|
+
const diff = Math.abs(nowSec - envelope.timestamp);
|
|
112
|
+
if(diff > skewSec) {
|
|
113
|
+
throw new HttpTransportError(
|
|
114
|
+
`Envelope timestamp out of skew: ${diff}s > ${skewSec}s`,
|
|
115
|
+
'ENVELOPE_TIMESTAMP_SKEW',
|
|
116
|
+
{ diff, skewSec, timestamp: envelope.timestamp, now: nowSec },
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
let sigBytes: Uint8Array;
|
|
121
|
+
try {
|
|
122
|
+
sigBytes = hexToBytes(envelope.sig);
|
|
123
|
+
} catch {
|
|
124
|
+
throw new HttpTransportError(
|
|
125
|
+
'Envelope signature is not valid hex',
|
|
126
|
+
'ENVELOPE_SIG_HEX',
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
if(sigBytes.length !== 64) {
|
|
130
|
+
throw new HttpTransportError(
|
|
131
|
+
`Invalid signature length: ${sigBytes.length} (expected 64)`,
|
|
132
|
+
'ENVELOPE_SIG_LENGTH',
|
|
133
|
+
{ length: sigBytes.length },
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const { sig: _sig, ...unsigned } = envelope;
|
|
138
|
+
const digest = canonicalHashBytes(unsigned);
|
|
139
|
+
|
|
140
|
+
const ok = senderPk.verify(sigBytes, digest, { scheme: 'schnorr' });
|
|
141
|
+
if(!ok) {
|
|
142
|
+
throw new HttpTransportError(
|
|
143
|
+
'Envelope signature verification failed',
|
|
144
|
+
'ENVELOPE_SIG_INVALID',
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function normalizeMessage(message: EnvelopeMessage): Record<string, unknown> {
|
|
150
|
+
const maybeToJSON = (message as { toJSON?: () => unknown }).toJSON;
|
|
151
|
+
if(typeof maybeToJSON === 'function') {
|
|
152
|
+
return maybeToJSON.call(message) as Record<string, unknown>;
|
|
153
|
+
}
|
|
154
|
+
return message as Record<string, unknown>;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Recursively replace `Uint8Array` values with `{ __bytes: hex }` sentinel
|
|
159
|
+
* objects so they survive JSON canonicalization / HTTP body serialization.
|
|
160
|
+
* Pairs with {@link reviveFromWire}.
|
|
161
|
+
*
|
|
162
|
+
* Without this, `JSON.stringify` serializes a `Uint8Array` as an index-keyed
|
|
163
|
+
* object (`{"0":1,"1":2,...}`), which `canonicalize` then re-parses into a
|
|
164
|
+
* plain object — the receiver cannot reconstruct the original bytes even
|
|
165
|
+
* though the signature still verifies.
|
|
166
|
+
*/
|
|
167
|
+
export function normalizeForWire(value: unknown): unknown {
|
|
168
|
+
if(value instanceof Uint8Array) {
|
|
169
|
+
return { __bytes: bytesToHex(value) };
|
|
170
|
+
}
|
|
171
|
+
if(Array.isArray(value)) {
|
|
172
|
+
return value.map((v) => normalizeForWire(v));
|
|
173
|
+
}
|
|
174
|
+
if(value && typeof value === 'object') {
|
|
175
|
+
const out: Record<string, unknown> = {};
|
|
176
|
+
for(const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
|
177
|
+
out[k] = normalizeForWire(v);
|
|
178
|
+
}
|
|
179
|
+
return out;
|
|
180
|
+
}
|
|
181
|
+
return value;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Recursively convert `{ __bytes: hex }` sentinels back into `Uint8Array`
|
|
186
|
+
* values. Call on `envelope.message` *after* successful verification and
|
|
187
|
+
* before handing the payload to a runner's handler.
|
|
188
|
+
*/
|
|
189
|
+
export function reviveFromWire(value: unknown): unknown {
|
|
190
|
+
if(value && typeof value === 'object' && !Array.isArray(value)) {
|
|
191
|
+
const rec = value as Record<string, unknown>;
|
|
192
|
+
const keys = Object.keys(rec);
|
|
193
|
+
if(keys.length === 1 && keys[0] === '__bytes' && typeof rec.__bytes === 'string') {
|
|
194
|
+
return hexToBytes(rec.__bytes);
|
|
195
|
+
}
|
|
196
|
+
const out: Record<string, unknown> = {};
|
|
197
|
+
for(const [k, v] of Object.entries(rec)) out[k] = reviveFromWire(v);
|
|
198
|
+
return out;
|
|
199
|
+
}
|
|
200
|
+
if(Array.isArray(value)) {
|
|
201
|
+
return value.map((v) => reviveFromWire(v));
|
|
202
|
+
}
|
|
203
|
+
return value;
|
|
204
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { TransportAdapterError } from '../error.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Errors raised by the HTTP transport adapter. Extends {@link TransportAdapterError}
|
|
5
|
+
* so callers can catch HTTP-specific failures narrowly or transport failures broadly.
|
|
6
|
+
*/
|
|
7
|
+
export class HttpTransportError extends TransportAdapterError {
|
|
8
|
+
constructor(message: string, type: string = 'HttpTransportError', data?: Record<string, any>) {
|
|
9
|
+
super(message, type, { adapter: 'http', ...(data ?? {}) });
|
|
10
|
+
}
|
|
11
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
export interface StoredEvent {
|
|
2
|
+
/** Monotonic ID assigned at append time. Stable across the buffer's lifetime. */
|
|
3
|
+
id: string;
|
|
4
|
+
/** SSE event name. */
|
|
5
|
+
event: string;
|
|
6
|
+
/** SSE data payload (typically a JSON-stringified {@link SignedEnvelope}). */
|
|
7
|
+
data: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Fixed-capacity FIFO ring buffer of SSE events for a single actor's inbox.
|
|
12
|
+
*
|
|
13
|
+
* When a subscriber (re)connects with a `Last-Event-ID` header, the server
|
|
14
|
+
* uses {@link since} to replay everything that arrived while the subscriber
|
|
15
|
+
* was disconnected. Events older than the replay window (evicted from the
|
|
16
|
+
* ring) are unrecoverable — callers should choose `capacity` based on
|
|
17
|
+
* expected message rate × acceptable reconnect window.
|
|
18
|
+
*/
|
|
19
|
+
export class InboxBuffer {
|
|
20
|
+
readonly #capacity: number;
|
|
21
|
+
readonly #entries: StoredEvent[] = [];
|
|
22
|
+
#nextId = 1;
|
|
23
|
+
|
|
24
|
+
constructor(capacity = 100) {
|
|
25
|
+
if(capacity < 1) throw new Error(`InboxBuffer capacity must be >= 1; got ${capacity}`);
|
|
26
|
+
this.#capacity = capacity;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Append an event. Returns the stored record (including its assigned id). */
|
|
30
|
+
append(event: string, data: string): StoredEvent {
|
|
31
|
+
const stored: StoredEvent = { id: String(this.#nextId++), event, data };
|
|
32
|
+
this.#entries.push(stored);
|
|
33
|
+
if(this.#entries.length > this.#capacity) this.#entries.shift();
|
|
34
|
+
return stored;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Return stored events with id strictly greater than `lastEventId`. If
|
|
39
|
+
* `lastEventId` is unset or unparseable, returns everything currently
|
|
40
|
+
* retained.
|
|
41
|
+
*/
|
|
42
|
+
since(lastEventId?: string): StoredEvent[] {
|
|
43
|
+
if(!lastEventId) return this.#entries.slice();
|
|
44
|
+
const boundary = Number(lastEventId);
|
|
45
|
+
if(!Number.isFinite(boundary)) return this.#entries.slice();
|
|
46
|
+
return this.#entries.filter((e) => Number(e.id) > boundary);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Currently retained event count. */
|
|
50
|
+
size(): number {
|
|
51
|
+
return this.#entries.length;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export * from './errors.js';
|
|
2
|
+
export * from './protocol.js';
|
|
3
|
+
export * from './envelope.js';
|
|
4
|
+
export * from './sse-stream.js';
|
|
5
|
+
export * from './sse-writer.js';
|
|
6
|
+
export * from './request-auth.js';
|
|
7
|
+
export * from './nonce-cache.js';
|
|
8
|
+
export * from './rate-limiter.js';
|
|
9
|
+
export * from './inbox-buffer.js';
|
|
10
|
+
export * from './client.js';
|
|
11
|
+
export * from './server.js';
|