ai-functions 0.4.0 → 2.0.2
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/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +17 -0
- package/dist/rpc/auth.d.ts +69 -0
- package/dist/rpc/auth.d.ts.map +1 -0
- package/dist/rpc/auth.js +136 -0
- package/dist/rpc/auth.js.map +1 -0
- package/dist/rpc/client.d.ts +62 -0
- package/dist/rpc/client.d.ts.map +1 -0
- package/dist/rpc/client.js +103 -0
- package/dist/rpc/client.js.map +1 -0
- package/dist/rpc/deferred.d.ts +60 -0
- package/dist/rpc/deferred.d.ts.map +1 -0
- package/dist/rpc/deferred.js +96 -0
- package/dist/rpc/deferred.js.map +1 -0
- package/dist/rpc/index.d.ts +22 -0
- package/dist/rpc/index.d.ts.map +1 -0
- package/dist/rpc/index.js +38 -0
- package/dist/rpc/index.js.map +1 -0
- package/dist/rpc/local.d.ts +42 -0
- package/dist/rpc/local.d.ts.map +1 -0
- package/dist/rpc/local.js +50 -0
- package/dist/rpc/local.js.map +1 -0
- package/dist/rpc/server.d.ts +165 -0
- package/dist/rpc/server.d.ts.map +1 -0
- package/dist/rpc/server.js +405 -0
- package/dist/rpc/server.js.map +1 -0
- package/dist/rpc/session.d.ts +32 -0
- package/dist/rpc/session.d.ts.map +1 -0
- package/dist/rpc/session.js +43 -0
- package/dist/rpc/session.js.map +1 -0
- package/dist/rpc/transport.d.ts +306 -0
- package/dist/rpc/transport.d.ts.map +1 -0
- package/dist/rpc/transport.js +731 -0
- package/dist/rpc/transport.js.map +1 -0
- package/package.json +3 -3
- package/.turbo/turbo-test.log +0 -105
|
@@ -0,0 +1,731 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RPC Transport Layer
|
|
3
|
+
*
|
|
4
|
+
* Unified transport abstraction supporting:
|
|
5
|
+
* - HTTP batch requests
|
|
6
|
+
* - WebSocket persistent connections
|
|
7
|
+
* - postMessage for iframe/worker communication
|
|
8
|
+
* - Bidirectional RPC callbacks
|
|
9
|
+
* - Async iterators for streaming
|
|
10
|
+
*
|
|
11
|
+
* @packageDocumentation
|
|
12
|
+
*/
|
|
13
|
+
// =============================================================================
|
|
14
|
+
// Callback Registry
|
|
15
|
+
// =============================================================================
|
|
16
|
+
/**
|
|
17
|
+
* Generate a cryptographically random ID
|
|
18
|
+
*/
|
|
19
|
+
function generateSecureId() {
|
|
20
|
+
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
|
|
21
|
+
return crypto.randomUUID();
|
|
22
|
+
}
|
|
23
|
+
// Fallback for environments without crypto.randomUUID
|
|
24
|
+
const array = new Uint8Array(16);
|
|
25
|
+
if (typeof crypto !== 'undefined' && crypto.getRandomValues) {
|
|
26
|
+
crypto.getRandomValues(array);
|
|
27
|
+
}
|
|
28
|
+
else {
|
|
29
|
+
// Last resort fallback (less secure)
|
|
30
|
+
for (let i = 0; i < 16; i++) {
|
|
31
|
+
array[i] = Math.floor(Math.random() * 256);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return Array.from(array, b => b.toString(16).padStart(2, '0')).join('');
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Registry for tracking callbacks that can be invoked remotely
|
|
38
|
+
*
|
|
39
|
+
* Security features:
|
|
40
|
+
* - Cryptographically random callback IDs
|
|
41
|
+
* - Automatic expiration (TTL)
|
|
42
|
+
* - Invocation limits
|
|
43
|
+
*/
|
|
44
|
+
export class CallbackRegistry {
|
|
45
|
+
callbacks = new Map();
|
|
46
|
+
cleanupInterval = null;
|
|
47
|
+
defaultTtl;
|
|
48
|
+
constructor(options = {}) {
|
|
49
|
+
this.defaultTtl = options.defaultTtl ?? 300000; // 5 minutes default
|
|
50
|
+
// Periodic cleanup of expired callbacks
|
|
51
|
+
this.cleanupInterval = setInterval(() => this.cleanup(), 60000);
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Register a callback and get its ID
|
|
55
|
+
*/
|
|
56
|
+
register(fn, options = {}) {
|
|
57
|
+
const id = `cb_${generateSecureId()}`;
|
|
58
|
+
const ttl = options.ttl ?? this.defaultTtl;
|
|
59
|
+
this.callbacks.set(id, {
|
|
60
|
+
fn,
|
|
61
|
+
invocations: 0,
|
|
62
|
+
maxInvocations: options.maxInvocations ?? Infinity,
|
|
63
|
+
expiresAt: Date.now() + ttl,
|
|
64
|
+
});
|
|
65
|
+
return id;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Invoke a callback by ID
|
|
69
|
+
*/
|
|
70
|
+
async invoke(id, args) {
|
|
71
|
+
const entry = this.callbacks.get(id);
|
|
72
|
+
if (!entry) {
|
|
73
|
+
throw new Error(`Callback not found: ${id}`);
|
|
74
|
+
}
|
|
75
|
+
// Check expiration
|
|
76
|
+
if (Date.now() > entry.expiresAt) {
|
|
77
|
+
this.callbacks.delete(id);
|
|
78
|
+
throw new Error(`Callback expired: ${id}`);
|
|
79
|
+
}
|
|
80
|
+
// Check invocation limit
|
|
81
|
+
if (entry.invocations >= entry.maxInvocations) {
|
|
82
|
+
this.callbacks.delete(id);
|
|
83
|
+
throw new Error(`Callback invocation limit reached: ${id}`);
|
|
84
|
+
}
|
|
85
|
+
entry.invocations++;
|
|
86
|
+
// Auto-cleanup if max invocations reached
|
|
87
|
+
if (entry.invocations >= entry.maxInvocations) {
|
|
88
|
+
this.callbacks.delete(id);
|
|
89
|
+
}
|
|
90
|
+
return entry.fn(...args);
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Remove a callback
|
|
94
|
+
*/
|
|
95
|
+
unregister(id) {
|
|
96
|
+
return this.callbacks.delete(id);
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Remove expired callbacks
|
|
100
|
+
*/
|
|
101
|
+
cleanup() {
|
|
102
|
+
const now = Date.now();
|
|
103
|
+
for (const [id, entry] of this.callbacks) {
|
|
104
|
+
if (now > entry.expiresAt) {
|
|
105
|
+
this.callbacks.delete(id);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Stop the cleanup interval
|
|
111
|
+
*/
|
|
112
|
+
destroy() {
|
|
113
|
+
if (this.cleanupInterval) {
|
|
114
|
+
clearInterval(this.cleanupInterval);
|
|
115
|
+
this.cleanupInterval = null;
|
|
116
|
+
}
|
|
117
|
+
this.callbacks.clear();
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Check if a value contains callbacks and serialize them
|
|
121
|
+
*/
|
|
122
|
+
serializeWithCallbacks(value, options = {}) {
|
|
123
|
+
const callbacks = new Map();
|
|
124
|
+
const serialize = (v, path) => {
|
|
125
|
+
if (typeof v === 'function') {
|
|
126
|
+
const id = this.register(v, options);
|
|
127
|
+
callbacks.set(path, id);
|
|
128
|
+
return { __callback__: id };
|
|
129
|
+
}
|
|
130
|
+
if (Array.isArray(v)) {
|
|
131
|
+
return v.map((item, i) => serialize(item, `${path}[${i}]`));
|
|
132
|
+
}
|
|
133
|
+
if (v && typeof v === 'object') {
|
|
134
|
+
const result = {};
|
|
135
|
+
for (const [key, val] of Object.entries(v)) {
|
|
136
|
+
result[key] = serialize(val, `${path}.${key}`);
|
|
137
|
+
}
|
|
138
|
+
return result;
|
|
139
|
+
}
|
|
140
|
+
return v;
|
|
141
|
+
};
|
|
142
|
+
return {
|
|
143
|
+
value: serialize(value, ''),
|
|
144
|
+
callbacks,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* HTTP batch transport - groups multiple calls into single requests
|
|
150
|
+
*/
|
|
151
|
+
export class HTTPTransport {
|
|
152
|
+
url;
|
|
153
|
+
headers;
|
|
154
|
+
timeout;
|
|
155
|
+
batchDelay;
|
|
156
|
+
maxBatchSize;
|
|
157
|
+
pendingRequests = [];
|
|
158
|
+
flushScheduled = false;
|
|
159
|
+
subscribers = new Set();
|
|
160
|
+
callbackRegistry = new CallbackRegistry();
|
|
161
|
+
state = 'connected';
|
|
162
|
+
constructor(options) {
|
|
163
|
+
this.url = options.url;
|
|
164
|
+
this.headers = options.headers ?? {};
|
|
165
|
+
this.timeout = options.timeout ?? 30000;
|
|
166
|
+
this.batchDelay = options.batchDelay ?? 0;
|
|
167
|
+
this.maxBatchSize = options.maxBatchSize ?? 100;
|
|
168
|
+
}
|
|
169
|
+
send(message) {
|
|
170
|
+
this.request(message).catch(() => {
|
|
171
|
+
// Fire and forget
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
async request(message) {
|
|
175
|
+
return new Promise((resolve, reject) => {
|
|
176
|
+
// Serialize any callbacks in params
|
|
177
|
+
if (message.params) {
|
|
178
|
+
const { value } = this.callbackRegistry.serializeWithCallbacks(message.params);
|
|
179
|
+
message = { ...message, params: value };
|
|
180
|
+
}
|
|
181
|
+
this.pendingRequests.push({ message, resolve, reject });
|
|
182
|
+
this.scheduleFlush();
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
scheduleFlush() {
|
|
186
|
+
if (this.flushScheduled)
|
|
187
|
+
return;
|
|
188
|
+
this.flushScheduled = true;
|
|
189
|
+
if (this.batchDelay === 0) {
|
|
190
|
+
queueMicrotask(() => this.flush());
|
|
191
|
+
}
|
|
192
|
+
else {
|
|
193
|
+
setTimeout(() => this.flush(), this.batchDelay);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
async flush() {
|
|
197
|
+
this.flushScheduled = false;
|
|
198
|
+
if (this.pendingRequests.length === 0)
|
|
199
|
+
return;
|
|
200
|
+
const batch = this.pendingRequests.splice(0, this.maxBatchSize);
|
|
201
|
+
// Schedule next flush if more pending
|
|
202
|
+
if (this.pendingRequests.length > 0) {
|
|
203
|
+
this.scheduleFlush();
|
|
204
|
+
}
|
|
205
|
+
try {
|
|
206
|
+
const controller = new AbortController();
|
|
207
|
+
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
|
|
208
|
+
const response = await fetch(this.url, {
|
|
209
|
+
method: 'POST',
|
|
210
|
+
headers: {
|
|
211
|
+
'Content-Type': 'application/json',
|
|
212
|
+
...this.headers,
|
|
213
|
+
},
|
|
214
|
+
body: JSON.stringify(batch.map(p => p.message)),
|
|
215
|
+
signal: controller.signal,
|
|
216
|
+
});
|
|
217
|
+
clearTimeout(timeoutId);
|
|
218
|
+
if (!response.ok) {
|
|
219
|
+
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
220
|
+
}
|
|
221
|
+
const results = (await response.json());
|
|
222
|
+
const resultMap = new Map(results.map(r => [r.id, r]));
|
|
223
|
+
for (const pending of batch) {
|
|
224
|
+
const result = resultMap.get(pending.message.id);
|
|
225
|
+
if (!result) {
|
|
226
|
+
pending.reject(new Error(`No response for message ${pending.message.id}`));
|
|
227
|
+
}
|
|
228
|
+
else {
|
|
229
|
+
// Return both success and error responses - let caller handle errors
|
|
230
|
+
pending.resolve(result);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
catch (error) {
|
|
235
|
+
for (const pending of batch) {
|
|
236
|
+
pending.reject(error instanceof Error ? error : new Error(String(error)));
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
subscribe(handler) {
|
|
241
|
+
this.subscribers.add(handler);
|
|
242
|
+
return () => this.subscribers.delete(handler);
|
|
243
|
+
}
|
|
244
|
+
close() {
|
|
245
|
+
this.state = 'disconnected';
|
|
246
|
+
// Reject any pending requests
|
|
247
|
+
for (const pending of this.pendingRequests) {
|
|
248
|
+
pending.reject(new Error('Transport closed'));
|
|
249
|
+
}
|
|
250
|
+
this.pendingRequests = [];
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* WebSocket transport - persistent connection with bidirectional communication
|
|
255
|
+
*/
|
|
256
|
+
export class WebSocketTransport {
|
|
257
|
+
url;
|
|
258
|
+
ws = null;
|
|
259
|
+
reconnect;
|
|
260
|
+
reconnectDelay;
|
|
261
|
+
maxReconnectAttempts;
|
|
262
|
+
pingInterval;
|
|
263
|
+
reconnectAttempts = 0;
|
|
264
|
+
pingTimer = null;
|
|
265
|
+
pendingRequests = new Map();
|
|
266
|
+
streamHandlers = new Map();
|
|
267
|
+
subscribers = new Set();
|
|
268
|
+
callbackRegistry = new CallbackRegistry();
|
|
269
|
+
state = 'disconnected';
|
|
270
|
+
constructor(options) {
|
|
271
|
+
this.url = options.url;
|
|
272
|
+
this.reconnect = options.reconnect ?? true;
|
|
273
|
+
this.reconnectDelay = options.reconnectDelay ?? 1000;
|
|
274
|
+
this.maxReconnectAttempts = options.maxReconnectAttempts ?? 10;
|
|
275
|
+
this.pingInterval = options.pingInterval ?? 30000;
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* Connect to the WebSocket server
|
|
279
|
+
*/
|
|
280
|
+
async connect() {
|
|
281
|
+
if (this.state === 'connected')
|
|
282
|
+
return;
|
|
283
|
+
this.state = 'connecting';
|
|
284
|
+
return new Promise((resolve, reject) => {
|
|
285
|
+
this.ws = new WebSocket(this.url);
|
|
286
|
+
this.ws.onopen = () => {
|
|
287
|
+
this.state = 'connected';
|
|
288
|
+
this.reconnectAttempts = 0;
|
|
289
|
+
this.startPing();
|
|
290
|
+
resolve();
|
|
291
|
+
};
|
|
292
|
+
this.ws.onerror = (event) => {
|
|
293
|
+
if (this.state === 'connecting') {
|
|
294
|
+
reject(new Error('WebSocket connection failed'));
|
|
295
|
+
}
|
|
296
|
+
};
|
|
297
|
+
this.ws.onclose = () => {
|
|
298
|
+
this.state = 'disconnected';
|
|
299
|
+
this.stopPing();
|
|
300
|
+
this.handleDisconnect();
|
|
301
|
+
};
|
|
302
|
+
this.ws.onmessage = (event) => {
|
|
303
|
+
try {
|
|
304
|
+
const message = JSON.parse(event.data);
|
|
305
|
+
this.handleMessage(message);
|
|
306
|
+
}
|
|
307
|
+
catch (error) {
|
|
308
|
+
console.error('Invalid WebSocket message:', error);
|
|
309
|
+
}
|
|
310
|
+
};
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
handleMessage(message) {
|
|
314
|
+
// Handle pending request responses
|
|
315
|
+
const pending = this.pendingRequests.get(message.id);
|
|
316
|
+
if (pending) {
|
|
317
|
+
this.pendingRequests.delete(message.id);
|
|
318
|
+
// Return both success and error responses - let caller handle errors
|
|
319
|
+
pending.resolve(message);
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
// Handle stream messages
|
|
323
|
+
if (message.type === 'stream' || message.type === 'stream-end') {
|
|
324
|
+
const handler = this.streamHandlers.get(message.id);
|
|
325
|
+
if (handler) {
|
|
326
|
+
handler(message.chunk, message.type === 'stream-end');
|
|
327
|
+
if (message.type === 'stream-end') {
|
|
328
|
+
this.streamHandlers.delete(message.id);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
// Handle callback invocations
|
|
334
|
+
if (message.type === 'callback' && message.callbackId) {
|
|
335
|
+
this.handleCallback(message);
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
// Notify subscribers
|
|
339
|
+
for (const sub of this.subscribers) {
|
|
340
|
+
sub(message);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
async handleCallback(message) {
|
|
344
|
+
try {
|
|
345
|
+
const result = await this.callbackRegistry.invoke(message.callbackId, message.params ?? []);
|
|
346
|
+
this.send({
|
|
347
|
+
id: message.id,
|
|
348
|
+
type: 'result',
|
|
349
|
+
result,
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
catch (error) {
|
|
353
|
+
this.send({
|
|
354
|
+
id: message.id,
|
|
355
|
+
type: 'error',
|
|
356
|
+
error: {
|
|
357
|
+
message: error instanceof Error ? error.message : String(error),
|
|
358
|
+
},
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
handleDisconnect() {
|
|
363
|
+
// Reject all pending requests
|
|
364
|
+
for (const [id, pending] of this.pendingRequests) {
|
|
365
|
+
pending.reject(new Error('WebSocket disconnected'));
|
|
366
|
+
}
|
|
367
|
+
this.pendingRequests.clear();
|
|
368
|
+
// End all streams
|
|
369
|
+
for (const [id, handler] of this.streamHandlers) {
|
|
370
|
+
handler(undefined, true);
|
|
371
|
+
}
|
|
372
|
+
this.streamHandlers.clear();
|
|
373
|
+
// Attempt reconnect
|
|
374
|
+
if (this.reconnect && this.reconnectAttempts < this.maxReconnectAttempts) {
|
|
375
|
+
this.reconnectAttempts++;
|
|
376
|
+
setTimeout(() => this.connect(), this.reconnectDelay * this.reconnectAttempts);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
startPing() {
|
|
380
|
+
this.pingTimer = setInterval(() => {
|
|
381
|
+
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
382
|
+
this.ws.send(JSON.stringify({ type: 'ping' }));
|
|
383
|
+
}
|
|
384
|
+
}, this.pingInterval);
|
|
385
|
+
}
|
|
386
|
+
stopPing() {
|
|
387
|
+
if (this.pingTimer) {
|
|
388
|
+
clearInterval(this.pingTimer);
|
|
389
|
+
this.pingTimer = null;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
send(message) {
|
|
393
|
+
if (this.ws?.readyState !== WebSocket.OPEN) {
|
|
394
|
+
throw new Error('WebSocket not connected');
|
|
395
|
+
}
|
|
396
|
+
// Serialize callbacks
|
|
397
|
+
if (message.params) {
|
|
398
|
+
const { value } = this.callbackRegistry.serializeWithCallbacks(message.params);
|
|
399
|
+
message = { ...message, params: value };
|
|
400
|
+
}
|
|
401
|
+
this.ws.send(JSON.stringify(message));
|
|
402
|
+
}
|
|
403
|
+
async request(message) {
|
|
404
|
+
if (this.state !== 'connected') {
|
|
405
|
+
await this.connect();
|
|
406
|
+
}
|
|
407
|
+
return new Promise((resolve, reject) => {
|
|
408
|
+
this.pendingRequests.set(message.id, { resolve, reject });
|
|
409
|
+
this.send(message);
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
/**
|
|
413
|
+
* Create an async iterator for streaming results
|
|
414
|
+
*/
|
|
415
|
+
async *stream(message) {
|
|
416
|
+
if (this.state !== 'connected') {
|
|
417
|
+
await this.connect();
|
|
418
|
+
}
|
|
419
|
+
const chunks = [];
|
|
420
|
+
let done = false;
|
|
421
|
+
let error = null;
|
|
422
|
+
let resolve = null;
|
|
423
|
+
this.streamHandlers.set(message.id, (chunk, isDone) => {
|
|
424
|
+
if (isDone) {
|
|
425
|
+
done = true;
|
|
426
|
+
}
|
|
427
|
+
else if (chunk !== undefined) {
|
|
428
|
+
chunks.push(chunk);
|
|
429
|
+
}
|
|
430
|
+
resolve?.();
|
|
431
|
+
});
|
|
432
|
+
this.send(message);
|
|
433
|
+
try {
|
|
434
|
+
while (!done) {
|
|
435
|
+
if (chunks.length > 0) {
|
|
436
|
+
yield chunks.shift();
|
|
437
|
+
}
|
|
438
|
+
else {
|
|
439
|
+
await new Promise(r => { resolve = r; });
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
// Yield any remaining chunks
|
|
443
|
+
while (chunks.length > 0) {
|
|
444
|
+
yield chunks.shift();
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
finally {
|
|
448
|
+
this.streamHandlers.delete(message.id);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
subscribe(handler) {
|
|
452
|
+
this.subscribers.add(handler);
|
|
453
|
+
return () => this.subscribers.delete(handler);
|
|
454
|
+
}
|
|
455
|
+
close() {
|
|
456
|
+
this.reconnect = false;
|
|
457
|
+
this.stopPing();
|
|
458
|
+
this.ws?.close();
|
|
459
|
+
this.state = 'disconnected';
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
// =============================================================================
|
|
463
|
+
// postMessage Transport
|
|
464
|
+
// =============================================================================
|
|
465
|
+
/** Valid RPC message types */
|
|
466
|
+
const VALID_MESSAGE_TYPES = new Set([
|
|
467
|
+
'call', 'result', 'error', 'callback', 'stream', 'stream-end', 'cancel', 'ping', 'pong'
|
|
468
|
+
]);
|
|
469
|
+
/**
|
|
470
|
+
* Validate that a message conforms to RPCMessage schema
|
|
471
|
+
*/
|
|
472
|
+
function isValidRPCMessage(data) {
|
|
473
|
+
if (!data || typeof data !== 'object')
|
|
474
|
+
return false;
|
|
475
|
+
const msg = data;
|
|
476
|
+
// Required: id must be a string
|
|
477
|
+
if (typeof msg.id !== 'string' || msg.id.length === 0)
|
|
478
|
+
return false;
|
|
479
|
+
// Required: type must be valid
|
|
480
|
+
if (typeof msg.type !== 'string' || !VALID_MESSAGE_TYPES.has(msg.type))
|
|
481
|
+
return false;
|
|
482
|
+
// Optional field validation
|
|
483
|
+
if (msg.method !== undefined && typeof msg.method !== 'string')
|
|
484
|
+
return false;
|
|
485
|
+
if (msg.params !== undefined && !Array.isArray(msg.params))
|
|
486
|
+
return false;
|
|
487
|
+
if (msg.callbackId !== undefined && typeof msg.callbackId !== 'string')
|
|
488
|
+
return false;
|
|
489
|
+
return true;
|
|
490
|
+
}
|
|
491
|
+
/**
|
|
492
|
+
* postMessage transport - for iframe/worker communication
|
|
493
|
+
*
|
|
494
|
+
* Security features:
|
|
495
|
+
* - Mandatory origin validation for Window targets
|
|
496
|
+
* - Message schema validation
|
|
497
|
+
* - Request timeouts
|
|
498
|
+
* - Max pending requests limit
|
|
499
|
+
* - Optional HMAC message signing
|
|
500
|
+
*/
|
|
501
|
+
export class PostMessageTransport {
|
|
502
|
+
target;
|
|
503
|
+
targetOrigin;
|
|
504
|
+
sourceOrigin;
|
|
505
|
+
isWindowTarget;
|
|
506
|
+
timeout;
|
|
507
|
+
maxPendingRequests;
|
|
508
|
+
secret;
|
|
509
|
+
pendingRequests = new Map();
|
|
510
|
+
subscribers = new Set();
|
|
511
|
+
callbackRegistry = new CallbackRegistry();
|
|
512
|
+
messageHandler;
|
|
513
|
+
state = 'connected';
|
|
514
|
+
constructor(options) {
|
|
515
|
+
this.target = options.target;
|
|
516
|
+
this.timeout = options.timeout ?? 30000;
|
|
517
|
+
this.maxPendingRequests = options.maxPendingRequests ?? 1000;
|
|
518
|
+
this.secret = options.secret;
|
|
519
|
+
// Determine if target is a Window
|
|
520
|
+
this.isWindowTarget = typeof Window !== 'undefined' && this.target instanceof Window;
|
|
521
|
+
// Security: For Window targets, require explicit origins
|
|
522
|
+
if (this.isWindowTarget) {
|
|
523
|
+
if (!options.targetOrigin || options.targetOrigin === '*') {
|
|
524
|
+
if (!options.allowUnsafeOrigin) {
|
|
525
|
+
throw new Error('PostMessageTransport: targetOrigin is required for Window targets. ' +
|
|
526
|
+
'Using "*" is insecure. Set allowUnsafeOrigin: true if you understand the risks.');
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
if (!options.sourceOrigin) {
|
|
530
|
+
if (!options.allowUnsafeOrigin) {
|
|
531
|
+
throw new Error('PostMessageTransport: sourceOrigin is required for Window targets. ' +
|
|
532
|
+
'Without origin validation, any origin can send messages. ' +
|
|
533
|
+
'Set allowUnsafeOrigin: true if you understand the risks.');
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
this.targetOrigin = options.targetOrigin ?? '*';
|
|
538
|
+
this.sourceOrigin = options.sourceOrigin;
|
|
539
|
+
this.messageHandler = this.handleMessage.bind(this);
|
|
540
|
+
// Subscribe to messages
|
|
541
|
+
if ('addEventListener' in this.target) {
|
|
542
|
+
this.target.addEventListener('message', this.messageHandler);
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
handleMessage(event) {
|
|
546
|
+
// Validate origin for Window targets
|
|
547
|
+
if (this.isWindowTarget && this.sourceOrigin && event.origin !== this.sourceOrigin) {
|
|
548
|
+
console.warn(`PostMessageTransport: Rejected message from untrusted origin: ${event.origin}`);
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
// Validate message schema
|
|
552
|
+
if (!isValidRPCMessage(event.data)) {
|
|
553
|
+
// Silently ignore invalid messages (could be from other sources)
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
const message = event.data;
|
|
557
|
+
// Verify HMAC signature if secret is configured
|
|
558
|
+
if (this.secret) {
|
|
559
|
+
const providedSig = message.__sig__;
|
|
560
|
+
if (!providedSig || !this.verifySignature(message, providedSig)) {
|
|
561
|
+
console.warn('PostMessageTransport: Rejected message with invalid signature');
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
// Handle pending request responses
|
|
566
|
+
const pending = this.pendingRequests.get(message.id);
|
|
567
|
+
if (pending) {
|
|
568
|
+
clearTimeout(pending.timeoutId);
|
|
569
|
+
this.pendingRequests.delete(message.id);
|
|
570
|
+
pending.resolve(message);
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
// Handle callback invocations
|
|
574
|
+
if (message.type === 'callback' && message.callbackId) {
|
|
575
|
+
this.handleCallback(message);
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
// Notify subscribers
|
|
579
|
+
for (const sub of this.subscribers) {
|
|
580
|
+
sub(message);
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
async handleCallback(message) {
|
|
584
|
+
try {
|
|
585
|
+
const result = await this.callbackRegistry.invoke(message.callbackId, message.params ?? []);
|
|
586
|
+
this.send({
|
|
587
|
+
id: message.id,
|
|
588
|
+
type: 'result',
|
|
589
|
+
result,
|
|
590
|
+
});
|
|
591
|
+
}
|
|
592
|
+
catch (error) {
|
|
593
|
+
this.send({
|
|
594
|
+
id: message.id,
|
|
595
|
+
type: 'error',
|
|
596
|
+
error: {
|
|
597
|
+
message: error instanceof Error ? error.message : String(error),
|
|
598
|
+
},
|
|
599
|
+
});
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
/**
|
|
603
|
+
* Sign a message with HMAC-SHA256
|
|
604
|
+
*/
|
|
605
|
+
async signMessage(message) {
|
|
606
|
+
if (!this.secret)
|
|
607
|
+
return '';
|
|
608
|
+
const data = JSON.stringify({ id: message.id, type: message.type, method: message.method });
|
|
609
|
+
const encoder = new TextEncoder();
|
|
610
|
+
if (typeof crypto !== 'undefined' && crypto.subtle) {
|
|
611
|
+
const key = await crypto.subtle.importKey('raw', encoder.encode(this.secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
|
|
612
|
+
const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(data));
|
|
613
|
+
return Array.from(new Uint8Array(signature), b => b.toString(16).padStart(2, '0')).join('');
|
|
614
|
+
}
|
|
615
|
+
// Fallback: simple hash (less secure, but better than nothing)
|
|
616
|
+
let hash = 0;
|
|
617
|
+
const combined = this.secret + data;
|
|
618
|
+
for (let i = 0; i < combined.length; i++) {
|
|
619
|
+
hash = ((hash << 5) - hash) + combined.charCodeAt(i);
|
|
620
|
+
hash = hash & hash;
|
|
621
|
+
}
|
|
622
|
+
return Math.abs(hash).toString(16);
|
|
623
|
+
}
|
|
624
|
+
/**
|
|
625
|
+
* Verify message signature
|
|
626
|
+
*/
|
|
627
|
+
verifySignature(message, providedSig) {
|
|
628
|
+
// For sync verification, we use the simple hash fallback
|
|
629
|
+
// A more complete implementation would use async verification
|
|
630
|
+
if (!this.secret)
|
|
631
|
+
return true;
|
|
632
|
+
const data = JSON.stringify({ id: message.id, type: message.type, method: message.method });
|
|
633
|
+
let hash = 0;
|
|
634
|
+
const combined = this.secret + data;
|
|
635
|
+
for (let i = 0; i < combined.length; i++) {
|
|
636
|
+
hash = ((hash << 5) - hash) + combined.charCodeAt(i);
|
|
637
|
+
hash = hash & hash;
|
|
638
|
+
}
|
|
639
|
+
const expectedSig = Math.abs(hash).toString(16);
|
|
640
|
+
return providedSig === expectedSig;
|
|
641
|
+
}
|
|
642
|
+
send(message) {
|
|
643
|
+
// Serialize callbacks
|
|
644
|
+
if (message.params) {
|
|
645
|
+
const { value } = this.callbackRegistry.serializeWithCallbacks(message.params);
|
|
646
|
+
message = { ...message, params: value };
|
|
647
|
+
}
|
|
648
|
+
// Add signature if secret is configured (sync version for send)
|
|
649
|
+
if (this.secret) {
|
|
650
|
+
const data = JSON.stringify({ id: message.id, type: message.type, method: message.method });
|
|
651
|
+
let hash = 0;
|
|
652
|
+
const combined = this.secret + data;
|
|
653
|
+
for (let i = 0; i < combined.length; i++) {
|
|
654
|
+
hash = ((hash << 5) - hash) + combined.charCodeAt(i);
|
|
655
|
+
hash = hash & hash;
|
|
656
|
+
}
|
|
657
|
+
;
|
|
658
|
+
message.__sig__ = Math.abs(hash).toString(16);
|
|
659
|
+
}
|
|
660
|
+
if ('postMessage' in this.target) {
|
|
661
|
+
if (this.isWindowTarget) {
|
|
662
|
+
this.target.postMessage(message, this.targetOrigin);
|
|
663
|
+
}
|
|
664
|
+
else {
|
|
665
|
+
this.target.postMessage(message);
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
async request(message) {
|
|
670
|
+
if (this.state === 'disconnected') {
|
|
671
|
+
return Promise.reject(new Error('Transport is closed'));
|
|
672
|
+
}
|
|
673
|
+
if (this.pendingRequests.size >= this.maxPendingRequests) {
|
|
674
|
+
return Promise.reject(new Error('Too many pending requests'));
|
|
675
|
+
}
|
|
676
|
+
return new Promise((resolve, reject) => {
|
|
677
|
+
const timeoutId = setTimeout(() => {
|
|
678
|
+
this.pendingRequests.delete(message.id);
|
|
679
|
+
reject(new Error(`Request timeout: ${message.id}`));
|
|
680
|
+
}, this.timeout);
|
|
681
|
+
this.pendingRequests.set(message.id, { resolve, reject, timeoutId });
|
|
682
|
+
this.send(message);
|
|
683
|
+
});
|
|
684
|
+
}
|
|
685
|
+
subscribe(handler) {
|
|
686
|
+
this.subscribers.add(handler);
|
|
687
|
+
return () => this.subscribers.delete(handler);
|
|
688
|
+
}
|
|
689
|
+
close() {
|
|
690
|
+
if ('removeEventListener' in this.target) {
|
|
691
|
+
this.target.removeEventListener('message', this.messageHandler);
|
|
692
|
+
}
|
|
693
|
+
this.state = 'disconnected';
|
|
694
|
+
// Clear all pending requests
|
|
695
|
+
for (const pending of this.pendingRequests.values()) {
|
|
696
|
+
clearTimeout(pending.timeoutId);
|
|
697
|
+
pending.reject(new Error('Transport closed'));
|
|
698
|
+
}
|
|
699
|
+
this.pendingRequests.clear();
|
|
700
|
+
// Cleanup callback registry
|
|
701
|
+
this.callbackRegistry.destroy();
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
// =============================================================================
|
|
705
|
+
// Factory Functions
|
|
706
|
+
// =============================================================================
|
|
707
|
+
/**
|
|
708
|
+
* Create an HTTP transport
|
|
709
|
+
*/
|
|
710
|
+
export function createHTTPTransport(options) {
|
|
711
|
+
return new HTTPTransport(options);
|
|
712
|
+
}
|
|
713
|
+
/**
|
|
714
|
+
* Create a WebSocket transport
|
|
715
|
+
*/
|
|
716
|
+
export function createWebSocketTransport(options) {
|
|
717
|
+
return new WebSocketTransport(options);
|
|
718
|
+
}
|
|
719
|
+
/**
|
|
720
|
+
* Create a postMessage transport
|
|
721
|
+
*/
|
|
722
|
+
export function createPostMessageTransport(options) {
|
|
723
|
+
return new PostMessageTransport(options);
|
|
724
|
+
}
|
|
725
|
+
/**
|
|
726
|
+
* Generate a unique message ID
|
|
727
|
+
*/
|
|
728
|
+
export function generateMessageId() {
|
|
729
|
+
return `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 9)}`;
|
|
730
|
+
}
|
|
731
|
+
//# sourceMappingURL=transport.js.map
|