tywrap 0.1.2 → 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.
Files changed (93) hide show
  1. package/README.md +11 -4
  2. package/dist/index.d.ts +20 -4
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +22 -2
  5. package/dist/index.js.map +1 -1
  6. package/dist/runtime/base.d.ts +17 -7
  7. package/dist/runtime/base.d.ts.map +1 -1
  8. package/dist/runtime/base.js +18 -1
  9. package/dist/runtime/base.js.map +1 -1
  10. package/dist/runtime/bounded-context.d.ts +252 -0
  11. package/dist/runtime/bounded-context.d.ts.map +1 -0
  12. package/dist/runtime/bounded-context.js +454 -0
  13. package/dist/runtime/bounded-context.js.map +1 -0
  14. package/dist/runtime/bridge-protocol.d.ts +167 -0
  15. package/dist/runtime/bridge-protocol.d.ts.map +1 -0
  16. package/dist/runtime/bridge-protocol.js +247 -0
  17. package/dist/runtime/bridge-protocol.js.map +1 -0
  18. package/dist/runtime/disposable.d.ts +40 -0
  19. package/dist/runtime/disposable.d.ts.map +1 -0
  20. package/dist/runtime/disposable.js +49 -0
  21. package/dist/runtime/disposable.js.map +1 -0
  22. package/dist/runtime/http-io.d.ts +91 -0
  23. package/dist/runtime/http-io.d.ts.map +1 -0
  24. package/dist/runtime/http-io.js +195 -0
  25. package/dist/runtime/http-io.js.map +1 -0
  26. package/dist/runtime/http.d.ts +47 -13
  27. package/dist/runtime/http.d.ts.map +1 -1
  28. package/dist/runtime/http.js +55 -74
  29. package/dist/runtime/http.js.map +1 -1
  30. package/dist/runtime/node.d.ts +142 -28
  31. package/dist/runtime/node.d.ts.map +1 -1
  32. package/dist/runtime/node.js +321 -168
  33. package/dist/runtime/node.js.map +1 -1
  34. package/dist/runtime/optimized-node.d.ts +19 -125
  35. package/dist/runtime/optimized-node.d.ts.map +1 -1
  36. package/dist/runtime/optimized-node.js +19 -550
  37. package/dist/runtime/optimized-node.js.map +1 -1
  38. package/dist/runtime/pooled-transport.d.ts +131 -0
  39. package/dist/runtime/pooled-transport.d.ts.map +1 -0
  40. package/dist/runtime/pooled-transport.js +175 -0
  41. package/dist/runtime/pooled-transport.js.map +1 -0
  42. package/dist/runtime/process-io.d.ts +204 -0
  43. package/dist/runtime/process-io.d.ts.map +1 -0
  44. package/dist/runtime/process-io.js +695 -0
  45. package/dist/runtime/process-io.js.map +1 -0
  46. package/dist/runtime/pyodide-io.d.ts +155 -0
  47. package/dist/runtime/pyodide-io.d.ts.map +1 -0
  48. package/dist/runtime/pyodide-io.js +397 -0
  49. package/dist/runtime/pyodide-io.js.map +1 -0
  50. package/dist/runtime/pyodide.d.ts +51 -19
  51. package/dist/runtime/pyodide.d.ts.map +1 -1
  52. package/dist/runtime/pyodide.js +57 -186
  53. package/dist/runtime/pyodide.js.map +1 -1
  54. package/dist/runtime/safe-codec.d.ts +81 -0
  55. package/dist/runtime/safe-codec.d.ts.map +1 -0
  56. package/dist/runtime/safe-codec.js +345 -0
  57. package/dist/runtime/safe-codec.js.map +1 -0
  58. package/dist/runtime/transport.d.ts +186 -0
  59. package/dist/runtime/transport.d.ts.map +1 -0
  60. package/dist/runtime/transport.js +86 -0
  61. package/dist/runtime/transport.js.map +1 -0
  62. package/dist/runtime/validators.d.ts +131 -0
  63. package/dist/runtime/validators.d.ts.map +1 -0
  64. package/dist/runtime/validators.js +219 -0
  65. package/dist/runtime/validators.js.map +1 -0
  66. package/dist/runtime/worker-pool.d.ts +196 -0
  67. package/dist/runtime/worker-pool.d.ts.map +1 -0
  68. package/dist/runtime/worker-pool.js +371 -0
  69. package/dist/runtime/worker-pool.js.map +1 -0
  70. package/dist/utils/codec.d.ts.map +1 -1
  71. package/dist/utils/codec.js +120 -1
  72. package/dist/utils/codec.js.map +1 -1
  73. package/package.json +2 -2
  74. package/runtime/python_bridge.py +30 -3
  75. package/runtime/safe_codec.py +344 -0
  76. package/src/index.ts +48 -5
  77. package/src/runtime/base.ts +18 -26
  78. package/src/runtime/bounded-context.ts +608 -0
  79. package/src/runtime/bridge-protocol.ts +319 -0
  80. package/src/runtime/disposable.ts +65 -0
  81. package/src/runtime/http-io.ts +244 -0
  82. package/src/runtime/http.ts +71 -117
  83. package/src/runtime/node.ts +460 -217
  84. package/src/runtime/optimized-node.ts +19 -761
  85. package/src/runtime/pooled-transport.ts +252 -0
  86. package/src/runtime/process-io.ts +902 -0
  87. package/src/runtime/pyodide-io.ts +485 -0
  88. package/src/runtime/pyodide.ts +75 -215
  89. package/src/runtime/safe-codec.ts +443 -0
  90. package/src/runtime/transport.ts +273 -0
  91. package/src/runtime/validators.ts +241 -0
  92. package/src/runtime/worker-pool.ts +498 -0
  93. 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
+ }