@olane/o-node 0.7.12-alpha.51 → 0.7.12-alpha.53
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/dist/src/connection/o-node-connection.d.ts +2 -0
- package/dist/src/connection/o-node-connection.d.ts.map +1 -1
- package/dist/src/connection/o-node-connection.js +22 -60
- package/dist/src/connection/o-node-connection.manager.d.ts.map +1 -1
- package/dist/src/connection/o-node-connection.manager.js +2 -1
- package/dist/src/connection/stream-handler.config.d.ts +46 -0
- package/dist/src/connection/stream-handler.config.d.ts.map +1 -0
- package/dist/src/connection/stream-handler.config.js +1 -0
- package/dist/src/connection/stream-handler.d.ts +95 -0
- package/dist/src/connection/stream-handler.d.ts.map +1 -0
- package/dist/src/connection/stream-handler.js +297 -0
- package/dist/src/connection/stream-handler.spec.d.ts +2 -0
- package/dist/src/connection/stream-handler.spec.d.ts.map +1 -0
- package/dist/src/connection/stream-handler.spec.js +309 -0
- package/dist/src/o-node.tool.d.ts +1 -0
- package/dist/src/o-node.tool.d.ts.map +1 -1
- package/dist/src/o-node.tool.js +9 -26
- package/package.json +6 -6
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { Connection, Stream } from '@olane/o-config';
|
|
2
2
|
import { oConnection, oRequest, oResponse } from '@olane/o-core';
|
|
3
3
|
import { oNodeConnectionConfig } from './interfaces/o-node-connection.config.js';
|
|
4
|
+
import { StreamHandler } from './stream-handler.js';
|
|
4
5
|
export declare class oNodeConnection extends oConnection {
|
|
5
6
|
protected readonly config: oNodeConnectionConfig;
|
|
6
7
|
p2pConnection: Connection;
|
|
8
|
+
protected streamHandler: StreamHandler;
|
|
7
9
|
constructor(config: oNodeConnectionConfig);
|
|
8
10
|
setupConnectionListeners(): void;
|
|
9
11
|
validate(stream?: Stream): void;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"o-node-connection.d.ts","sourceRoot":"","sources":["../../../src/connection/o-node-connection.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,EAAE,MAAM,iBAAiB,CAAC;AACrD,OAAO,EAEL,WAAW,EAGX,QAAQ,EACR,SAAS,EACV,MAAM,eAAe,CAAC;AACvB,OAAO,EAAE,qBAAqB,EAAE,MAAM,0CAA0C,CAAC;
|
|
1
|
+
{"version":3,"file":"o-node-connection.d.ts","sourceRoot":"","sources":["../../../src/connection/o-node-connection.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,EAAE,MAAM,iBAAiB,CAAC;AACrD,OAAO,EAEL,WAAW,EAGX,QAAQ,EACR,SAAS,EACV,MAAM,eAAe,CAAC;AACvB,OAAO,EAAE,qBAAqB,EAAE,MAAM,0CAA0C,CAAC;AACjF,OAAO,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAGpD,qBAAa,eAAgB,SAAQ,WAAW;IAIlC,SAAS,CAAC,QAAQ,CAAC,MAAM,EAAE,qBAAqB;IAHrD,aAAa,EAAE,UAAU,CAAC;IACjC,SAAS,CAAC,aAAa,EAAE,aAAa,CAAC;gBAER,MAAM,EAAE,qBAAqB;IAO5D,wBAAwB;IAYxB,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM;IAmBlB,iBAAiB,IAAI,OAAO,CAAC,MAAM,CAAC;IAkBpC,QAAQ,CAAC,OAAO,EAAE,QAAQ,GAAG,OAAO,CAAC,SAAS,CAAC;IAsC/C,YAAY,CAAC,MAAM,EAAE,MAAM;IAQ3B,KAAK,CAAC,KAAK,EAAE,KAAK;IAMlB,KAAK;CAKZ"}
|
|
@@ -1,9 +1,11 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { oConnection, oError, oErrorCodes, } from '@olane/o-core';
|
|
2
|
+
import { StreamHandler } from './stream-handler.js';
|
|
2
3
|
export class oNodeConnection extends oConnection {
|
|
3
4
|
constructor(config) {
|
|
4
5
|
super(config);
|
|
5
6
|
this.config = config;
|
|
6
7
|
this.p2pConnection = config.p2pConnection;
|
|
8
|
+
this.streamHandler = new StreamHandler(this.logger);
|
|
7
9
|
this.setupConnectionListeners();
|
|
8
10
|
}
|
|
9
11
|
setupConnectionListeners() {
|
|
@@ -26,16 +28,16 @@ export class oNodeConnection extends oConnection {
|
|
|
26
28
|
}
|
|
27
29
|
}
|
|
28
30
|
async getOrCreateStream() {
|
|
29
|
-
|
|
30
|
-
throw new oError(oErrorCodes.INVALID_STATE, 'Connection not open');
|
|
31
|
-
}
|
|
32
|
-
return this.p2pConnection.newStream(this.nextHopAddress.protocol, {
|
|
31
|
+
const streamConfig = {
|
|
33
32
|
signal: this.abortSignal,
|
|
34
33
|
maxOutboundStreams: process.env.MAX_OUTBOUND_STREAMS
|
|
35
34
|
? parseInt(process.env.MAX_OUTBOUND_STREAMS)
|
|
36
35
|
: 1000,
|
|
37
36
|
runOnLimitedConnection: this.config.runOnLimitedConnection ?? false,
|
|
38
|
-
|
|
37
|
+
reusePolicy: 'none', // Default policy, can be overridden in subclasses
|
|
38
|
+
drainTimeoutMs: this.config.drainTimeoutMs,
|
|
39
|
+
};
|
|
40
|
+
return this.streamHandler.getOrCreateStream(this.p2pConnection, this.nextHopAddress.protocol, streamConfig);
|
|
39
41
|
}
|
|
40
42
|
async transmit(request) {
|
|
41
43
|
try {
|
|
@@ -44,54 +46,18 @@ export class oNodeConnection extends oConnection {
|
|
|
44
46
|
}
|
|
45
47
|
const stream = await this.getOrCreateStream();
|
|
46
48
|
this.validate(stream);
|
|
47
|
-
|
|
49
|
+
const streamConfig = {
|
|
50
|
+
signal: this.abortSignal,
|
|
51
|
+
drainTimeoutMs: this.config.drainTimeoutMs,
|
|
52
|
+
reusePolicy: 'none', // Default policy
|
|
53
|
+
};
|
|
54
|
+
// Send the request with backpressure handling
|
|
48
55
|
const data = new TextEncoder().encode(request.toString());
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
await
|
|
52
|
-
|
|
53
|
-
try {
|
|
54
|
-
await stream.abort(new Error('Request aborted'));
|
|
55
|
-
}
|
|
56
|
-
catch (e) {
|
|
57
|
-
// Stream may already be closed
|
|
58
|
-
}
|
|
59
|
-
reject(new Error('Request aborted'));
|
|
60
|
-
};
|
|
61
|
-
// Listen for abort signal
|
|
62
|
-
if (this.abortSignal) {
|
|
63
|
-
this.abortSignal.addEventListener('abort', abortHandler);
|
|
64
|
-
}
|
|
65
|
-
stream.addEventListener('message', async (event) => {
|
|
66
|
-
const response = await CoreUtils.processStreamResponse(event);
|
|
67
|
-
this.emitter.emit('chunk', response);
|
|
68
|
-
// marked as the last chunk let's close
|
|
69
|
-
if (response.result._last || !response.result._isStreaming) {
|
|
70
|
-
lastResponse = response;
|
|
71
|
-
// Clean up abort listener before closing
|
|
72
|
-
if (this.abortSignal) {
|
|
73
|
-
this.abortSignal.removeEventListener('abort', abortHandler);
|
|
74
|
-
}
|
|
75
|
-
resolve(true);
|
|
76
|
-
}
|
|
77
|
-
});
|
|
78
|
-
});
|
|
79
|
-
stream.addEventListener('close', async () => {
|
|
80
|
-
this.logger.debug('Stream closed by remote peer, closing connection...');
|
|
81
|
-
stream.close().catch((error) => {
|
|
82
|
-
this.logger.error('Error closing stream: ', error);
|
|
83
|
-
});
|
|
84
|
-
});
|
|
85
|
-
// If send() returns false, wait for the stream to drain before continuing
|
|
86
|
-
if (!sent) {
|
|
87
|
-
this.logger.debug('Stream buffer full, waiting for drain...');
|
|
88
|
-
await stream.onDrain({
|
|
89
|
-
signal: AbortSignal.timeout(this.config.drainTimeoutMs ?? 30000),
|
|
90
|
-
}); // Default: 30 second timeout
|
|
91
|
-
}
|
|
92
|
-
// handle cleanup of the stream
|
|
56
|
+
await this.streamHandler.send(stream, data, streamConfig);
|
|
57
|
+
// Handle response using StreamHandler
|
|
58
|
+
const response = await this.streamHandler.handleOutgoingStream(stream, this.emitter, streamConfig);
|
|
59
|
+
// Handle cleanup of the stream
|
|
93
60
|
await this.postTransmit(stream);
|
|
94
|
-
const response = oResponse.fromJSON(lastResponse);
|
|
95
61
|
return response;
|
|
96
62
|
}
|
|
97
63
|
catch (error) {
|
|
@@ -102,14 +68,10 @@ export class oNodeConnection extends oConnection {
|
|
|
102
68
|
}
|
|
103
69
|
}
|
|
104
70
|
async postTransmit(stream) {
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
}
|
|
110
|
-
else {
|
|
111
|
-
this.logger.debug('Stream is not open, skipping cleanup');
|
|
112
|
-
}
|
|
71
|
+
const streamConfig = {
|
|
72
|
+
reusePolicy: 'none', // Default policy, can be overridden in subclasses
|
|
73
|
+
};
|
|
74
|
+
await this.streamHandler.close(stream, streamConfig);
|
|
113
75
|
}
|
|
114
76
|
async abort(error) {
|
|
115
77
|
this.logger.debug('Aborting connection');
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"o-node-connection.manager.d.ts","sourceRoot":"","sources":["../../../src/connection/o-node-connection.manager.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,iBAAiB,EAAE,kBAAkB,EAAE,MAAM,eAAe,CAAC;AAChF,OAAO,EAAU,UAAU,EAAU,MAAM,iBAAiB,CAAC;AAC7D,OAAO,EAAE,4BAA4B,EAAE,MAAM,kDAAkD,CAAC;AAEhG,OAAO,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAC;AAEzD,qBAAa,sBAAuB,SAAQ,kBAAkB;IAKhD,QAAQ,CAAC,MAAM,EAAE,4BAA4B;IAJzD,OAAO,CAAC,OAAO,CAAS;IACxB,OAAO,CAAC,oBAAoB,CAAC,CAAS;IACtC,OAAO,CAAC,qBAAqB,CAAC,CAAS;gBAElB,MAAM,EAAE,4BAA4B;IAOnD,qBAAqB,CACzB,cAAc,EAAE,QAAQ,EACxB,OAAO,EAAE,QAAQ,GAChB,OAAO,CAAC,UAAU,CAAC;
|
|
1
|
+
{"version":3,"file":"o-node-connection.manager.d.ts","sourceRoot":"","sources":["../../../src/connection/o-node-connection.manager.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,iBAAiB,EAAE,kBAAkB,EAAE,MAAM,eAAe,CAAC;AAChF,OAAO,EAAU,UAAU,EAAU,MAAM,iBAAiB,CAAC;AAC7D,OAAO,EAAE,4BAA4B,EAAE,MAAM,kDAAkD,CAAC;AAEhG,OAAO,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAC;AAEzD,qBAAa,sBAAuB,SAAQ,kBAAkB;IAKhD,QAAQ,CAAC,MAAM,EAAE,4BAA4B;IAJzD,OAAO,CAAC,OAAO,CAAS;IACxB,OAAO,CAAC,oBAAoB,CAAC,CAAS;IACtC,OAAO,CAAC,qBAAqB,CAAC,CAAS;gBAElB,MAAM,EAAE,4BAA4B;IAOnD,qBAAqB,CACzB,cAAc,EAAE,QAAQ,EACxB,OAAO,EAAE,QAAQ,GAChB,OAAO,CAAC,UAAU,CAAC;IA4BtB;;;;OAIG;IACG,OAAO,CAAC,MAAM,EAAE,iBAAiB,GAAG,OAAO,CAAC,eAAe,CAAC;IA6BlE;;;;OAIG;IACH,QAAQ,CAAC,OAAO,EAAE,QAAQ,GAAG,OAAO;IA4BpC;;;;OAIG;IACH,yBAAyB,CAAC,OAAO,EAAE,QAAQ,GAAG,UAAU,GAAG,IAAI;CA0BhE"}
|
|
@@ -13,7 +13,8 @@ export class oNodeConnectionManager extends oConnectionManager {
|
|
|
13
13
|
const existingConnection = this.getCachedLibp2pConnection(nextHopAddress);
|
|
14
14
|
let p2pConnection;
|
|
15
15
|
if (existingConnection && existingConnection.status === 'open') {
|
|
16
|
-
this.logger.debug('Reusing existing libp2p connection for address: ' +
|
|
16
|
+
this.logger.debug('Reusing existing libp2p connection for address: ' +
|
|
17
|
+
nextHopAddress.toString());
|
|
17
18
|
p2pConnection = existingConnection;
|
|
18
19
|
}
|
|
19
20
|
else {
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/// <reference types="node" />
|
|
2
|
+
import type { Stream } from '@libp2p/interface';
|
|
3
|
+
/**
|
|
4
|
+
* Stream reuse policy determines how streams are managed across multiple requests
|
|
5
|
+
*/
|
|
6
|
+
export type StreamReusePolicy = 'none' | 'reuse' | 'pool';
|
|
7
|
+
/**
|
|
8
|
+
* Configuration for StreamHandler behavior
|
|
9
|
+
*/
|
|
10
|
+
export interface StreamHandlerConfig {
|
|
11
|
+
/**
|
|
12
|
+
* Stream reuse policy:
|
|
13
|
+
* - 'none': Create new stream for each request (default)
|
|
14
|
+
* - 'reuse': Reuse existing open streams
|
|
15
|
+
* - 'pool': (Future) Maintain a pool of streams
|
|
16
|
+
*/
|
|
17
|
+
reusePolicy?: StreamReusePolicy;
|
|
18
|
+
/**
|
|
19
|
+
* Timeout in milliseconds to wait for stream drain when buffer is full
|
|
20
|
+
* @default 30000 (30 seconds)
|
|
21
|
+
*/
|
|
22
|
+
drainTimeoutMs?: number;
|
|
23
|
+
/**
|
|
24
|
+
* Whether this connection can run on limited connections
|
|
25
|
+
* @default false
|
|
26
|
+
*/
|
|
27
|
+
runOnLimitedConnection?: boolean;
|
|
28
|
+
/**
|
|
29
|
+
* Maximum number of outbound streams
|
|
30
|
+
* @default 1000
|
|
31
|
+
*/
|
|
32
|
+
maxOutboundStreams?: number;
|
|
33
|
+
/**
|
|
34
|
+
* AbortSignal for cancellation
|
|
35
|
+
*/
|
|
36
|
+
signal?: AbortSignal;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Context for stream lifecycle operations
|
|
40
|
+
*/
|
|
41
|
+
export interface StreamContext {
|
|
42
|
+
stream: Stream;
|
|
43
|
+
protocol: string;
|
|
44
|
+
config: StreamHandlerConfig;
|
|
45
|
+
}
|
|
46
|
+
//# sourceMappingURL=stream-handler.config.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"stream-handler.config.d.ts","sourceRoot":"","sources":["../../../src/connection/stream-handler.config.ts"],"names":[],"mappings":";AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,mBAAmB,CAAC;AAEhD;;GAEG;AACH,MAAM,MAAM,iBAAiB,GAAG,MAAM,GAAG,OAAO,GAAG,MAAM,CAAC;AAE1D;;GAEG;AACH,MAAM,WAAW,mBAAmB;IAClC;;;;;OAKG;IACH,WAAW,CAAC,EAAE,iBAAiB,CAAC;IAEhC;;;OAGG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;IAExB;;;OAGG;IACH,sBAAsB,CAAC,EAAE,OAAO,CAAC;IAEjC;;;OAGG;IACH,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAE5B;;OAEG;IACH,MAAM,CAAC,EAAE,WAAW,CAAC;CACtB;AAED;;GAEG;AACH,MAAM,WAAW,aAAa;IAC5B,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,mBAAmB,CAAC;CAC7B"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/// <reference types="node" />
|
|
2
|
+
import type { Connection, Stream } from '@libp2p/interface';
|
|
3
|
+
import { EventEmitter } from 'events';
|
|
4
|
+
import { oRequest, oResponse, Logger } from '@olane/o-core';
|
|
5
|
+
import type { oRouterRequest } from '@olane/o-core';
|
|
6
|
+
import type { oConnection } from '@olane/o-core';
|
|
7
|
+
import type { RunResult } from '@olane/o-tool';
|
|
8
|
+
import type { StreamHandlerConfig } from './stream-handler.config.js';
|
|
9
|
+
/**
|
|
10
|
+
* StreamHandler centralizes all stream-related functionality including:
|
|
11
|
+
* - Message type detection (request vs response)
|
|
12
|
+
* - Stream lifecycle management (create, reuse, close)
|
|
13
|
+
* - Backpressure handling
|
|
14
|
+
* - Request/response handling
|
|
15
|
+
* - Stream routing for middleware nodes
|
|
16
|
+
*/
|
|
17
|
+
export declare class StreamHandler {
|
|
18
|
+
private logger;
|
|
19
|
+
constructor(logger?: Logger);
|
|
20
|
+
/**
|
|
21
|
+
* Detects if a decoded message is a request
|
|
22
|
+
* Requests have a 'method' field and no 'result' field
|
|
23
|
+
*/
|
|
24
|
+
isRequest(message: any): boolean;
|
|
25
|
+
/**
|
|
26
|
+
* Detects if a decoded message is a response
|
|
27
|
+
* Responses have a 'result' field and no 'method' field
|
|
28
|
+
*/
|
|
29
|
+
isResponse(message: any): boolean;
|
|
30
|
+
/**
|
|
31
|
+
* Decodes a stream message event into a JSON object
|
|
32
|
+
*/
|
|
33
|
+
decodeMessage(event: any): Promise<any>;
|
|
34
|
+
/**
|
|
35
|
+
* Gets an existing open stream or creates a new one based on reuse policy
|
|
36
|
+
*
|
|
37
|
+
* @param connection - The libp2p connection
|
|
38
|
+
* @param protocol - The protocol to use for the stream
|
|
39
|
+
* @param config - Stream handler configuration
|
|
40
|
+
*/
|
|
41
|
+
getOrCreateStream(connection: Connection, protocol: string, config?: StreamHandlerConfig): Promise<Stream>;
|
|
42
|
+
/**
|
|
43
|
+
* Sends data through a stream with backpressure handling
|
|
44
|
+
*
|
|
45
|
+
* @param stream - The stream to send data through
|
|
46
|
+
* @param data - The data to send
|
|
47
|
+
* @param config - Configuration for timeout and other options
|
|
48
|
+
*/
|
|
49
|
+
send(stream: Stream, data: Uint8Array, config?: StreamHandlerConfig): Promise<void>;
|
|
50
|
+
/**
|
|
51
|
+
* Closes a stream safely with error handling
|
|
52
|
+
*
|
|
53
|
+
* @param stream - The stream to close
|
|
54
|
+
* @param config - Configuration including reuse policy
|
|
55
|
+
*/
|
|
56
|
+
close(stream: Stream, config?: StreamHandlerConfig): Promise<void>;
|
|
57
|
+
/**
|
|
58
|
+
* Handles an incoming stream on the server side
|
|
59
|
+
* Attaches message listener immediately (libp2p v3 best practice)
|
|
60
|
+
* Routes requests or executes tools based on the message
|
|
61
|
+
*
|
|
62
|
+
* @param stream - The incoming stream
|
|
63
|
+
* @param connection - The connection the stream belongs to
|
|
64
|
+
* @param toolExecutor - Function to execute tools for requests
|
|
65
|
+
*/
|
|
66
|
+
handleIncomingStream(stream: Stream, connection: Connection, toolExecutor: (request: oRequest, stream: Stream) => Promise<RunResult>): Promise<void>;
|
|
67
|
+
/**
|
|
68
|
+
* Handles a request message by executing the tool and sending response
|
|
69
|
+
*
|
|
70
|
+
* @param message - The decoded request message
|
|
71
|
+
* @param stream - The stream to send the response on
|
|
72
|
+
* @param toolExecutor - Function to execute the tool
|
|
73
|
+
*/
|
|
74
|
+
private handleRequestMessage;
|
|
75
|
+
/**
|
|
76
|
+
* Handles an outgoing stream on the client side
|
|
77
|
+
* Listens for response messages and emits them via the event emitter
|
|
78
|
+
*
|
|
79
|
+
* @param stream - The outgoing stream
|
|
80
|
+
* @param emitter - Event emitter for chunk events
|
|
81
|
+
* @param config - Configuration including abort signal
|
|
82
|
+
* @returns Promise that resolves with the final response
|
|
83
|
+
*/
|
|
84
|
+
handleOutgoingStream(stream: Stream, emitter: EventEmitter, config?: StreamHandlerConfig): Promise<oResponse>;
|
|
85
|
+
/**
|
|
86
|
+
* Forwards a request to the next hop and relays response chunks back
|
|
87
|
+
* This implements the middleware/proxy pattern for intermediate nodes
|
|
88
|
+
*
|
|
89
|
+
* @param request - The router request to forward
|
|
90
|
+
* @param incomingStream - The stream to send responses back on
|
|
91
|
+
* @param dialFn - Function to dial the next hop connection
|
|
92
|
+
*/
|
|
93
|
+
forwardRequest(request: oRouterRequest, incomingStream: Stream, dialFn: (address: string) => Promise<oConnection>): Promise<void>;
|
|
94
|
+
}
|
|
95
|
+
//# sourceMappingURL=stream-handler.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"stream-handler.d.ts","sourceRoot":"","sources":["../../../src/connection/stream-handler.ts"],"names":[],"mappings":";AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,EAAE,MAAM,mBAAmB,CAAC;AAC5D,OAAO,EAAE,YAAY,EAAE,MAAM,QAAQ,CAAC;AACtC,OAAO,EACL,QAAQ,EACR,SAAS,EAIT,MAAM,EAEP,MAAM,eAAe,CAAC;AACvB,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,eAAe,CAAC;AACpD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,eAAe,CAAC;AACjD,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,eAAe,CAAC;AAC/C,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,4BAA4B,CAAC;AAEtE;;;;;;;GAOG;AACH,qBAAa,aAAa;IACxB,OAAO,CAAC,MAAM,CAAS;gBAEX,MAAM,CAAC,EAAE,MAAM;IAI3B;;;OAGG;IACH,SAAS,CAAC,OAAO,EAAE,GAAG,GAAG,OAAO;IAIhC;;;OAGG;IACH,UAAU,CAAC,OAAO,EAAE,GAAG,GAAG,OAAO;IAIjC;;OAEG;IACG,aAAa,CAAC,KAAK,EAAE,GAAG,GAAG,OAAO,CAAC,GAAG,CAAC;IAI7C;;;;;;OAMG;IACG,iBAAiB,CACrB,UAAU,EAAE,UAAU,EACtB,QAAQ,EAAE,MAAM,EAChB,MAAM,GAAE,mBAAwB,GAC/B,OAAO,CAAC,MAAM,CAAC;IA2ClB;;;;;;OAMG;IACG,IAAI,CACR,MAAM,EAAE,MAAM,EACd,IAAI,EAAE,UAAU,EAChB,MAAM,GAAE,mBAAwB,GAC/B,OAAO,CAAC,IAAI,CAAC;IAiBhB;;;;;OAKG;IACG,KAAK,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,GAAE,mBAAwB,GAAG,OAAO,CAAC,IAAI,CAAC;IAiB5E;;;;;;;;OAQG;IACG,oBAAoB,CACxB,MAAM,EAAE,MAAM,EACd,UAAU,EAAE,UAAU,EACtB,YAAY,EAAE,CAAC,OAAO,EAAE,QAAQ,EAAE,MAAM,EAAE,MAAM,KAAK,OAAO,CAAC,SAAS,CAAC,GACtE,OAAO,CAAC,IAAI,CAAC;IA0ChB;;;;;;OAMG;YACW,oBAAoB;IAkBlC;;;;;;;;OAQG;IACG,oBAAoB,CACxB,MAAM,EAAE,MAAM,EACd,OAAO,EAAE,YAAY,EACrB,MAAM,GAAE,mBAAwB,GAC/B,OAAO,CAAC,SAAS,CAAC;IA4FrB;;;;;;;OAOG;IACG,cAAc,CAClB,OAAO,EAAE,cAAc,EACvB,cAAc,EAAE,MAAM,EACtB,MAAM,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,OAAO,CAAC,WAAW,CAAC,GAChD,OAAO,CAAC,IAAI,CAAC;CAyBjB"}
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
import { oRequest, CoreUtils, oError, oErrorCodes, Logger, ResponseBuilder, } from '@olane/o-core';
|
|
2
|
+
/**
|
|
3
|
+
* StreamHandler centralizes all stream-related functionality including:
|
|
4
|
+
* - Message type detection (request vs response)
|
|
5
|
+
* - Stream lifecycle management (create, reuse, close)
|
|
6
|
+
* - Backpressure handling
|
|
7
|
+
* - Request/response handling
|
|
8
|
+
* - Stream routing for middleware nodes
|
|
9
|
+
*/
|
|
10
|
+
export class StreamHandler {
|
|
11
|
+
constructor(logger) {
|
|
12
|
+
this.logger = logger ?? new Logger('StreamHandler');
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Detects if a decoded message is a request
|
|
16
|
+
* Requests have a 'method' field and no 'result' field
|
|
17
|
+
*/
|
|
18
|
+
isRequest(message) {
|
|
19
|
+
return typeof message?.method === 'string' && message.result === undefined;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Detects if a decoded message is a response
|
|
23
|
+
* Responses have a 'result' field and no 'method' field
|
|
24
|
+
*/
|
|
25
|
+
isResponse(message) {
|
|
26
|
+
return message?.result !== undefined && message.method === undefined;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Decodes a stream message event into a JSON object
|
|
30
|
+
*/
|
|
31
|
+
async decodeMessage(event) {
|
|
32
|
+
return CoreUtils.processStream(event);
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Gets an existing open stream or creates a new one based on reuse policy
|
|
36
|
+
*
|
|
37
|
+
* @param connection - The libp2p connection
|
|
38
|
+
* @param protocol - The protocol to use for the stream
|
|
39
|
+
* @param config - Stream handler configuration
|
|
40
|
+
*/
|
|
41
|
+
async getOrCreateStream(connection, protocol, config = {}) {
|
|
42
|
+
if (connection.status !== 'open') {
|
|
43
|
+
throw new oError(oErrorCodes.INVALID_STATE, 'Connection not open');
|
|
44
|
+
}
|
|
45
|
+
const reusePolicy = config.reusePolicy ?? 'none';
|
|
46
|
+
this.logger.debug('Reuse policy:', reusePolicy);
|
|
47
|
+
// Check for existing stream if reuse is enabled
|
|
48
|
+
if (reusePolicy === 'reuse') {
|
|
49
|
+
this.logger.debug('Reusing existing stream if we can find one. Stream insights:', JSON.stringify({
|
|
50
|
+
streamCount: connection.streams.length,
|
|
51
|
+
protocol: protocol,
|
|
52
|
+
}));
|
|
53
|
+
connection.streams.forEach((stream) => {
|
|
54
|
+
this.logger.debug('Stream re-use option:', stream.protocol, stream.status, stream.direction);
|
|
55
|
+
});
|
|
56
|
+
const existingStream = connection.streams.find((stream) => stream.status === 'open' && stream.protocol?.length > 0);
|
|
57
|
+
if (existingStream) {
|
|
58
|
+
this.logger.debug('Reusing existing stream');
|
|
59
|
+
return existingStream;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
// Create new stream
|
|
63
|
+
this.logger.debug('Creating new stream');
|
|
64
|
+
return connection.newStream(protocol, {
|
|
65
|
+
signal: config.signal,
|
|
66
|
+
maxOutboundStreams: config.maxOutboundStreams ?? 1000,
|
|
67
|
+
runOnLimitedConnection: config.runOnLimitedConnection ?? false,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Sends data through a stream with backpressure handling
|
|
72
|
+
*
|
|
73
|
+
* @param stream - The stream to send data through
|
|
74
|
+
* @param data - The data to send
|
|
75
|
+
* @param config - Configuration for timeout and other options
|
|
76
|
+
*/
|
|
77
|
+
async send(stream, data, config = {}) {
|
|
78
|
+
// Send the data with backpressure handling (libp2p v3 best practice)
|
|
79
|
+
const sent = stream.send(data);
|
|
80
|
+
// If send() returns false, buffer is full - wait for drain
|
|
81
|
+
if (!sent) {
|
|
82
|
+
this.logger.debug('Stream buffer full, waiting for drain...');
|
|
83
|
+
const drainTimeout = config.drainTimeoutMs ?? 30000;
|
|
84
|
+
await stream.onDrain({
|
|
85
|
+
signal: AbortSignal.timeout(drainTimeout),
|
|
86
|
+
});
|
|
87
|
+
this.logger.debug('Stream drained successfully');
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Closes a stream safely with error handling
|
|
92
|
+
*
|
|
93
|
+
* @param stream - The stream to close
|
|
94
|
+
* @param config - Configuration including reuse policy
|
|
95
|
+
*/
|
|
96
|
+
async close(stream, config = {}) {
|
|
97
|
+
// Don't close if reuse policy is enabled
|
|
98
|
+
if (config.reusePolicy === 'reuse') {
|
|
99
|
+
this.logger.debug('Stream reuse enabled, not closing stream');
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
if (stream.status === 'open') {
|
|
103
|
+
try {
|
|
104
|
+
await stream.close();
|
|
105
|
+
this.logger.debug('Stream closed successfully');
|
|
106
|
+
}
|
|
107
|
+
catch (error) {
|
|
108
|
+
this.logger.debug('Error closing stream:', error.message);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Handles an incoming stream on the server side
|
|
114
|
+
* Attaches message listener immediately (libp2p v3 best practice)
|
|
115
|
+
* Routes requests or executes tools based on the message
|
|
116
|
+
*
|
|
117
|
+
* @param stream - The incoming stream
|
|
118
|
+
* @param connection - The connection the stream belongs to
|
|
119
|
+
* @param toolExecutor - Function to execute tools for requests
|
|
120
|
+
*/
|
|
121
|
+
async handleIncomingStream(stream, connection, toolExecutor) {
|
|
122
|
+
// CRITICAL: Attach message listener immediately to prevent buffer overflow (libp2p v3)
|
|
123
|
+
const messageHandler = async (event) => {
|
|
124
|
+
try {
|
|
125
|
+
// avoid processing non-olane messages
|
|
126
|
+
if (!event.data) {
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
const message = await this.decodeMessage(event);
|
|
130
|
+
if (typeof message === 'string') {
|
|
131
|
+
// this.logger.warn(
|
|
132
|
+
// 'Received string message on server-side stream, ignoring',
|
|
133
|
+
// message,
|
|
134
|
+
// );
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
if (this.isRequest(message)) {
|
|
138
|
+
await this.handleRequestMessage(message, stream, toolExecutor);
|
|
139
|
+
}
|
|
140
|
+
else if (this.isResponse(message)) {
|
|
141
|
+
this.logger.warn('Received response message on server-side stream, ignoring');
|
|
142
|
+
}
|
|
143
|
+
else {
|
|
144
|
+
this.logger.warn('Received unknown message type', message);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
catch (error) {
|
|
148
|
+
this.logger.error('Error handling stream message:', error);
|
|
149
|
+
// Error already logged, stream will be closed by remote peer or timeout
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
const closeHandler = () => {
|
|
153
|
+
this.logger.debug('Stream closed by remote peer');
|
|
154
|
+
stream.removeEventListener('message', messageHandler);
|
|
155
|
+
// stream.removeEventListener('close', closeHandler);
|
|
156
|
+
};
|
|
157
|
+
stream.addEventListener('message', messageHandler);
|
|
158
|
+
// stream.addEventListener('close', closeHandler);
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Handles a request message by executing the tool and sending response
|
|
162
|
+
*
|
|
163
|
+
* @param message - The decoded request message
|
|
164
|
+
* @param stream - The stream to send the response on
|
|
165
|
+
* @param toolExecutor - Function to execute the tool
|
|
166
|
+
*/
|
|
167
|
+
async handleRequestMessage(message, stream, toolExecutor) {
|
|
168
|
+
const request = new oRequest(message);
|
|
169
|
+
const responseBuilder = ResponseBuilder.create();
|
|
170
|
+
try {
|
|
171
|
+
const result = await toolExecutor(request, stream);
|
|
172
|
+
const response = await responseBuilder.build(request, result, null);
|
|
173
|
+
await CoreUtils.sendResponse(response, stream);
|
|
174
|
+
}
|
|
175
|
+
catch (error) {
|
|
176
|
+
const errorResponse = await responseBuilder.buildError(request, error);
|
|
177
|
+
await CoreUtils.sendResponse(errorResponse, stream);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Handles an outgoing stream on the client side
|
|
182
|
+
* Listens for response messages and emits them via the event emitter
|
|
183
|
+
*
|
|
184
|
+
* @param stream - The outgoing stream
|
|
185
|
+
* @param emitter - Event emitter for chunk events
|
|
186
|
+
* @param config - Configuration including abort signal
|
|
187
|
+
* @returns Promise that resolves with the final response
|
|
188
|
+
*/
|
|
189
|
+
async handleOutgoingStream(stream, emitter, config = {}) {
|
|
190
|
+
return new Promise((resolve, reject) => {
|
|
191
|
+
let lastResponse;
|
|
192
|
+
const messageHandler = async (event) => {
|
|
193
|
+
// avoid processing non-olane messages
|
|
194
|
+
if (!event.data) {
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
try {
|
|
198
|
+
const message = await this.decodeMessage(event);
|
|
199
|
+
if (typeof message === 'string') {
|
|
200
|
+
// this.logger.warn(
|
|
201
|
+
// 'Received string message on server-side stream, ignoring',
|
|
202
|
+
// message,
|
|
203
|
+
// );
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
if (this.isResponse(message)) {
|
|
207
|
+
const response = await CoreUtils.processStreamResponse(event);
|
|
208
|
+
// Emit chunk for streaming responses
|
|
209
|
+
emitter.emit('chunk', response);
|
|
210
|
+
// Check if this is the last chunk
|
|
211
|
+
if (response.result._last || !response.result._isStreaming) {
|
|
212
|
+
lastResponse = response;
|
|
213
|
+
cleanup();
|
|
214
|
+
resolve(response);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
else if (this.isRequest(message)) {
|
|
218
|
+
this.logger.warn('Received request message on client-side stream, ignoring');
|
|
219
|
+
}
|
|
220
|
+
else {
|
|
221
|
+
this.logger.warn('Received unknown message type', message);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
catch (error) {
|
|
225
|
+
this.logger.error('Error handling response message:', error);
|
|
226
|
+
cleanup();
|
|
227
|
+
reject(error);
|
|
228
|
+
}
|
|
229
|
+
};
|
|
230
|
+
const closeHandler = () => {
|
|
231
|
+
this.logger.debug('Stream closed by remote peer');
|
|
232
|
+
cleanup();
|
|
233
|
+
if (lastResponse) {
|
|
234
|
+
resolve(lastResponse);
|
|
235
|
+
}
|
|
236
|
+
else {
|
|
237
|
+
reject(new oError(oErrorCodes.TIMEOUT, 'Stream closed before response received'));
|
|
238
|
+
}
|
|
239
|
+
};
|
|
240
|
+
const abortHandler = () => {
|
|
241
|
+
this.logger.debug('Request aborted');
|
|
242
|
+
cleanup();
|
|
243
|
+
try {
|
|
244
|
+
stream.abort(new Error('Request aborted'));
|
|
245
|
+
}
|
|
246
|
+
catch (error) {
|
|
247
|
+
this.logger.debug('Error aborting stream:', error.message);
|
|
248
|
+
}
|
|
249
|
+
reject(new oError(oErrorCodes.TIMEOUT, 'Request aborted'));
|
|
250
|
+
};
|
|
251
|
+
const cleanup = () => {
|
|
252
|
+
stream.removeEventListener('message', messageHandler);
|
|
253
|
+
// stream.removeEventListener('close', closeHandler);
|
|
254
|
+
if (config.signal) {
|
|
255
|
+
config.signal.removeEventListener('abort', abortHandler);
|
|
256
|
+
}
|
|
257
|
+
};
|
|
258
|
+
stream.addEventListener('message', messageHandler);
|
|
259
|
+
// stream.addEventListener('close', closeHandler);
|
|
260
|
+
if (config.signal) {
|
|
261
|
+
config.signal.addEventListener('abort', abortHandler);
|
|
262
|
+
}
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* Forwards a request to the next hop and relays response chunks back
|
|
267
|
+
* This implements the middleware/proxy pattern for intermediate nodes
|
|
268
|
+
*
|
|
269
|
+
* @param request - The router request to forward
|
|
270
|
+
* @param incomingStream - The stream to send responses back on
|
|
271
|
+
* @param dialFn - Function to dial the next hop connection
|
|
272
|
+
*/
|
|
273
|
+
async forwardRequest(request, incomingStream, dialFn) {
|
|
274
|
+
try {
|
|
275
|
+
// Connect to next hop
|
|
276
|
+
const nextHopConnection = await dialFn(request.params.address);
|
|
277
|
+
// Set up chunk relay - forward responses from next hop back to incoming stream
|
|
278
|
+
nextHopConnection.onChunk(async (response) => {
|
|
279
|
+
try {
|
|
280
|
+
await CoreUtils.sendStreamResponse(response, incomingStream);
|
|
281
|
+
}
|
|
282
|
+
catch (error) {
|
|
283
|
+
this.logger.error('Error forwarding chunk:', error);
|
|
284
|
+
}
|
|
285
|
+
});
|
|
286
|
+
// Transmit the request to next hop
|
|
287
|
+
await nextHopConnection.transmit(request);
|
|
288
|
+
}
|
|
289
|
+
catch (error) {
|
|
290
|
+
this.logger.error('Error forwarding request:', error);
|
|
291
|
+
// Send error response back on incoming stream using ResponseBuilder
|
|
292
|
+
const responseBuilder = ResponseBuilder.create();
|
|
293
|
+
const errorResponse = await responseBuilder.buildError(request, error);
|
|
294
|
+
await CoreUtils.sendResponse(errorResponse, incomingStream);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"stream-handler.spec.d.ts","sourceRoot":"","sources":["../../../src/connection/stream-handler.spec.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
2
|
+
import { StreamHandler } from './stream-handler.js';
|
|
3
|
+
import { EventEmitter } from 'events';
|
|
4
|
+
import { oError, oErrorCodes } from '@o/o-core/src/error/o-error';
|
|
5
|
+
// Mock Stream interface
|
|
6
|
+
const createMockStream = (status = 'open') => {
|
|
7
|
+
const listeners = new Map();
|
|
8
|
+
return {
|
|
9
|
+
status,
|
|
10
|
+
send: vi.fn().mockReturnValue(true),
|
|
11
|
+
close: vi.fn().mockResolvedValue(undefined),
|
|
12
|
+
abort: vi.fn().mockResolvedValue(undefined),
|
|
13
|
+
onDrain: vi.fn().mockResolvedValue(undefined),
|
|
14
|
+
addEventListener: vi.fn((event, handler) => {
|
|
15
|
+
if (!listeners.has(event)) {
|
|
16
|
+
listeners.set(event, []);
|
|
17
|
+
}
|
|
18
|
+
listeners.get(event)?.push(handler);
|
|
19
|
+
}),
|
|
20
|
+
removeEventListener: vi.fn((event, handler) => {
|
|
21
|
+
const handlers = listeners.get(event);
|
|
22
|
+
if (handlers) {
|
|
23
|
+
const index = handlers.indexOf(handler);
|
|
24
|
+
if (index > -1) {
|
|
25
|
+
handlers.splice(index, 1);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}),
|
|
29
|
+
// Helper to trigger events in tests
|
|
30
|
+
_triggerEvent: (event, data) => {
|
|
31
|
+
listeners.get(event)?.forEach((handler) => handler(data));
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
};
|
|
35
|
+
// Mock Connection interface
|
|
36
|
+
const createMockConnection = (status = 'open') => {
|
|
37
|
+
return {
|
|
38
|
+
status,
|
|
39
|
+
streams: [],
|
|
40
|
+
newStream: vi.fn().mockImplementation(() => createMockStream()),
|
|
41
|
+
};
|
|
42
|
+
};
|
|
43
|
+
describe('StreamHandler', () => {
|
|
44
|
+
let streamHandler;
|
|
45
|
+
beforeEach(() => {
|
|
46
|
+
streamHandler = new StreamHandler();
|
|
47
|
+
});
|
|
48
|
+
describe('Message Type Detection', () => {
|
|
49
|
+
it('should identify a request message', () => {
|
|
50
|
+
const message = {
|
|
51
|
+
jsonrpc: '2.0',
|
|
52
|
+
id: '1',
|
|
53
|
+
method: 'test_method',
|
|
54
|
+
params: {},
|
|
55
|
+
};
|
|
56
|
+
expect(streamHandler.isRequest(message)).toBe(true);
|
|
57
|
+
expect(streamHandler.isResponse(message)).toBe(false);
|
|
58
|
+
});
|
|
59
|
+
it('should identify a response message', () => {
|
|
60
|
+
const message = {
|
|
61
|
+
jsonrpc: '2.0',
|
|
62
|
+
id: '1',
|
|
63
|
+
result: { success: true },
|
|
64
|
+
};
|
|
65
|
+
expect(streamHandler.isResponse(message)).toBe(true);
|
|
66
|
+
expect(streamHandler.isRequest(message)).toBe(false);
|
|
67
|
+
});
|
|
68
|
+
it('should not identify malformed message as request or response', () => {
|
|
69
|
+
const message = {
|
|
70
|
+
jsonrpc: '2.0',
|
|
71
|
+
id: '1',
|
|
72
|
+
};
|
|
73
|
+
expect(streamHandler.isRequest(message)).toBe(false);
|
|
74
|
+
expect(streamHandler.isResponse(message)).toBe(false);
|
|
75
|
+
});
|
|
76
|
+
it('should handle message with both method and result', () => {
|
|
77
|
+
const message = {
|
|
78
|
+
jsonrpc: '2.0',
|
|
79
|
+
id: '1',
|
|
80
|
+
method: 'test',
|
|
81
|
+
result: {},
|
|
82
|
+
};
|
|
83
|
+
// Should identify as request since method is present
|
|
84
|
+
expect(streamHandler.isRequest(message)).toBe(true);
|
|
85
|
+
expect(streamHandler.isResponse(message)).toBe(false);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
describe('Stream Lifecycle - getOrCreateStream', () => {
|
|
89
|
+
it('should create a new stream with none reuse policy', async () => {
|
|
90
|
+
const connection = createMockConnection();
|
|
91
|
+
const protocol = '/test/1.0.0';
|
|
92
|
+
const stream = await streamHandler.getOrCreateStream(connection, protocol, {
|
|
93
|
+
reusePolicy: 'none',
|
|
94
|
+
});
|
|
95
|
+
expect(stream).toBeDefined();
|
|
96
|
+
expect(connection.newStream).toHaveBeenCalledWith(protocol, expect.any(Object));
|
|
97
|
+
});
|
|
98
|
+
it('should reuse existing stream with reuse policy', async () => {
|
|
99
|
+
const existingStream = createMockStream();
|
|
100
|
+
const connection = createMockConnection();
|
|
101
|
+
connection.streams = [existingStream];
|
|
102
|
+
const stream = await streamHandler.getOrCreateStream(connection, '/test/1.0.0', { reusePolicy: 'reuse' });
|
|
103
|
+
expect(stream).toBe(existingStream);
|
|
104
|
+
expect(connection.newStream).not.toHaveBeenCalled();
|
|
105
|
+
});
|
|
106
|
+
it('should create new stream when no open stream exists with reuse policy', async () => {
|
|
107
|
+
const closedStream = createMockStream('closed');
|
|
108
|
+
const connection = createMockConnection();
|
|
109
|
+
connection.streams = [closedStream];
|
|
110
|
+
const stream = await streamHandler.getOrCreateStream(connection, '/test/1.0.0', { reusePolicy: 'reuse' });
|
|
111
|
+
expect(stream).not.toBe(closedStream);
|
|
112
|
+
expect(connection.newStream).toHaveBeenCalled();
|
|
113
|
+
});
|
|
114
|
+
it('should throw error when connection is not open', async () => {
|
|
115
|
+
const connection = createMockConnection('closed');
|
|
116
|
+
await expect(streamHandler.getOrCreateStream(connection, '/test/1.0.0')).rejects.toThrow();
|
|
117
|
+
});
|
|
118
|
+
it('should pass configuration options to newStream', async () => {
|
|
119
|
+
const connection = createMockConnection();
|
|
120
|
+
const signal = new AbortController().signal;
|
|
121
|
+
await streamHandler.getOrCreateStream(connection, '/test/1.0.0', {
|
|
122
|
+
signal,
|
|
123
|
+
maxOutboundStreams: 500,
|
|
124
|
+
runOnLimitedConnection: true,
|
|
125
|
+
});
|
|
126
|
+
expect(connection.newStream).toHaveBeenCalledWith('/test/1.0.0', {
|
|
127
|
+
signal,
|
|
128
|
+
maxOutboundStreams: 500,
|
|
129
|
+
runOnLimitedConnection: true,
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
describe('Stream Lifecycle - send', () => {
|
|
134
|
+
it('should send data without backpressure', async () => {
|
|
135
|
+
const stream = createMockStream();
|
|
136
|
+
stream.send = vi.fn().mockReturnValue(true);
|
|
137
|
+
const data = new TextEncoder().encode('test data');
|
|
138
|
+
await streamHandler.send(stream, data);
|
|
139
|
+
expect(stream.send).toHaveBeenCalledWith(data);
|
|
140
|
+
expect(stream.onDrain).not.toHaveBeenCalled();
|
|
141
|
+
});
|
|
142
|
+
it('should wait for drain when backpressure occurs', async () => {
|
|
143
|
+
const stream = createMockStream();
|
|
144
|
+
stream.send = vi.fn().mockReturnValue(false);
|
|
145
|
+
const data = new TextEncoder().encode('test data');
|
|
146
|
+
await streamHandler.send(stream, data, { drainTimeoutMs: 5000 });
|
|
147
|
+
expect(stream.send).toHaveBeenCalledWith(data);
|
|
148
|
+
expect(stream.onDrain).toHaveBeenCalledWith({
|
|
149
|
+
signal: expect.any(AbortSignal),
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
describe('Stream Lifecycle - close', () => {
|
|
154
|
+
it('should close stream when reuse policy is none', async () => {
|
|
155
|
+
const stream = createMockStream();
|
|
156
|
+
await streamHandler.close(stream, { reusePolicy: 'none' });
|
|
157
|
+
expect(stream.close).toHaveBeenCalled();
|
|
158
|
+
});
|
|
159
|
+
it('should not close stream when reuse policy is reuse', async () => {
|
|
160
|
+
const stream = createMockStream();
|
|
161
|
+
await streamHandler.close(stream, { reusePolicy: 'reuse' });
|
|
162
|
+
expect(stream.close).not.toHaveBeenCalled();
|
|
163
|
+
});
|
|
164
|
+
it('should not close stream when status is not open', async () => {
|
|
165
|
+
const stream = createMockStream('closed');
|
|
166
|
+
await streamHandler.close(stream, { reusePolicy: 'none' });
|
|
167
|
+
expect(stream.close).not.toHaveBeenCalled();
|
|
168
|
+
});
|
|
169
|
+
it('should handle errors during close gracefully', async () => {
|
|
170
|
+
const stream = createMockStream();
|
|
171
|
+
stream.close = vi.fn().mockRejectedValue(new Error('Close failed'));
|
|
172
|
+
// Should not throw
|
|
173
|
+
await expect(streamHandler.close(stream)).resolves.toBeUndefined();
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
describe('Server-side - handleIncomingStream', () => {
|
|
177
|
+
it('should attach message listener immediately', async () => {
|
|
178
|
+
const stream = createMockStream();
|
|
179
|
+
const connection = createMockConnection();
|
|
180
|
+
const toolExecutor = vi.fn().mockResolvedValue({ success: true });
|
|
181
|
+
await streamHandler.handleIncomingStream(stream, connection, toolExecutor);
|
|
182
|
+
expect(stream.addEventListener).toHaveBeenCalledWith('message', expect.any(Function));
|
|
183
|
+
expect(stream.addEventListener).toHaveBeenCalledWith('close', expect.any(Function));
|
|
184
|
+
});
|
|
185
|
+
it('should execute tool when request message is received', async () => {
|
|
186
|
+
const stream = createMockStream();
|
|
187
|
+
const connection = createMockConnection();
|
|
188
|
+
const toolExecutor = vi.fn().mockResolvedValue({ success: true });
|
|
189
|
+
await streamHandler.handleIncomingStream(stream, connection, toolExecutor);
|
|
190
|
+
// Simulate receiving a request
|
|
191
|
+
const requestData = {
|
|
192
|
+
data: new TextEncoder().encode(JSON.stringify({
|
|
193
|
+
jsonrpc: '2.0',
|
|
194
|
+
id: '1',
|
|
195
|
+
method: 'test_method',
|
|
196
|
+
params: {},
|
|
197
|
+
})),
|
|
198
|
+
};
|
|
199
|
+
stream._triggerEvent('message', requestData);
|
|
200
|
+
// Wait for async handling
|
|
201
|
+
await new Promise(resolve => setTimeout(resolve, 10));
|
|
202
|
+
expect(toolExecutor).toHaveBeenCalled();
|
|
203
|
+
});
|
|
204
|
+
it('should send error response when tool execution fails', async () => {
|
|
205
|
+
const stream = createMockStream();
|
|
206
|
+
const connection = createMockConnection();
|
|
207
|
+
const toolExecutor = vi.fn().mockRejectedValue(new oError(oErrorCodes.INTERNAL_ERROR, 'Tool execution failed'));
|
|
208
|
+
await streamHandler.handleIncomingStream(stream, connection, toolExecutor);
|
|
209
|
+
const requestData = {
|
|
210
|
+
data: new TextEncoder().encode(JSON.stringify({
|
|
211
|
+
jsonrpc: '2.0',
|
|
212
|
+
id: '1',
|
|
213
|
+
method: 'test_method',
|
|
214
|
+
params: {},
|
|
215
|
+
})),
|
|
216
|
+
};
|
|
217
|
+
stream._triggerEvent('message', requestData);
|
|
218
|
+
// Wait for async handling
|
|
219
|
+
await new Promise(resolve => setTimeout(resolve, 10));
|
|
220
|
+
expect(stream.send).toHaveBeenCalled();
|
|
221
|
+
});
|
|
222
|
+
it('should clean up listeners on stream close', async () => {
|
|
223
|
+
const stream = createMockStream();
|
|
224
|
+
const connection = createMockConnection();
|
|
225
|
+
const toolExecutor = vi.fn();
|
|
226
|
+
await streamHandler.handleIncomingStream(stream, connection, toolExecutor);
|
|
227
|
+
stream._triggerEvent('close', {});
|
|
228
|
+
expect(stream.removeEventListener).toHaveBeenCalledWith('message', expect.any(Function));
|
|
229
|
+
expect(stream.removeEventListener).toHaveBeenCalledWith('close', expect.any(Function));
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
describe('Client-side - handleOutgoingStream', () => {
|
|
233
|
+
it('should resolve with response when final chunk received', async () => {
|
|
234
|
+
const stream = createMockStream();
|
|
235
|
+
const emitter = new EventEmitter();
|
|
236
|
+
const responsePromise = streamHandler.handleOutgoingStream(stream, emitter);
|
|
237
|
+
// Simulate receiving a final response
|
|
238
|
+
const responseData = {
|
|
239
|
+
data: new TextEncoder().encode(JSON.stringify({
|
|
240
|
+
jsonrpc: '2.0',
|
|
241
|
+
id: '1',
|
|
242
|
+
result: { _last: true, success: true },
|
|
243
|
+
})),
|
|
244
|
+
};
|
|
245
|
+
stream._triggerEvent('message', responseData);
|
|
246
|
+
const response = await responsePromise;
|
|
247
|
+
expect(response).toBeDefined();
|
|
248
|
+
expect(response.result._last).toBe(true);
|
|
249
|
+
});
|
|
250
|
+
it('should emit chunks for streaming responses', async () => {
|
|
251
|
+
const stream = createMockStream();
|
|
252
|
+
const emitter = new EventEmitter();
|
|
253
|
+
const chunkListener = vi.fn();
|
|
254
|
+
emitter.on('chunk', chunkListener);
|
|
255
|
+
const responsePromise = streamHandler.handleOutgoingStream(stream, emitter);
|
|
256
|
+
// Send a chunk
|
|
257
|
+
const chunkData = {
|
|
258
|
+
data: new TextEncoder().encode(JSON.stringify({
|
|
259
|
+
jsonrpc: '2.0',
|
|
260
|
+
id: '1',
|
|
261
|
+
result: { _isStreaming: true, _last: false, data: 'chunk 1' },
|
|
262
|
+
})),
|
|
263
|
+
};
|
|
264
|
+
stream._triggerEvent('message', chunkData);
|
|
265
|
+
// Wait for async handling
|
|
266
|
+
await new Promise(resolve => setTimeout(resolve, 10));
|
|
267
|
+
expect(chunkListener).toHaveBeenCalled();
|
|
268
|
+
// Send final chunk
|
|
269
|
+
const finalData = {
|
|
270
|
+
data: new TextEncoder().encode(JSON.stringify({
|
|
271
|
+
jsonrpc: '2.0',
|
|
272
|
+
id: '1',
|
|
273
|
+
result: { _isStreaming: true, _last: true, data: 'final chunk' },
|
|
274
|
+
})),
|
|
275
|
+
};
|
|
276
|
+
stream._triggerEvent('message', finalData);
|
|
277
|
+
await responsePromise;
|
|
278
|
+
});
|
|
279
|
+
it('should reject when stream closes before response', async () => {
|
|
280
|
+
const stream = createMockStream();
|
|
281
|
+
const emitter = new EventEmitter();
|
|
282
|
+
const responsePromise = streamHandler.handleOutgoingStream(stream, emitter);
|
|
283
|
+
stream._triggerEvent('close', {});
|
|
284
|
+
await expect(responsePromise).rejects.toThrow();
|
|
285
|
+
});
|
|
286
|
+
it('should handle abort signal', async () => {
|
|
287
|
+
const stream = createMockStream();
|
|
288
|
+
const emitter = new EventEmitter();
|
|
289
|
+
const abortController = new AbortController();
|
|
290
|
+
const responsePromise = streamHandler.handleOutgoingStream(stream, emitter, {
|
|
291
|
+
signal: abortController.signal,
|
|
292
|
+
});
|
|
293
|
+
abortController.abort();
|
|
294
|
+
// Wait for abort to be processed
|
|
295
|
+
await new Promise(resolve => setTimeout(resolve, 10));
|
|
296
|
+
await expect(responsePromise).rejects.toThrow();
|
|
297
|
+
expect(stream.abort).toHaveBeenCalled();
|
|
298
|
+
});
|
|
299
|
+
});
|
|
300
|
+
describe('Message Decoding', () => {
|
|
301
|
+
it('should decode Uint8Array message', async () => {
|
|
302
|
+
const testData = { test: 'data' };
|
|
303
|
+
const encoded = new TextEncoder().encode(JSON.stringify(testData));
|
|
304
|
+
const event = { data: encoded };
|
|
305
|
+
const decoded = await streamHandler.decodeMessage(event);
|
|
306
|
+
expect(decoded).toEqual(testData);
|
|
307
|
+
});
|
|
308
|
+
});
|
|
309
|
+
});
|
|
@@ -8,6 +8,7 @@ declare const oNodeTool_base: typeof oServerNode;
|
|
|
8
8
|
* @returns A new class that extends the base class and implements the oTool interface
|
|
9
9
|
*/
|
|
10
10
|
export declare class oNodeTool extends oNodeTool_base {
|
|
11
|
+
private streamHandler;
|
|
11
12
|
handleProtocol(address: oAddress): Promise<void>;
|
|
12
13
|
initialize(): Promise<void>;
|
|
13
14
|
handleStream(stream: Stream, connection: Connection): Promise<void>;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"o-node.tool.d.ts","sourceRoot":"","sources":["../../src/o-node.tool.ts"],"names":[],"mappings":"AAAA,OAAO,
|
|
1
|
+
{"version":3,"file":"o-node.tool.d.ts","sourceRoot":"","sources":["../../src/o-node.tool.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,QAAQ,EACR,QAAQ,EAET,MAAM,eAAe,CAAC;AAEvB,OAAO,EAAE,WAAW,EAAE,MAAM,wBAAwB,CAAC;AACrD,OAAO,EAAE,UAAU,EAAE,MAAM,EAAE,MAAM,iBAAiB,CAAC;;AAKrD;;;;GAIG;AACH,qBAAa,SAAU,SAAQ,cAAkB;IAC/C,OAAO,CAAC,aAAa,CAAiB;IAEhC,cAAc,CAAC,OAAO,EAAE,QAAQ;IAUhC,UAAU,IAAI,OAAO,CAAC,IAAI,CAAC;IAY3B,YAAY,CAAC,MAAM,EAAE,MAAM,EAAE,UAAU,EAAE,UAAU,GAAG,OAAO,CAAC,IAAI,CAAC;IA0BnE,cAAc,IAAI,OAAO,CAAC,GAAG,CAAC;IAQ9B,oBAAoB,CAAC,OAAO,EAAE,QAAQ,GAAG,OAAO,CAAC,GAAG,CAAC;CA2B5D"}
|
package/dist/src/o-node.tool.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { ChildJoinedEvent, } from '@olane/o-core';
|
|
2
2
|
import { oTool } from '@olane/o-tool';
|
|
3
3
|
import { oServerNode } from './nodes/server.node.js';
|
|
4
4
|
import { oNodeTransport } from './router/o-node.transport.js';
|
|
5
5
|
import { oNodeAddress } from './router/o-node.address.js';
|
|
6
|
+
import { StreamHandler } from './connection/stream-handler.js';
|
|
6
7
|
/**
|
|
7
8
|
* oTool is a mixin that extends the base class and implements the oTool interface
|
|
8
9
|
* @param Base - The base class to extend
|
|
@@ -20,6 +21,7 @@ export class oNodeTool extends oTool(oServerNode) {
|
|
|
20
21
|
}
|
|
21
22
|
async initialize() {
|
|
22
23
|
await super.initialize();
|
|
24
|
+
this.streamHandler = new StreamHandler(this.logger);
|
|
23
25
|
await this.handleProtocol(this.address);
|
|
24
26
|
if (this.staticAddress &&
|
|
25
27
|
this.staticAddress?.toString() !== this.address.toString()) {
|
|
@@ -28,37 +30,18 @@ export class oNodeTool extends oTool(oServerNode) {
|
|
|
28
30
|
}
|
|
29
31
|
async handleStream(stream, connection) {
|
|
30
32
|
this.logger.debug('Handling connection: ', connection.id);
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
|
|
34
|
-
const messageHandler = async (event) => {
|
|
35
|
-
if (!event.data) {
|
|
36
|
-
this.logger.warn('Malformed event data');
|
|
37
|
-
return;
|
|
38
|
-
}
|
|
39
|
-
const requestConfig = await CoreUtils.processStream(event);
|
|
40
|
-
const request = new oRequest(requestConfig);
|
|
41
|
-
// Use ResponseBuilder with automatic error handling and metrics tracking
|
|
42
|
-
const responseBuilder = ResponseBuilder.create().withMetrics(this.metrics);
|
|
43
|
-
let response;
|
|
33
|
+
// Use StreamHandler for consistent stream handling
|
|
34
|
+
// This follows libp2p v3 best practices for immediate message listener attachment
|
|
35
|
+
await this.streamHandler.handleIncomingStream(stream, connection, async (request, stream) => {
|
|
44
36
|
try {
|
|
45
37
|
const result = await this.execute(request, stream);
|
|
46
|
-
|
|
38
|
+
// Return the raw result - StreamHandler will build and send the response
|
|
39
|
+
return result;
|
|
47
40
|
}
|
|
48
41
|
catch (error) {
|
|
49
42
|
this.logger.error('Error executing tool: ', request.toString(), error, typeof error);
|
|
50
|
-
|
|
43
|
+
throw error; // StreamHandler will handle error response building
|
|
51
44
|
}
|
|
52
|
-
// Send the response
|
|
53
|
-
await CoreUtils.sendResponse(response, stream);
|
|
54
|
-
};
|
|
55
|
-
// Attach listener synchronously before any async operations
|
|
56
|
-
stream.addEventListener('message', messageHandler);
|
|
57
|
-
stream.addEventListener('close', () => {
|
|
58
|
-
this.logger.debug('Stream closed by remote peer');
|
|
59
|
-
stream.close().catch((error) => {
|
|
60
|
-
this.logger.error('Error closing stream: ', error);
|
|
61
|
-
});
|
|
62
45
|
});
|
|
63
46
|
}
|
|
64
47
|
async _tool_identify() {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@olane/o-node",
|
|
3
|
-
"version": "0.7.12-alpha.
|
|
3
|
+
"version": "0.7.12-alpha.53",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "dist/src/index.js",
|
|
6
6
|
"types": "dist/src/index.d.ts",
|
|
@@ -54,12 +54,12 @@
|
|
|
54
54
|
"typescript": "5.4.5"
|
|
55
55
|
},
|
|
56
56
|
"dependencies": {
|
|
57
|
-
"@olane/o-config": "0.7.12-alpha.
|
|
58
|
-
"@olane/o-core": "0.7.12-alpha.
|
|
59
|
-
"@olane/o-protocol": "0.7.12-alpha.
|
|
60
|
-
"@olane/o-tool": "0.7.12-alpha.
|
|
57
|
+
"@olane/o-config": "0.7.12-alpha.53",
|
|
58
|
+
"@olane/o-core": "0.7.12-alpha.53",
|
|
59
|
+
"@olane/o-protocol": "0.7.12-alpha.53",
|
|
60
|
+
"@olane/o-tool": "0.7.12-alpha.53",
|
|
61
61
|
"debug": "^4.4.1",
|
|
62
62
|
"dotenv": "^16.5.0"
|
|
63
63
|
},
|
|
64
|
-
"gitHead": "
|
|
64
|
+
"gitHead": "f6cd4162b80dd265aba3a8bf322a009fa0ff81e4"
|
|
65
65
|
}
|