@xtr-dev/rondevu-client 0.18.10 → 0.21.1
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 +92 -117
- package/dist/api/batcher.d.ts +83 -0
- package/dist/api/batcher.js +155 -0
- package/dist/api/client.d.ts +198 -0
- package/dist/api/client.js +400 -0
- package/dist/{answerer-connection.d.ts → connections/answerer.d.ts} +25 -8
- package/dist/{answerer-connection.js → connections/answerer.js} +70 -48
- package/dist/{connection.d.ts → connections/base.d.ts} +30 -7
- package/dist/{connection.js → connections/base.js} +65 -14
- package/dist/connections/config.d.ts +51 -0
- package/dist/{connection-config.js → connections/config.js} +20 -0
- package/dist/{connection-events.d.ts → connections/events.d.ts} +6 -6
- package/dist/connections/offerer.d.ts +108 -0
- package/dist/connections/offerer.js +306 -0
- package/dist/core/ice-config.d.ts +35 -0
- package/dist/core/ice-config.js +111 -0
- package/dist/core/index.d.ts +22 -0
- package/dist/core/index.js +22 -0
- package/dist/core/offer-pool.d.ts +113 -0
- package/dist/core/offer-pool.js +281 -0
- package/dist/core/peer.d.ts +155 -0
- package/dist/core/peer.js +252 -0
- package/dist/core/polling-manager.d.ts +71 -0
- package/dist/core/polling-manager.js +122 -0
- package/dist/core/rondevu-errors.d.ts +59 -0
- package/dist/core/rondevu-errors.js +75 -0
- package/dist/core/rondevu-types.d.ts +125 -0
- package/dist/core/rondevu-types.js +6 -0
- package/dist/core/rondevu.d.ts +296 -0
- package/dist/core/rondevu.js +472 -0
- package/dist/crypto/adapter.d.ts +53 -0
- package/dist/crypto/node.d.ts +57 -0
- package/dist/crypto/node.js +149 -0
- package/dist/crypto/web.d.ts +38 -0
- package/dist/crypto/web.js +129 -0
- package/dist/utils/async-lock.d.ts +42 -0
- package/dist/utils/async-lock.js +75 -0
- package/dist/{message-buffer.d.ts → utils/message-buffer.d.ts} +1 -1
- package/dist/{message-buffer.js → utils/message-buffer.js} +4 -4
- package/dist/webrtc/adapter.d.ts +22 -0
- package/dist/webrtc/adapter.js +5 -0
- package/dist/webrtc/browser.d.ts +12 -0
- package/dist/webrtc/browser.js +15 -0
- package/dist/webrtc/node.d.ts +32 -0
- package/dist/webrtc/node.js +32 -0
- package/package.json +20 -9
- package/dist/api.d.ts +0 -146
- package/dist/api.js +0 -279
- package/dist/connection-config.d.ts +0 -21
- package/dist/crypto-adapter.d.ts +0 -37
- package/dist/index.d.ts +0 -13
- package/dist/index.js +0 -10
- package/dist/node-crypto-adapter.d.ts +0 -35
- package/dist/node-crypto-adapter.js +0 -78
- package/dist/offerer-connection.d.ts +0 -54
- package/dist/offerer-connection.js +0 -177
- package/dist/rondevu-signaler.d.ts +0 -112
- package/dist/rondevu-signaler.js +0 -401
- package/dist/rondevu.d.ts +0 -407
- package/dist/rondevu.js +0 -847
- package/dist/rpc-batcher.d.ts +0 -61
- package/dist/rpc-batcher.js +0 -111
- package/dist/web-crypto-adapter.d.ts +0 -16
- package/dist/web-crypto-adapter.js +0 -52
- /package/dist/{connection-events.js → connections/events.js} +0 -0
- /package/dist/{types.d.ts → core/types.d.ts} +0 -0
- /package/dist/{types.js → core/types.js} +0 -0
- /package/dist/{crypto-adapter.js → crypto/adapter.js} +0 -0
- /package/dist/{exponential-backoff.d.ts → utils/exponential-backoff.d.ts} +0 -0
- /package/dist/{exponential-backoff.js → utils/exponential-backoff.js} +0 -0
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rondevu API Client - RPC interface
|
|
3
|
+
*/
|
|
4
|
+
import { CryptoAdapter, Credential } from '../crypto/adapter.js';
|
|
5
|
+
import { BatcherOptions } from './batcher.js';
|
|
6
|
+
export type { Credential } from '../crypto/adapter.js';
|
|
7
|
+
export type { BatcherOptions } from './batcher.js';
|
|
8
|
+
export interface OfferRequest {
|
|
9
|
+
sdp: string;
|
|
10
|
+
}
|
|
11
|
+
export interface PublishRequest {
|
|
12
|
+
tags: string[];
|
|
13
|
+
offers: OfferRequest[];
|
|
14
|
+
ttl?: number;
|
|
15
|
+
}
|
|
16
|
+
export interface DiscoverRequest {
|
|
17
|
+
tags: string[];
|
|
18
|
+
limit?: number;
|
|
19
|
+
offset?: number;
|
|
20
|
+
}
|
|
21
|
+
export interface TaggedOffer {
|
|
22
|
+
offerId: string;
|
|
23
|
+
username: string;
|
|
24
|
+
tags: string[];
|
|
25
|
+
sdp: string;
|
|
26
|
+
createdAt: number;
|
|
27
|
+
expiresAt: number;
|
|
28
|
+
}
|
|
29
|
+
export interface DiscoverResponse {
|
|
30
|
+
offers: TaggedOffer[];
|
|
31
|
+
count: number;
|
|
32
|
+
limit: number;
|
|
33
|
+
offset: number;
|
|
34
|
+
}
|
|
35
|
+
export interface PublishResponse {
|
|
36
|
+
username: string;
|
|
37
|
+
tags: string[];
|
|
38
|
+
offers: Array<{
|
|
39
|
+
offerId: string;
|
|
40
|
+
sdp: string;
|
|
41
|
+
createdAt: number;
|
|
42
|
+
expiresAt: number;
|
|
43
|
+
}>;
|
|
44
|
+
createdAt: number;
|
|
45
|
+
expiresAt: number;
|
|
46
|
+
}
|
|
47
|
+
export interface IceCandidate {
|
|
48
|
+
candidate: RTCIceCandidateInit | null;
|
|
49
|
+
role: 'offerer' | 'answerer';
|
|
50
|
+
createdAt: number;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* RondevuAPI - RPC-based API client for Rondevu signaling server
|
|
54
|
+
*/
|
|
55
|
+
export declare class RondevuAPI {
|
|
56
|
+
private baseUrl;
|
|
57
|
+
private credential;
|
|
58
|
+
private static readonly DEFAULT_MAX_RETRIES;
|
|
59
|
+
private static readonly DEFAULT_TIMEOUT_MS;
|
|
60
|
+
private static readonly DEFAULT_CREDENTIAL_NAME_MAX_LENGTH;
|
|
61
|
+
private static readonly DEFAULT_SECRET_MIN_LENGTH;
|
|
62
|
+
private static readonly MAX_BACKOFF_MS;
|
|
63
|
+
private static readonly MAX_CANONICALIZE_DEPTH;
|
|
64
|
+
private crypto;
|
|
65
|
+
private batcher;
|
|
66
|
+
constructor(baseUrl: string, credential: Credential, cryptoAdapter?: CryptoAdapter, batcherOptions?: BatcherOptions);
|
|
67
|
+
/**
|
|
68
|
+
* Canonical JSON serialization with sorted keys
|
|
69
|
+
* Ensures deterministic output regardless of property insertion order
|
|
70
|
+
*/
|
|
71
|
+
private canonicalJSON;
|
|
72
|
+
/**
|
|
73
|
+
* Build signature message following server format
|
|
74
|
+
* Format: timestamp:nonce:method:canonicalJSON(params || {})
|
|
75
|
+
*
|
|
76
|
+
* Uses canonical JSON (sorted keys) to ensure deterministic serialization
|
|
77
|
+
* across different JavaScript engines and platforms.
|
|
78
|
+
*
|
|
79
|
+
* Note: When params is undefined, it's serialized as "{}" (empty object).
|
|
80
|
+
* This matches the server's expectation for parameterless RPC calls.
|
|
81
|
+
*/
|
|
82
|
+
private buildSignatureMessage;
|
|
83
|
+
/**
|
|
84
|
+
* Generate cryptographically secure nonce
|
|
85
|
+
* Uses crypto.randomUUID() if available, falls back to secure random bytes
|
|
86
|
+
*
|
|
87
|
+
* Note: this.crypto is always initialized in constructor (WebCryptoAdapter or NodeCryptoAdapter)
|
|
88
|
+
* and TypeScript enforces that both implement randomBytes(), so the fallback is always safe.
|
|
89
|
+
*/
|
|
90
|
+
private generateNonce;
|
|
91
|
+
/**
|
|
92
|
+
* Generate authentication headers for RPC request
|
|
93
|
+
* Uses HMAC-SHA256 signature with nonce for replay protection
|
|
94
|
+
*
|
|
95
|
+
* Security notes:
|
|
96
|
+
* - Nonce: Cryptographically secure random value (UUID or 128-bit hex)
|
|
97
|
+
* - Timestamp: Prevents replay attacks outside the server's time window
|
|
98
|
+
* - Server validates timestamp is within acceptable range (typically ±5 minutes)
|
|
99
|
+
* - Tolerates reasonable clock skew between client and server
|
|
100
|
+
* - Requests with stale timestamps are rejected
|
|
101
|
+
* - Signature: HMAC-SHA256 ensures message integrity and authenticity
|
|
102
|
+
* - Server validates nonce uniqueness to prevent replay within time window
|
|
103
|
+
* - Each nonce can only be used once within the timestamp validity window
|
|
104
|
+
* - Server maintains nonce cache with expiration matching timestamp window
|
|
105
|
+
*/
|
|
106
|
+
private generateAuthHeaders;
|
|
107
|
+
/**
|
|
108
|
+
* Execute RPC call via batcher
|
|
109
|
+
* Requests are batched with throttling for efficiency
|
|
110
|
+
*/
|
|
111
|
+
private rpc;
|
|
112
|
+
/**
|
|
113
|
+
* Generate new credentials (name + secret pair)
|
|
114
|
+
* This is the entry point for new users - no authentication required
|
|
115
|
+
* Credentials are generated server-side to ensure security and uniqueness
|
|
116
|
+
*
|
|
117
|
+
* ⚠️ SECURITY NOTE:
|
|
118
|
+
* - Store the returned credential securely
|
|
119
|
+
* - The secret provides full access to this identity
|
|
120
|
+
* - Credentials should be persisted encrypted and never logged
|
|
121
|
+
*
|
|
122
|
+
* @param baseUrl - Rondevu server URL
|
|
123
|
+
* @param expiresAt - Optional custom expiry timestamp (defaults to 1 year)
|
|
124
|
+
* @param options - Optional: { maxRetries: number, timeout: number }
|
|
125
|
+
* @returns Generated credential with name and secret
|
|
126
|
+
*/
|
|
127
|
+
static generateCredentials(baseUrl: string, options?: {
|
|
128
|
+
name?: string;
|
|
129
|
+
expiresAt?: number;
|
|
130
|
+
maxRetries?: number;
|
|
131
|
+
timeout?: number;
|
|
132
|
+
}): Promise<Credential>;
|
|
133
|
+
/**
|
|
134
|
+
* Generate a random secret locally (for advanced use cases)
|
|
135
|
+
* @param cryptoAdapter - Optional crypto adapter
|
|
136
|
+
*/
|
|
137
|
+
static generateSecret(cryptoAdapter?: CryptoAdapter): string;
|
|
138
|
+
/**
|
|
139
|
+
* Publish offers with tags
|
|
140
|
+
*/
|
|
141
|
+
publish(request: PublishRequest): Promise<PublishResponse>;
|
|
142
|
+
/**
|
|
143
|
+
* Discover offers by tags
|
|
144
|
+
* @param request - Discovery request with tags and optional pagination
|
|
145
|
+
* @returns Paginated response if limit provided, single offer if not
|
|
146
|
+
*/
|
|
147
|
+
discover(request: DiscoverRequest): Promise<DiscoverResponse | TaggedOffer>;
|
|
148
|
+
/**
|
|
149
|
+
* Delete an offer by ID
|
|
150
|
+
*/
|
|
151
|
+
deleteOffer(offerId: string): Promise<{
|
|
152
|
+
success: boolean;
|
|
153
|
+
}>;
|
|
154
|
+
/**
|
|
155
|
+
* Answer an offer
|
|
156
|
+
*/
|
|
157
|
+
answerOffer(offerId: string, sdp: string): Promise<void>;
|
|
158
|
+
/**
|
|
159
|
+
* Get answer for a specific offer (offerer polls this)
|
|
160
|
+
*/
|
|
161
|
+
getOfferAnswer(offerId: string): Promise<{
|
|
162
|
+
sdp: string;
|
|
163
|
+
offerId: string;
|
|
164
|
+
answererId: string;
|
|
165
|
+
answeredAt: number;
|
|
166
|
+
} | null>;
|
|
167
|
+
/**
|
|
168
|
+
* Combined polling for answers and ICE candidates
|
|
169
|
+
*/
|
|
170
|
+
poll(since?: number): Promise<{
|
|
171
|
+
answers: Array<{
|
|
172
|
+
offerId: string;
|
|
173
|
+
answererId: string;
|
|
174
|
+
sdp: string;
|
|
175
|
+
answeredAt: number;
|
|
176
|
+
}>;
|
|
177
|
+
iceCandidates: Record<string, Array<{
|
|
178
|
+
candidate: RTCIceCandidateInit | null;
|
|
179
|
+
role: 'offerer' | 'answerer';
|
|
180
|
+
peerId: string;
|
|
181
|
+
createdAt: number;
|
|
182
|
+
}>>;
|
|
183
|
+
}>;
|
|
184
|
+
/**
|
|
185
|
+
* Add ICE candidates to a specific offer
|
|
186
|
+
*/
|
|
187
|
+
addOfferIceCandidates(offerId: string, candidates: RTCIceCandidateInit[]): Promise<{
|
|
188
|
+
count: number;
|
|
189
|
+
offerId: string;
|
|
190
|
+
}>;
|
|
191
|
+
/**
|
|
192
|
+
* Get ICE candidates for a specific offer
|
|
193
|
+
*/
|
|
194
|
+
getOfferIceCandidates(offerId: string, since?: number): Promise<{
|
|
195
|
+
candidates: IceCandidate[];
|
|
196
|
+
offerId: string;
|
|
197
|
+
}>;
|
|
198
|
+
}
|
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rondevu API Client - RPC interface
|
|
3
|
+
*/
|
|
4
|
+
import { WebCryptoAdapter } from '../crypto/web.js';
|
|
5
|
+
import { RpcBatcher } from './batcher.js';
|
|
6
|
+
/**
|
|
7
|
+
* RondevuAPI - RPC-based API client for Rondevu signaling server
|
|
8
|
+
*/
|
|
9
|
+
export class RondevuAPI {
|
|
10
|
+
constructor(baseUrl, credential, cryptoAdapter, batcherOptions) {
|
|
11
|
+
this.baseUrl = baseUrl;
|
|
12
|
+
this.credential = credential;
|
|
13
|
+
// Use WebCryptoAdapter by default (browser environment)
|
|
14
|
+
this.crypto = cryptoAdapter || new WebCryptoAdapter();
|
|
15
|
+
// Create batcher for request batching with throttling
|
|
16
|
+
this.batcher = new RpcBatcher(baseUrl, batcherOptions);
|
|
17
|
+
// Validate credential format early to provide clear error messages
|
|
18
|
+
if (!credential.name || typeof credential.name !== 'string') {
|
|
19
|
+
throw new Error('Invalid credential: name must be a non-empty string');
|
|
20
|
+
}
|
|
21
|
+
// Validate name format (alphanumeric, dots, underscores, hyphens only)
|
|
22
|
+
// Limit to prevent HTTP header size issues
|
|
23
|
+
if (credential.name.length > RondevuAPI.DEFAULT_CREDENTIAL_NAME_MAX_LENGTH) {
|
|
24
|
+
throw new Error(`Invalid credential: name must not exceed ${RondevuAPI.DEFAULT_CREDENTIAL_NAME_MAX_LENGTH} characters`);
|
|
25
|
+
}
|
|
26
|
+
if (!/^[a-zA-Z0-9._-]+$/.test(credential.name)) {
|
|
27
|
+
throw new Error('Invalid credential: name must contain only alphanumeric characters, dots, underscores, and hyphens');
|
|
28
|
+
}
|
|
29
|
+
// Validate secret
|
|
30
|
+
if (!credential.secret || typeof credential.secret !== 'string') {
|
|
31
|
+
throw new Error('Invalid credential: secret must be a non-empty string');
|
|
32
|
+
}
|
|
33
|
+
// Minimum 256 bits (64 hex characters) for security
|
|
34
|
+
if (credential.secret.length < RondevuAPI.DEFAULT_SECRET_MIN_LENGTH) {
|
|
35
|
+
throw new Error(`Invalid credential: secret must be at least 256 bits (${RondevuAPI.DEFAULT_SECRET_MIN_LENGTH} hex characters)`);
|
|
36
|
+
}
|
|
37
|
+
// Validate secret is valid hex (even length, only hex characters)
|
|
38
|
+
if (credential.secret.length % 2 !== 0) {
|
|
39
|
+
throw new Error('Invalid credential: secret must be a valid hex string (even length)');
|
|
40
|
+
}
|
|
41
|
+
if (!/^[0-9a-fA-F]+$/.test(credential.secret)) {
|
|
42
|
+
throw new Error('Invalid credential: secret must contain only hexadecimal characters');
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Canonical JSON serialization with sorted keys
|
|
47
|
+
* Ensures deterministic output regardless of property insertion order
|
|
48
|
+
*/
|
|
49
|
+
canonicalJSON(obj, depth = 0) {
|
|
50
|
+
// Prevent stack overflow from deeply nested objects
|
|
51
|
+
if (depth > RondevuAPI.MAX_CANONICALIZE_DEPTH) {
|
|
52
|
+
throw new Error('Object nesting too deep for canonicalization');
|
|
53
|
+
}
|
|
54
|
+
// Handle null
|
|
55
|
+
if (obj === null) {
|
|
56
|
+
return 'null';
|
|
57
|
+
}
|
|
58
|
+
// Handle undefined
|
|
59
|
+
if (obj === undefined) {
|
|
60
|
+
return JSON.stringify(undefined);
|
|
61
|
+
}
|
|
62
|
+
// Validate primitive types
|
|
63
|
+
const type = typeof obj;
|
|
64
|
+
// Reject unsupported types
|
|
65
|
+
if (type === 'function') {
|
|
66
|
+
throw new Error('Functions are not supported in RPC parameters');
|
|
67
|
+
}
|
|
68
|
+
if (type === 'symbol' || type === 'bigint') {
|
|
69
|
+
throw new Error(`${type} is not supported in RPC parameters`);
|
|
70
|
+
}
|
|
71
|
+
// Validate numbers (reject NaN and Infinity)
|
|
72
|
+
if (type === 'number' && !Number.isFinite(obj)) {
|
|
73
|
+
throw new Error('NaN and Infinity are not supported in RPC parameters');
|
|
74
|
+
}
|
|
75
|
+
// Handle primitives (string, number, boolean)
|
|
76
|
+
if (type !== 'object') {
|
|
77
|
+
return JSON.stringify(obj);
|
|
78
|
+
}
|
|
79
|
+
// Handle arrays recursively
|
|
80
|
+
if (Array.isArray(obj)) {
|
|
81
|
+
return '[' + obj.map(item => this.canonicalJSON(item, depth + 1)).join(',') + ']';
|
|
82
|
+
}
|
|
83
|
+
// Handle objects - sort keys alphabetically for deterministic output
|
|
84
|
+
const sortedKeys = Object.keys(obj).sort();
|
|
85
|
+
const pairs = sortedKeys.map(key => {
|
|
86
|
+
return JSON.stringify(key) + ':' + this.canonicalJSON(obj[key], depth + 1);
|
|
87
|
+
});
|
|
88
|
+
return '{' + pairs.join(',') + '}';
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Build signature message following server format
|
|
92
|
+
* Format: timestamp:nonce:method:canonicalJSON(params || {})
|
|
93
|
+
*
|
|
94
|
+
* Uses canonical JSON (sorted keys) to ensure deterministic serialization
|
|
95
|
+
* across different JavaScript engines and platforms.
|
|
96
|
+
*
|
|
97
|
+
* Note: When params is undefined, it's serialized as "{}" (empty object).
|
|
98
|
+
* This matches the server's expectation for parameterless RPC calls.
|
|
99
|
+
*/
|
|
100
|
+
buildSignatureMessage(timestamp, nonce, method, params) {
|
|
101
|
+
if (!method || typeof method !== 'string') {
|
|
102
|
+
throw new Error('Invalid method: must be a non-empty string');
|
|
103
|
+
}
|
|
104
|
+
const paramsJson = this.canonicalJSON(params || {});
|
|
105
|
+
return `${timestamp}:${nonce}:${method}:${paramsJson}`;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Generate cryptographically secure nonce
|
|
109
|
+
* Uses crypto.randomUUID() if available, falls back to secure random bytes
|
|
110
|
+
*
|
|
111
|
+
* Note: this.crypto is always initialized in constructor (WebCryptoAdapter or NodeCryptoAdapter)
|
|
112
|
+
* and TypeScript enforces that both implement randomBytes(), so the fallback is always safe.
|
|
113
|
+
*/
|
|
114
|
+
generateNonce() {
|
|
115
|
+
// Get crypto object from global scope (supports various contexts)
|
|
116
|
+
// In browsers: window.crypto or self.crypto
|
|
117
|
+
// In modern environments: global crypto
|
|
118
|
+
const globalCrypto = typeof crypto !== 'undefined'
|
|
119
|
+
? crypto
|
|
120
|
+
: (typeof window !== 'undefined' && window.crypto) ||
|
|
121
|
+
(typeof self !== 'undefined' && self.crypto) ||
|
|
122
|
+
undefined;
|
|
123
|
+
// Prefer crypto.randomUUID() for widespread support and standard format
|
|
124
|
+
// UUIDv4 provides 122 bits of entropy (6 fixed version/variant bits)
|
|
125
|
+
if (globalCrypto && typeof globalCrypto.randomUUID === 'function') {
|
|
126
|
+
return globalCrypto.randomUUID();
|
|
127
|
+
}
|
|
128
|
+
// Fallback: 16 random bytes (128 bits entropy) as hex string
|
|
129
|
+
// Slightly more entropy than UUID, but both are cryptographically secure
|
|
130
|
+
// Safe because this.crypto is guaranteed to implement CryptoAdapter interface
|
|
131
|
+
const randomBytes = this.crypto.randomBytes(16);
|
|
132
|
+
return this.crypto.bytesToHex(randomBytes);
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Generate authentication headers for RPC request
|
|
136
|
+
* Uses HMAC-SHA256 signature with nonce for replay protection
|
|
137
|
+
*
|
|
138
|
+
* Security notes:
|
|
139
|
+
* - Nonce: Cryptographically secure random value (UUID or 128-bit hex)
|
|
140
|
+
* - Timestamp: Prevents replay attacks outside the server's time window
|
|
141
|
+
* - Server validates timestamp is within acceptable range (typically ±5 minutes)
|
|
142
|
+
* - Tolerates reasonable clock skew between client and server
|
|
143
|
+
* - Requests with stale timestamps are rejected
|
|
144
|
+
* - Signature: HMAC-SHA256 ensures message integrity and authenticity
|
|
145
|
+
* - Server validates nonce uniqueness to prevent replay within time window
|
|
146
|
+
* - Each nonce can only be used once within the timestamp validity window
|
|
147
|
+
* - Server maintains nonce cache with expiration matching timestamp window
|
|
148
|
+
*/
|
|
149
|
+
async generateAuthHeaders(request) {
|
|
150
|
+
const timestamp = Date.now();
|
|
151
|
+
const nonce = this.generateNonce();
|
|
152
|
+
// Build message and generate signature
|
|
153
|
+
const message = this.buildSignatureMessage(timestamp, nonce, request.method, request.params);
|
|
154
|
+
const signature = await this.crypto.generateSignature(this.credential.secret, message);
|
|
155
|
+
return {
|
|
156
|
+
'X-Name': this.credential.name,
|
|
157
|
+
'X-Timestamp': timestamp.toString(),
|
|
158
|
+
'X-Nonce': nonce,
|
|
159
|
+
'X-Signature': signature,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Execute RPC call via batcher
|
|
164
|
+
* Requests are batched with throttling for efficiency
|
|
165
|
+
*/
|
|
166
|
+
async rpc(request, authHeaders) {
|
|
167
|
+
return this.batcher.add(request, authHeaders);
|
|
168
|
+
}
|
|
169
|
+
// ============================================
|
|
170
|
+
// Credential Management
|
|
171
|
+
// ============================================
|
|
172
|
+
/**
|
|
173
|
+
* Generate new credentials (name + secret pair)
|
|
174
|
+
* This is the entry point for new users - no authentication required
|
|
175
|
+
* Credentials are generated server-side to ensure security and uniqueness
|
|
176
|
+
*
|
|
177
|
+
* ⚠️ SECURITY NOTE:
|
|
178
|
+
* - Store the returned credential securely
|
|
179
|
+
* - The secret provides full access to this identity
|
|
180
|
+
* - Credentials should be persisted encrypted and never logged
|
|
181
|
+
*
|
|
182
|
+
* @param baseUrl - Rondevu server URL
|
|
183
|
+
* @param expiresAt - Optional custom expiry timestamp (defaults to 1 year)
|
|
184
|
+
* @param options - Optional: { maxRetries: number, timeout: number }
|
|
185
|
+
* @returns Generated credential with name and secret
|
|
186
|
+
*/
|
|
187
|
+
static async generateCredentials(baseUrl, options) {
|
|
188
|
+
const maxRetries = options?.maxRetries ?? RondevuAPI.DEFAULT_MAX_RETRIES;
|
|
189
|
+
const timeout = options?.timeout ?? RondevuAPI.DEFAULT_TIMEOUT_MS;
|
|
190
|
+
let lastError = null;
|
|
191
|
+
// Build params object with optional name and expiresAt
|
|
192
|
+
const params = {};
|
|
193
|
+
if (options?.name)
|
|
194
|
+
params.name = options.name;
|
|
195
|
+
if (options?.expiresAt)
|
|
196
|
+
params.expiresAt = options.expiresAt;
|
|
197
|
+
const request = {
|
|
198
|
+
method: 'generateCredentials',
|
|
199
|
+
params: Object.keys(params).length > 0 ? params : undefined,
|
|
200
|
+
};
|
|
201
|
+
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
|
202
|
+
// httpStatus is scoped to each iteration intentionally - resets on each retry
|
|
203
|
+
let httpStatus = null;
|
|
204
|
+
try {
|
|
205
|
+
// Create abort controller for timeout
|
|
206
|
+
if (typeof AbortController === 'undefined') {
|
|
207
|
+
throw new Error('AbortController not supported in this environment');
|
|
208
|
+
}
|
|
209
|
+
const controller = new AbortController();
|
|
210
|
+
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
|
211
|
+
try {
|
|
212
|
+
const response = await fetch(`${baseUrl}/rpc`, {
|
|
213
|
+
method: 'POST',
|
|
214
|
+
headers: {
|
|
215
|
+
'Content-Type': 'application/json',
|
|
216
|
+
},
|
|
217
|
+
body: JSON.stringify([request]), // Server expects array (batch format)
|
|
218
|
+
signal: controller.signal,
|
|
219
|
+
});
|
|
220
|
+
httpStatus = response.status;
|
|
221
|
+
if (!response.ok) {
|
|
222
|
+
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
223
|
+
}
|
|
224
|
+
// Server returns array of responses
|
|
225
|
+
const results = await response.json();
|
|
226
|
+
const result = results[0];
|
|
227
|
+
if (!result || !result.success) {
|
|
228
|
+
throw new Error(result?.error || 'Failed to generate credentials');
|
|
229
|
+
}
|
|
230
|
+
// Validate credential structure
|
|
231
|
+
const credential = result.result;
|
|
232
|
+
if (!credential || typeof credential !== 'object') {
|
|
233
|
+
throw new Error('Invalid credential response: result is not an object');
|
|
234
|
+
}
|
|
235
|
+
if (typeof credential.name !== 'string' || !credential.name) {
|
|
236
|
+
throw new Error('Invalid credential response: missing or invalid name');
|
|
237
|
+
}
|
|
238
|
+
if (typeof credential.secret !== 'string' || !credential.secret) {
|
|
239
|
+
throw new Error('Invalid credential response: missing or invalid secret');
|
|
240
|
+
}
|
|
241
|
+
return credential;
|
|
242
|
+
}
|
|
243
|
+
finally {
|
|
244
|
+
// Always clear timeout to prevent memory leaks
|
|
245
|
+
clearTimeout(timeoutId);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
catch (error) {
|
|
249
|
+
lastError = error;
|
|
250
|
+
// Don't retry on abort (timeout)
|
|
251
|
+
if (error instanceof Error && error.name === 'AbortError') {
|
|
252
|
+
throw new Error(`Credential generation timed out after ${timeout}ms`);
|
|
253
|
+
}
|
|
254
|
+
// Don't retry on 4xx errors (client errors) - check actual status
|
|
255
|
+
if (httpStatus !== null && httpStatus >= 400 && httpStatus < 500) {
|
|
256
|
+
throw error;
|
|
257
|
+
}
|
|
258
|
+
// Retry with exponential backoff + jitter for network/server errors (5xx or network failures)
|
|
259
|
+
// Jitter prevents thundering herd when many clients retry simultaneously
|
|
260
|
+
// Cap backoff to prevent excessive waits
|
|
261
|
+
if (attempt < maxRetries - 1) {
|
|
262
|
+
const backoffMs = Math.min(1000 * Math.pow(2, attempt) + Math.random() * 1000, RondevuAPI.MAX_BACKOFF_MS);
|
|
263
|
+
await new Promise(resolve => setTimeout(resolve, backoffMs));
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
throw new Error(`Failed to generate credentials after ${maxRetries} attempts: ${lastError?.message || 'Unknown error'}`);
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* Generate a random secret locally (for advanced use cases)
|
|
271
|
+
* @param cryptoAdapter - Optional crypto adapter
|
|
272
|
+
*/
|
|
273
|
+
static generateSecret(cryptoAdapter) {
|
|
274
|
+
const adapter = cryptoAdapter || new WebCryptoAdapter();
|
|
275
|
+
return adapter.generateSecret();
|
|
276
|
+
}
|
|
277
|
+
// ============================================
|
|
278
|
+
// Tags-based Offer Management (v2)
|
|
279
|
+
// ============================================
|
|
280
|
+
/**
|
|
281
|
+
* Publish offers with tags
|
|
282
|
+
*/
|
|
283
|
+
async publish(request) {
|
|
284
|
+
const rpcRequest = {
|
|
285
|
+
method: 'publishOffer',
|
|
286
|
+
params: {
|
|
287
|
+
tags: request.tags,
|
|
288
|
+
offers: request.offers,
|
|
289
|
+
ttl: request.ttl,
|
|
290
|
+
},
|
|
291
|
+
};
|
|
292
|
+
const authHeaders = await this.generateAuthHeaders(rpcRequest);
|
|
293
|
+
return await this.rpc(rpcRequest, authHeaders);
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* Discover offers by tags
|
|
297
|
+
* @param request - Discovery request with tags and optional pagination
|
|
298
|
+
* @returns Paginated response if limit provided, single offer if not
|
|
299
|
+
*/
|
|
300
|
+
async discover(request) {
|
|
301
|
+
const rpcRequest = {
|
|
302
|
+
method: 'discover',
|
|
303
|
+
params: {
|
|
304
|
+
tags: request.tags,
|
|
305
|
+
limit: request.limit,
|
|
306
|
+
offset: request.offset,
|
|
307
|
+
},
|
|
308
|
+
};
|
|
309
|
+
const authHeaders = await this.generateAuthHeaders(rpcRequest);
|
|
310
|
+
return await this.rpc(rpcRequest, authHeaders);
|
|
311
|
+
}
|
|
312
|
+
/**
|
|
313
|
+
* Delete an offer by ID
|
|
314
|
+
*/
|
|
315
|
+
async deleteOffer(offerId) {
|
|
316
|
+
const request = {
|
|
317
|
+
method: 'deleteOffer',
|
|
318
|
+
params: { offerId },
|
|
319
|
+
};
|
|
320
|
+
const authHeaders = await this.generateAuthHeaders(request);
|
|
321
|
+
return await this.rpc(request, authHeaders);
|
|
322
|
+
}
|
|
323
|
+
// ============================================
|
|
324
|
+
// WebRTC Signaling
|
|
325
|
+
// ============================================
|
|
326
|
+
/**
|
|
327
|
+
* Answer an offer
|
|
328
|
+
*/
|
|
329
|
+
async answerOffer(offerId, sdp) {
|
|
330
|
+
const request = {
|
|
331
|
+
method: 'answerOffer',
|
|
332
|
+
params: { offerId, sdp },
|
|
333
|
+
};
|
|
334
|
+
const authHeaders = await this.generateAuthHeaders(request);
|
|
335
|
+
await this.rpc(request, authHeaders);
|
|
336
|
+
}
|
|
337
|
+
/**
|
|
338
|
+
* Get answer for a specific offer (offerer polls this)
|
|
339
|
+
*/
|
|
340
|
+
async getOfferAnswer(offerId) {
|
|
341
|
+
try {
|
|
342
|
+
const request = {
|
|
343
|
+
method: 'getOfferAnswer',
|
|
344
|
+
params: { offerId },
|
|
345
|
+
};
|
|
346
|
+
const authHeaders = await this.generateAuthHeaders(request);
|
|
347
|
+
return await this.rpc(request, authHeaders);
|
|
348
|
+
}
|
|
349
|
+
catch (err) {
|
|
350
|
+
if (err.message.includes('not yet answered')) {
|
|
351
|
+
return null;
|
|
352
|
+
}
|
|
353
|
+
throw err;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* Combined polling for answers and ICE candidates
|
|
358
|
+
*/
|
|
359
|
+
async poll(since) {
|
|
360
|
+
const request = {
|
|
361
|
+
method: 'poll',
|
|
362
|
+
params: { since },
|
|
363
|
+
};
|
|
364
|
+
const authHeaders = await this.generateAuthHeaders(request);
|
|
365
|
+
return await this.rpc(request, authHeaders);
|
|
366
|
+
}
|
|
367
|
+
/**
|
|
368
|
+
* Add ICE candidates to a specific offer
|
|
369
|
+
*/
|
|
370
|
+
async addOfferIceCandidates(offerId, candidates) {
|
|
371
|
+
const request = {
|
|
372
|
+
method: 'addIceCandidates',
|
|
373
|
+
params: { offerId, candidates },
|
|
374
|
+
};
|
|
375
|
+
const authHeaders = await this.generateAuthHeaders(request);
|
|
376
|
+
return await this.rpc(request, authHeaders);
|
|
377
|
+
}
|
|
378
|
+
/**
|
|
379
|
+
* Get ICE candidates for a specific offer
|
|
380
|
+
*/
|
|
381
|
+
async getOfferIceCandidates(offerId, since = 0) {
|
|
382
|
+
const request = {
|
|
383
|
+
method: 'getIceCandidates',
|
|
384
|
+
params: { offerId, since },
|
|
385
|
+
};
|
|
386
|
+
const authHeaders = await this.generateAuthHeaders(request);
|
|
387
|
+
const result = await this.rpc(request, authHeaders);
|
|
388
|
+
return {
|
|
389
|
+
candidates: result.candidates || [],
|
|
390
|
+
offerId: result.offerId,
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
// Default values for credential generation
|
|
395
|
+
RondevuAPI.DEFAULT_MAX_RETRIES = 3;
|
|
396
|
+
RondevuAPI.DEFAULT_TIMEOUT_MS = 30000; // 30 seconds
|
|
397
|
+
RondevuAPI.DEFAULT_CREDENTIAL_NAME_MAX_LENGTH = 128;
|
|
398
|
+
RondevuAPI.DEFAULT_SECRET_MIN_LENGTH = 64; // 256 bits
|
|
399
|
+
RondevuAPI.MAX_BACKOFF_MS = 60000; // 60 seconds max backoff
|
|
400
|
+
RondevuAPI.MAX_CANONICALIZE_DEPTH = 100; // Prevent stack overflow
|
|
@@ -1,15 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Answerer-side WebRTC connection with answer creation and offer processing
|
|
3
3
|
*/
|
|
4
|
-
import { RondevuConnection } from './
|
|
5
|
-
import { RondevuAPI } from '
|
|
6
|
-
import { ConnectionConfig } from './
|
|
4
|
+
import { RondevuConnection } from './base.js';
|
|
5
|
+
import { RondevuAPI, IceCandidate } from '../api/client.js';
|
|
6
|
+
import { ConnectionConfig } from './config.js';
|
|
7
|
+
import { WebRTCAdapter } from '../webrtc/adapter.js';
|
|
7
8
|
export interface AnswererOptions {
|
|
8
9
|
api: RondevuAPI;
|
|
9
|
-
|
|
10
|
+
ownerUsername: string;
|
|
11
|
+
tags: string[];
|
|
10
12
|
offerId: string;
|
|
11
13
|
offerSdp: string;
|
|
12
14
|
rtcConfig?: RTCConfiguration;
|
|
15
|
+
webrtcAdapter?: WebRTCAdapter;
|
|
13
16
|
config?: Partial<ConnectionConfig>;
|
|
14
17
|
}
|
|
15
18
|
/**
|
|
@@ -17,7 +20,8 @@ export interface AnswererOptions {
|
|
|
17
20
|
*/
|
|
18
21
|
export declare class AnswererConnection extends RondevuConnection {
|
|
19
22
|
private api;
|
|
20
|
-
private
|
|
23
|
+
private ownerUsername;
|
|
24
|
+
private tags;
|
|
21
25
|
private offerId;
|
|
22
26
|
private offerSdp;
|
|
23
27
|
constructor(options: AnswererOptions);
|
|
@@ -30,15 +34,28 @@ export declare class AnswererConnection extends RondevuConnection {
|
|
|
30
34
|
*/
|
|
31
35
|
protected onLocalIceCandidate(candidate: RTCIceCandidate): void;
|
|
32
36
|
/**
|
|
33
|
-
*
|
|
37
|
+
* Get the API instance
|
|
34
38
|
*/
|
|
35
|
-
protected
|
|
39
|
+
protected getApi(): any;
|
|
36
40
|
/**
|
|
37
|
-
*
|
|
41
|
+
* Get the owner username
|
|
42
|
+
*/
|
|
43
|
+
protected getOwnerUsername(): string;
|
|
44
|
+
/**
|
|
45
|
+
* Answerers accept ICE candidates from offerers only
|
|
46
|
+
*/
|
|
47
|
+
protected getIceCandidateRole(): 'offerer' | null;
|
|
48
|
+
/**
|
|
49
|
+
* Attempt to reconnect to the same user
|
|
38
50
|
*/
|
|
39
51
|
protected attemptReconnect(): void;
|
|
40
52
|
/**
|
|
41
53
|
* Get the offer ID we're answering
|
|
42
54
|
*/
|
|
43
55
|
getOfferId(): string;
|
|
56
|
+
/**
|
|
57
|
+
* Handle remote ICE candidates received from polling
|
|
58
|
+
* Called by Rondevu when poll:ice event is received
|
|
59
|
+
*/
|
|
60
|
+
handleRemoteIceCandidates(candidates: IceCandidate[]): void;
|
|
44
61
|
}
|