tywrap 0.2.0 → 0.2.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 +1 -1
- package/dist/index.d.ts +20 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +22 -2
- package/dist/index.js.map +1 -1
- package/dist/runtime/base.d.ts +17 -7
- package/dist/runtime/base.d.ts.map +1 -1
- package/dist/runtime/base.js +18 -1
- package/dist/runtime/base.js.map +1 -1
- package/dist/runtime/bounded-context.d.ts +252 -0
- package/dist/runtime/bounded-context.d.ts.map +1 -0
- package/dist/runtime/bounded-context.js +454 -0
- package/dist/runtime/bounded-context.js.map +1 -0
- package/dist/runtime/bridge-protocol.d.ts +167 -0
- package/dist/runtime/bridge-protocol.d.ts.map +1 -0
- package/dist/runtime/bridge-protocol.js +247 -0
- package/dist/runtime/bridge-protocol.js.map +1 -0
- package/dist/runtime/disposable.d.ts +40 -0
- package/dist/runtime/disposable.d.ts.map +1 -0
- package/dist/runtime/disposable.js +49 -0
- package/dist/runtime/disposable.js.map +1 -0
- package/dist/runtime/http-io.d.ts +91 -0
- package/dist/runtime/http-io.d.ts.map +1 -0
- package/dist/runtime/http-io.js +195 -0
- package/dist/runtime/http-io.js.map +1 -0
- package/dist/runtime/http.d.ts +47 -13
- package/dist/runtime/http.d.ts.map +1 -1
- package/dist/runtime/http.js +55 -74
- package/dist/runtime/http.js.map +1 -1
- package/dist/runtime/node.d.ts +97 -130
- package/dist/runtime/node.d.ts.map +1 -1
- package/dist/runtime/node.js +256 -523
- package/dist/runtime/node.js.map +1 -1
- package/dist/runtime/pooled-transport.d.ts +131 -0
- package/dist/runtime/pooled-transport.d.ts.map +1 -0
- package/dist/runtime/pooled-transport.js +175 -0
- package/dist/runtime/pooled-transport.js.map +1 -0
- package/dist/runtime/process-io.d.ts +204 -0
- package/dist/runtime/process-io.d.ts.map +1 -0
- package/dist/runtime/process-io.js +695 -0
- package/dist/runtime/process-io.js.map +1 -0
- package/dist/runtime/pyodide-io.d.ts +155 -0
- package/dist/runtime/pyodide-io.d.ts.map +1 -0
- package/dist/runtime/pyodide-io.js +397 -0
- package/dist/runtime/pyodide-io.js.map +1 -0
- package/dist/runtime/pyodide.d.ts +51 -19
- package/dist/runtime/pyodide.d.ts.map +1 -1
- package/dist/runtime/pyodide.js +57 -186
- package/dist/runtime/pyodide.js.map +1 -1
- package/dist/runtime/safe-codec.d.ts +81 -0
- package/dist/runtime/safe-codec.d.ts.map +1 -0
- package/dist/runtime/safe-codec.js +345 -0
- package/dist/runtime/safe-codec.js.map +1 -0
- package/dist/runtime/transport.d.ts +186 -0
- package/dist/runtime/transport.d.ts.map +1 -0
- package/dist/runtime/transport.js +86 -0
- package/dist/runtime/transport.js.map +1 -0
- package/dist/runtime/validators.d.ts +131 -0
- package/dist/runtime/validators.d.ts.map +1 -0
- package/dist/runtime/validators.js +219 -0
- package/dist/runtime/validators.js.map +1 -0
- package/dist/runtime/worker-pool.d.ts +196 -0
- package/dist/runtime/worker-pool.d.ts.map +1 -0
- package/dist/runtime/worker-pool.js +371 -0
- package/dist/runtime/worker-pool.js.map +1 -0
- package/dist/utils/codec.d.ts.map +1 -1
- package/dist/utils/codec.js +120 -1
- package/dist/utils/codec.js.map +1 -1
- package/package.json +2 -2
- package/runtime/python_bridge.py +30 -3
- package/runtime/safe_codec.py +344 -0
- package/src/index.ts +48 -5
- package/src/runtime/base.ts +18 -26
- package/src/runtime/bounded-context.ts +608 -0
- package/src/runtime/bridge-protocol.ts +319 -0
- package/src/runtime/disposable.ts +65 -0
- package/src/runtime/http-io.ts +244 -0
- package/src/runtime/http.ts +71 -117
- package/src/runtime/node.ts +358 -691
- package/src/runtime/pooled-transport.ts +252 -0
- package/src/runtime/process-io.ts +902 -0
- package/src/runtime/pyodide-io.ts +485 -0
- package/src/runtime/pyodide.ts +75 -215
- package/src/runtime/safe-codec.ts +443 -0
- package/src/runtime/transport.ts +273 -0
- package/src/runtime/validators.ts +241 -0
- package/src/runtime/worker-pool.ts +498 -0
- package/src/utils/codec.ts +126 -1
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BridgeProtocol - Unified abstraction for JS<->Python communication.
|
|
3
|
+
*
|
|
4
|
+
* Combines BoundedContext + SafeCodec + Transport into a single base class
|
|
5
|
+
* that handles all cross-boundary concerns:
|
|
6
|
+
* - Lifecycle management (init/dispose state machine)
|
|
7
|
+
* - Request/response encoding with validation
|
|
8
|
+
* - Transport-agnostic message passing
|
|
9
|
+
* - Bounded execution (timeout, retry, abort)
|
|
10
|
+
*
|
|
11
|
+
* Subclasses (NodeBridge, HttpBridge, PyodideBridge) only need to:
|
|
12
|
+
* 1. Create their transport in their constructor
|
|
13
|
+
* 2. Pass it to super() via BridgeProtocolOptions
|
|
14
|
+
* 3. Optionally override doInit() and doDispose() for additional setup/teardown
|
|
15
|
+
*
|
|
16
|
+
* @see https://github.com/bbopen/tywrap/issues/149
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { BoundedContext, type ExecuteOptions } from './bounded-context.js';
|
|
20
|
+
import { SafeCodec, type CodecOptions } from './safe-codec.js';
|
|
21
|
+
import { PROTOCOL_ID, type Transport, type ProtocolMessage } from './transport.js';
|
|
22
|
+
|
|
23
|
+
// =============================================================================
|
|
24
|
+
// TYPES
|
|
25
|
+
// =============================================================================
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Configuration options for BridgeProtocol.
|
|
29
|
+
*/
|
|
30
|
+
export interface BridgeProtocolOptions {
|
|
31
|
+
/** The transport to use for communication */
|
|
32
|
+
transport: Transport;
|
|
33
|
+
|
|
34
|
+
/** Codec options for validation/serialization */
|
|
35
|
+
codec?: CodecOptions;
|
|
36
|
+
|
|
37
|
+
/** Default timeout for operations in ms. Default: 30000 (30s) */
|
|
38
|
+
defaultTimeoutMs?: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// =============================================================================
|
|
42
|
+
// BRIDGE PROTOCOL BASE CLASS
|
|
43
|
+
// =============================================================================
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* BridgeProtocol combines BoundedContext + SafeCodec + Transport
|
|
47
|
+
* into a unified abstraction for all JS<->Python communication.
|
|
48
|
+
*
|
|
49
|
+
* This class provides:
|
|
50
|
+
* - Automatic transport lifecycle management
|
|
51
|
+
* - Request encoding with guardrails (special float rejection, key validation)
|
|
52
|
+
* - Response decoding with Arrow support
|
|
53
|
+
* - Full RuntimeExecution interface implementation
|
|
54
|
+
*
|
|
55
|
+
* Subclasses should:
|
|
56
|
+
* 1. Create their transport in their constructor
|
|
57
|
+
* 2. Pass it to super() via BridgeProtocolOptions
|
|
58
|
+
* 3. Optionally override doInit() and doDispose() for additional setup/teardown
|
|
59
|
+
*
|
|
60
|
+
* @example
|
|
61
|
+
* ```typescript
|
|
62
|
+
* class NodeBridge extends BridgeProtocol {
|
|
63
|
+
* constructor(options: NodeBridgeOptions) {
|
|
64
|
+
* const transport = new ProcessIO(options);
|
|
65
|
+
* super({ transport, defaultTimeoutMs: options.timeout });
|
|
66
|
+
* }
|
|
67
|
+
* }
|
|
68
|
+
* ```
|
|
69
|
+
*/
|
|
70
|
+
export class BridgeProtocol extends BoundedContext {
|
|
71
|
+
/** Codec instance for validation and serialization */
|
|
72
|
+
protected readonly codec: SafeCodec;
|
|
73
|
+
|
|
74
|
+
/** Transport instance for message passing */
|
|
75
|
+
protected readonly transport: Transport;
|
|
76
|
+
|
|
77
|
+
/** Default timeout for operations in milliseconds */
|
|
78
|
+
protected readonly defaultTimeoutMs: number;
|
|
79
|
+
|
|
80
|
+
/** Counter for generating unique request IDs */
|
|
81
|
+
private requestId = 0;
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Create a new BridgeProtocol instance.
|
|
85
|
+
*
|
|
86
|
+
* @param options - Configuration options including transport and codec settings
|
|
87
|
+
*/
|
|
88
|
+
constructor(options: BridgeProtocolOptions) {
|
|
89
|
+
super();
|
|
90
|
+
this.codec = new SafeCodec(options.codec);
|
|
91
|
+
this.transport = options.transport;
|
|
92
|
+
this.defaultTimeoutMs = options.defaultTimeoutMs ?? 30000;
|
|
93
|
+
|
|
94
|
+
// Track the transport for automatic cleanup during dispose
|
|
95
|
+
this.trackResource(this.transport);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ===========================================================================
|
|
99
|
+
// LIFECYCLE
|
|
100
|
+
// ===========================================================================
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Initialize the protocol.
|
|
104
|
+
*
|
|
105
|
+
* Initializes the underlying transport. Subclasses can override this
|
|
106
|
+
* to add additional initialization logic, but must call super.doInit().
|
|
107
|
+
*/
|
|
108
|
+
protected async doInit(): Promise<void> {
|
|
109
|
+
await this.transport.init();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Dispose the protocol.
|
|
114
|
+
*
|
|
115
|
+
* The transport is tracked as a resource and will be disposed automatically
|
|
116
|
+
* by BoundedContext. Subclasses can override this to add additional cleanup,
|
|
117
|
+
* but should not need to dispose the transport manually.
|
|
118
|
+
*/
|
|
119
|
+
protected async doDispose(): Promise<void> {
|
|
120
|
+
// Transport is tracked and will be disposed by BoundedContext
|
|
121
|
+
// Subclasses can override to add additional cleanup
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ===========================================================================
|
|
125
|
+
// MESSAGE SENDING
|
|
126
|
+
// ===========================================================================
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Send a protocol message and receive a typed response.
|
|
130
|
+
*
|
|
131
|
+
* This method:
|
|
132
|
+
* 1. Generates a unique request ID
|
|
133
|
+
* 2. Encodes the request with validation (special float rejection, etc.)
|
|
134
|
+
* 3. Sends via transport with timeout/abort support
|
|
135
|
+
* 4. Decodes and validates the response
|
|
136
|
+
*
|
|
137
|
+
* @param message - The protocol message (without id field)
|
|
138
|
+
* @param options - Execution options (timeout, retries, validation)
|
|
139
|
+
* @returns The typed response from Python
|
|
140
|
+
*
|
|
141
|
+
* @throws BridgeProtocolError if encoding/decoding fails
|
|
142
|
+
* @throws BridgeExecutionError if Python returns an error
|
|
143
|
+
* @throws BridgeTimeoutError if the operation times out
|
|
144
|
+
*/
|
|
145
|
+
protected async sendMessage<T>(
|
|
146
|
+
message: Omit<ProtocolMessage, 'id' | 'protocol'>,
|
|
147
|
+
options?: ExecuteOptions<T>
|
|
148
|
+
): Promise<T> {
|
|
149
|
+
const fullMessage: ProtocolMessage = {
|
|
150
|
+
...message,
|
|
151
|
+
id: this.generateId(),
|
|
152
|
+
protocol: PROTOCOL_ID,
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
return this.execute(async () => {
|
|
156
|
+
// 1. Encode request (validates args)
|
|
157
|
+
const encoded = this.codec.encodeRequest(fullMessage);
|
|
158
|
+
|
|
159
|
+
// 2. Send via transport
|
|
160
|
+
const responseStr = await this.transport.send(
|
|
161
|
+
encoded,
|
|
162
|
+
options?.timeoutMs ?? this.defaultTimeoutMs,
|
|
163
|
+
options?.signal
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
// 3. Decode response (validates result)
|
|
167
|
+
return this.codec.decodeResponse<T>(responseStr);
|
|
168
|
+
}, options);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Async version that uses decodeResponseAsync for Arrow support.
|
|
173
|
+
*
|
|
174
|
+
* Use this method when the response may contain encoded DataFrames,
|
|
175
|
+
* ndarrays, or other Arrow-encoded data structures.
|
|
176
|
+
*
|
|
177
|
+
* @param message - The protocol message (without id field)
|
|
178
|
+
* @param options - Execution options (timeout, retries, validation)
|
|
179
|
+
* @returns The typed response from Python with Arrow decoding applied
|
|
180
|
+
*
|
|
181
|
+
* @throws BridgeProtocolError if encoding/decoding fails
|
|
182
|
+
* @throws BridgeExecutionError if Python returns an error
|
|
183
|
+
* @throws BridgeTimeoutError if the operation times out
|
|
184
|
+
*/
|
|
185
|
+
protected async sendMessageAsync<T>(
|
|
186
|
+
message: Omit<ProtocolMessage, 'id' | 'protocol'>,
|
|
187
|
+
options?: ExecuteOptions<T>
|
|
188
|
+
): Promise<T> {
|
|
189
|
+
const fullMessage: ProtocolMessage = {
|
|
190
|
+
...message,
|
|
191
|
+
id: this.generateId(),
|
|
192
|
+
protocol: PROTOCOL_ID,
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
return this.execute(async () => {
|
|
196
|
+
// 1. Encode request (validates args)
|
|
197
|
+
const encoded = this.codec.encodeRequest(fullMessage);
|
|
198
|
+
|
|
199
|
+
// 2. Send via transport
|
|
200
|
+
const responseStr = await this.transport.send(
|
|
201
|
+
encoded,
|
|
202
|
+
options?.timeoutMs ?? this.defaultTimeoutMs,
|
|
203
|
+
options?.signal
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
// 3. Decode response with Arrow support
|
|
207
|
+
return this.codec.decodeResponseAsync<T>(responseStr);
|
|
208
|
+
}, options);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Generate a unique request ID.
|
|
213
|
+
*
|
|
214
|
+
* Returns a monotonically increasing integer that ensures uniqueness
|
|
215
|
+
* within a process lifetime.
|
|
216
|
+
*/
|
|
217
|
+
private generateId(): number {
|
|
218
|
+
return ++this.requestId;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// ===========================================================================
|
|
222
|
+
// RUNTIME EXECUTION INTERFACE
|
|
223
|
+
// ===========================================================================
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Call a Python function.
|
|
227
|
+
*
|
|
228
|
+
* @param module - Python module path (e.g., 'numpy', 'mypackage.submodule')
|
|
229
|
+
* @param functionName - Function name to call
|
|
230
|
+
* @param args - Positional arguments
|
|
231
|
+
* @param kwargs - Keyword arguments
|
|
232
|
+
* @returns The function result
|
|
233
|
+
*/
|
|
234
|
+
async call<T = unknown>(
|
|
235
|
+
module: string,
|
|
236
|
+
functionName: string,
|
|
237
|
+
args: unknown[],
|
|
238
|
+
kwargs?: Record<string, unknown>
|
|
239
|
+
): Promise<T> {
|
|
240
|
+
return this.sendMessageAsync<T>({
|
|
241
|
+
method: 'call',
|
|
242
|
+
params: {
|
|
243
|
+
module,
|
|
244
|
+
functionName,
|
|
245
|
+
args,
|
|
246
|
+
kwargs,
|
|
247
|
+
},
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Instantiate a Python class.
|
|
253
|
+
*
|
|
254
|
+
* @param module - Python module path containing the class
|
|
255
|
+
* @param className - Class name to instantiate
|
|
256
|
+
* @param args - Positional constructor arguments
|
|
257
|
+
* @param kwargs - Keyword constructor arguments
|
|
258
|
+
* @returns A handle to the created instance
|
|
259
|
+
*/
|
|
260
|
+
async instantiate<T = unknown>(
|
|
261
|
+
module: string,
|
|
262
|
+
className: string,
|
|
263
|
+
args: unknown[],
|
|
264
|
+
kwargs?: Record<string, unknown>
|
|
265
|
+
): Promise<T> {
|
|
266
|
+
return this.sendMessageAsync<T>({
|
|
267
|
+
method: 'instantiate',
|
|
268
|
+
params: {
|
|
269
|
+
module,
|
|
270
|
+
className,
|
|
271
|
+
args,
|
|
272
|
+
kwargs,
|
|
273
|
+
},
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Call a method on a Python instance.
|
|
279
|
+
*
|
|
280
|
+
* @param handle - Instance handle returned from instantiate()
|
|
281
|
+
* @param methodName - Method name to call
|
|
282
|
+
* @param args - Positional arguments
|
|
283
|
+
* @param kwargs - Keyword arguments
|
|
284
|
+
* @returns The method result
|
|
285
|
+
*/
|
|
286
|
+
async callMethod<T = unknown>(
|
|
287
|
+
handle: string,
|
|
288
|
+
methodName: string,
|
|
289
|
+
args: unknown[],
|
|
290
|
+
kwargs?: Record<string, unknown>
|
|
291
|
+
): Promise<T> {
|
|
292
|
+
return this.sendMessageAsync<T>({
|
|
293
|
+
method: 'call_method',
|
|
294
|
+
params: {
|
|
295
|
+
handle,
|
|
296
|
+
methodName,
|
|
297
|
+
args,
|
|
298
|
+
kwargs,
|
|
299
|
+
},
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Dispose a Python instance.
|
|
305
|
+
*
|
|
306
|
+
* Releases the instance handle on the Python side, allowing
|
|
307
|
+
* the object to be garbage collected.
|
|
308
|
+
*
|
|
309
|
+
* @param handle - Instance handle to dispose
|
|
310
|
+
*/
|
|
311
|
+
async disposeInstance(handle: string): Promise<void> {
|
|
312
|
+
await this.sendMessageAsync<void>({
|
|
313
|
+
method: 'dispose_instance',
|
|
314
|
+
params: {
|
|
315
|
+
handle,
|
|
316
|
+
},
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Disposable interface for resources that need cleanup.
|
|
3
|
+
*
|
|
4
|
+
* This interface enables the BoundedContext resource tracking system
|
|
5
|
+
* to automatically dispose of owned resources when the context is disposed.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Interface for resources that require explicit cleanup.
|
|
10
|
+
*/
|
|
11
|
+
export interface Disposable {
|
|
12
|
+
/**
|
|
13
|
+
* Release resources held by this object.
|
|
14
|
+
* This method should be idempotent (safe to call multiple times).
|
|
15
|
+
*/
|
|
16
|
+
dispose(): Promise<void>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Type guard to check if a value implements the Disposable interface.
|
|
21
|
+
*
|
|
22
|
+
* @param value - The value to check
|
|
23
|
+
* @returns True if the value has a dispose method
|
|
24
|
+
*/
|
|
25
|
+
export function isDisposable(value: unknown): value is Disposable {
|
|
26
|
+
return (
|
|
27
|
+
value !== null &&
|
|
28
|
+
typeof value === 'object' &&
|
|
29
|
+
typeof (value as Disposable).dispose === 'function'
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Safely dispose a value if it implements Disposable.
|
|
35
|
+
* Does nothing if the value is not disposable.
|
|
36
|
+
*
|
|
37
|
+
* @param value - The value to dispose
|
|
38
|
+
* @returns Promise that resolves when disposal is complete
|
|
39
|
+
*/
|
|
40
|
+
export async function safeDispose(value: unknown): Promise<void> {
|
|
41
|
+
if (isDisposable(value)) {
|
|
42
|
+
await value.dispose();
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Dispose multiple resources, collecting any errors.
|
|
48
|
+
* All resources will be attempted even if some fail.
|
|
49
|
+
*
|
|
50
|
+
* @param resources - Iterable of Disposable resources
|
|
51
|
+
* @returns Array of errors that occurred during disposal (empty if all succeeded)
|
|
52
|
+
*/
|
|
53
|
+
export async function disposeAll(resources: Iterable<Disposable>): Promise<Error[]> {
|
|
54
|
+
const errors: Error[] = [];
|
|
55
|
+
|
|
56
|
+
for (const resource of resources) {
|
|
57
|
+
try {
|
|
58
|
+
await resource.dispose();
|
|
59
|
+
} catch (e) {
|
|
60
|
+
errors.push(e instanceof Error ? e : new Error(String(e)));
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return errors;
|
|
65
|
+
}
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP Transport for BridgeProtocol.
|
|
3
|
+
*
|
|
4
|
+
* Provides stateless HTTP POST-based communication with a Python server.
|
|
5
|
+
* Each request is independent - no connection state is maintained.
|
|
6
|
+
*
|
|
7
|
+
* @see https://github.com/bbopen/tywrap/issues/149
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
BridgeDisposedError,
|
|
12
|
+
BridgeExecutionError,
|
|
13
|
+
BridgeProtocolError,
|
|
14
|
+
BridgeTimeoutError,
|
|
15
|
+
} from './errors.js';
|
|
16
|
+
import type { Transport } from './transport.js';
|
|
17
|
+
|
|
18
|
+
// =============================================================================
|
|
19
|
+
// OPTIONS
|
|
20
|
+
// =============================================================================
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Configuration options for HttpIO transport.
|
|
24
|
+
*/
|
|
25
|
+
export interface HttpIOOptions {
|
|
26
|
+
/** Base URL for the Python server (e.g., 'http://localhost:8000') */
|
|
27
|
+
baseURL: string;
|
|
28
|
+
|
|
29
|
+
/** Additional headers to include in each request */
|
|
30
|
+
headers?: Record<string, string>;
|
|
31
|
+
|
|
32
|
+
/** Default timeout in milliseconds. Default: 30000 (30 seconds) */
|
|
33
|
+
defaultTimeoutMs?: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// =============================================================================
|
|
37
|
+
// HTTP TRANSPORT
|
|
38
|
+
// =============================================================================
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* HTTP-based transport for BridgeProtocol.
|
|
42
|
+
*
|
|
43
|
+
* This transport sends protocol messages as HTTP POST requests to a Python
|
|
44
|
+
* server. It is stateless - each request is independent and the transport
|
|
45
|
+
* is always ready after construction.
|
|
46
|
+
*
|
|
47
|
+
* Features:
|
|
48
|
+
* - Stateless design (init/dispose are no-ops)
|
|
49
|
+
* - Timeout handling via AbortController
|
|
50
|
+
* - External signal support for cancellation
|
|
51
|
+
* - Proper error classification
|
|
52
|
+
*
|
|
53
|
+
* @example
|
|
54
|
+
* ```typescript
|
|
55
|
+
* const transport = new HttpIO({ baseURL: 'http://localhost:8000' });
|
|
56
|
+
* await transport.init(); // No-op but follows Transport contract
|
|
57
|
+
*
|
|
58
|
+
* const response = await transport.send(
|
|
59
|
+
* JSON.stringify({ id: '1', type: 'call', module: 'math', functionName: 'sqrt', args: [16] }),
|
|
60
|
+
* 5000
|
|
61
|
+
* );
|
|
62
|
+
*
|
|
63
|
+
* await transport.dispose(); // Marks as disposed
|
|
64
|
+
* ```
|
|
65
|
+
*/
|
|
66
|
+
export class HttpIO implements Transport {
|
|
67
|
+
private readonly baseURL: string;
|
|
68
|
+
private readonly headers: Record<string, string>;
|
|
69
|
+
private readonly defaultTimeoutMs: number;
|
|
70
|
+
private _isDisposed = false;
|
|
71
|
+
|
|
72
|
+
constructor(options: HttpIOOptions) {
|
|
73
|
+
// Normalize URL - remove trailing slash
|
|
74
|
+
this.baseURL = options.baseURL.replace(/\/$/, '');
|
|
75
|
+
this.headers = {
|
|
76
|
+
'Content-Type': 'application/json',
|
|
77
|
+
...options.headers,
|
|
78
|
+
};
|
|
79
|
+
this.defaultTimeoutMs = options.defaultTimeoutMs ?? 30000;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ===========================================================================
|
|
83
|
+
// LIFECYCLE
|
|
84
|
+
// ===========================================================================
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Initialize the transport.
|
|
88
|
+
*
|
|
89
|
+
* HTTP is stateless, so this is a no-op. The transport is ready
|
|
90
|
+
* immediately after construction.
|
|
91
|
+
*/
|
|
92
|
+
async init(): Promise<void> {
|
|
93
|
+
// HTTP is stateless - nothing to initialize
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Dispose the transport.
|
|
98
|
+
*
|
|
99
|
+
* Marks the transport as disposed. Subsequent send() calls will fail.
|
|
100
|
+
*/
|
|
101
|
+
async dispose(): Promise<void> {
|
|
102
|
+
this._isDisposed = true;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Whether the transport is ready to send messages.
|
|
107
|
+
*
|
|
108
|
+
* Returns true unless dispose() has been called.
|
|
109
|
+
*/
|
|
110
|
+
get isReady(): boolean {
|
|
111
|
+
return !this._isDisposed;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ===========================================================================
|
|
115
|
+
// SEND
|
|
116
|
+
// ===========================================================================
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Send a message to the Python server and wait for the response.
|
|
120
|
+
*
|
|
121
|
+
* @param message - The JSON-encoded protocol message to send
|
|
122
|
+
* @param timeoutMs - Timeout in milliseconds (0 = no timeout, negative = use default)
|
|
123
|
+
* @param signal - Optional AbortSignal for external cancellation
|
|
124
|
+
* @returns The raw response string (JSON-encoded ProtocolResponse)
|
|
125
|
+
*
|
|
126
|
+
* @throws BridgeDisposedError if the transport has been disposed
|
|
127
|
+
* @throws BridgeTimeoutError if the operation times out or is aborted
|
|
128
|
+
* @throws BridgeExecutionError if the server returns a non-2xx status
|
|
129
|
+
* @throws BridgeProtocolError if the response cannot be read
|
|
130
|
+
*/
|
|
131
|
+
async send(message: string, timeoutMs: number, signal?: AbortSignal): Promise<string> {
|
|
132
|
+
if (this._isDisposed) {
|
|
133
|
+
throw new BridgeDisposedError('Transport has been disposed');
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Determine effective timeout
|
|
137
|
+
// 0 = no timeout (per interface contract), negative = use default
|
|
138
|
+
const effectiveTimeout = timeoutMs === 0 ? 0 : timeoutMs > 0 ? timeoutMs : this.defaultTimeoutMs;
|
|
139
|
+
|
|
140
|
+
// Create abort controller for timeout
|
|
141
|
+
const controller = new AbortController();
|
|
142
|
+
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
|
143
|
+
|
|
144
|
+
// Set up timeout if enabled
|
|
145
|
+
if (effectiveTimeout > 0) {
|
|
146
|
+
timeoutId = setTimeout(() => {
|
|
147
|
+
controller.abort();
|
|
148
|
+
}, effectiveTimeout);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Handle external signal - abort our controller when external aborts
|
|
152
|
+
const externalAbortHandler = (): void => {
|
|
153
|
+
controller.abort();
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
if (signal) {
|
|
157
|
+
// Check if already aborted
|
|
158
|
+
if (signal.aborted) {
|
|
159
|
+
if (timeoutId !== undefined) {
|
|
160
|
+
clearTimeout(timeoutId);
|
|
161
|
+
}
|
|
162
|
+
throw new BridgeTimeoutError('Operation aborted');
|
|
163
|
+
}
|
|
164
|
+
signal.addEventListener('abort', externalAbortHandler, { once: true });
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
try {
|
|
168
|
+
const response = await fetch(this.baseURL, {
|
|
169
|
+
method: 'POST',
|
|
170
|
+
headers: this.headers,
|
|
171
|
+
body: message,
|
|
172
|
+
signal: controller.signal,
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
// Handle non-2xx status codes
|
|
176
|
+
if (!response.ok) {
|
|
177
|
+
const errorBody = await this.safeReadText(response);
|
|
178
|
+
throw new BridgeExecutionError(
|
|
179
|
+
`HTTP ${response.status}: ${errorBody || response.statusText}`,
|
|
180
|
+
{ code: `HTTP_${response.status}` }
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Read response body
|
|
185
|
+
const responseText = await response.text();
|
|
186
|
+
return responseText;
|
|
187
|
+
} catch (error) {
|
|
188
|
+
// Handle abort errors (timeout or external signal)
|
|
189
|
+
if (error instanceof Error && error.name === 'AbortError') {
|
|
190
|
+
// Determine if it was timeout or external abort
|
|
191
|
+
if (signal?.aborted) {
|
|
192
|
+
throw new BridgeTimeoutError('Operation aborted');
|
|
193
|
+
}
|
|
194
|
+
throw new BridgeTimeoutError(`Request timed out after ${effectiveTimeout}ms`);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Re-throw bridge errors as-is
|
|
198
|
+
if (
|
|
199
|
+
error instanceof BridgeTimeoutError ||
|
|
200
|
+
error instanceof BridgeExecutionError ||
|
|
201
|
+
error instanceof BridgeProtocolError
|
|
202
|
+
) {
|
|
203
|
+
throw error;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Wrap network errors
|
|
207
|
+
if (error instanceof TypeError) {
|
|
208
|
+
// fetch throws TypeError for network failures
|
|
209
|
+
throw new BridgeExecutionError(`Network error: ${error.message}`, { cause: error });
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Wrap unknown errors
|
|
213
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
214
|
+
throw new BridgeExecutionError(`Request failed: ${errorMessage}`, {
|
|
215
|
+
cause: error instanceof Error ? error : undefined,
|
|
216
|
+
});
|
|
217
|
+
} finally {
|
|
218
|
+
// Clean up timeout
|
|
219
|
+
if (timeoutId !== undefined) {
|
|
220
|
+
clearTimeout(timeoutId);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Clean up external signal listener
|
|
224
|
+
if (signal) {
|
|
225
|
+
signal.removeEventListener('abort', externalAbortHandler);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// ===========================================================================
|
|
231
|
+
// HELPERS
|
|
232
|
+
// ===========================================================================
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Safely read response body as text, returning empty string on failure.
|
|
236
|
+
*/
|
|
237
|
+
private async safeReadText(response: Response): Promise<string> {
|
|
238
|
+
try {
|
|
239
|
+
return await response.text();
|
|
240
|
+
} catch {
|
|
241
|
+
return '';
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|