@enkaku/client 0.14.3 → 0.14.4
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/lib/client.d.ts +2 -0
- package/lib/client.js +110 -26
- package/lib/events.d.ts +39 -0
- package/lib/events.js +1 -0
- package/lib/index.d.ts +1 -0
- package/lib/safe-write.d.ts +28 -0
- package/lib/safe-write.js +29 -0
- package/package.json +5 -5
package/lib/client.d.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { type Tracer } from '@enkaku/otel';
|
|
|
4
4
|
import type { AnyProcedureDefinition, AnyRequestProcedureDefinition, ChannelProcedureDefinition, ClientTransportOf, DataOf, EventProcedureDefinition, ProtocolDefinition, RequestProcedureDefinition, RequestType, ReturnOf, StreamProcedureDefinition } from '@enkaku/protocol';
|
|
5
5
|
import { type Runtime } from '@enkaku/runtime';
|
|
6
6
|
import { type Identity } from '@enkaku/token';
|
|
7
|
+
import type { ClientEmitter } from './events.js';
|
|
7
8
|
type FilterNever<T> = {
|
|
8
9
|
[K in keyof T as T[K] extends never ? never : K]: T[K];
|
|
9
10
|
};
|
|
@@ -80,6 +81,7 @@ export type ClientParams<Protocol extends ProtocolDefinition> = {
|
|
|
80
81
|
export declare class Client<Protocol extends ProtocolDefinition, ClientDefinitions extends ClientDefinitionsType<Protocol> = ClientDefinitionsType<Protocol>> extends Disposer {
|
|
81
82
|
#private;
|
|
82
83
|
constructor(params: ClientParams<Protocol>);
|
|
84
|
+
get events(): ClientEmitter;
|
|
83
85
|
sendEvent<Procedure extends keyof ClientDefinitions['Events'] & string, T extends ClientDefinitions['Events'][Procedure] = ClientDefinitions['Events'][Procedure]>(procedure: Procedure, ...args: T['Data'] extends never ? [config?: {
|
|
84
86
|
data?: never;
|
|
85
87
|
header?: AnyHeader;
|
package/lib/client.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { Disposer, defer } from '@enkaku/async';
|
|
2
|
+
import { EventEmitter } from '@enkaku/event';
|
|
2
3
|
import { getEnkakuLogger } from '@enkaku/log';
|
|
3
4
|
import { AttributeKeys, createTracer, injectTraceContext as otelInjectTraceContext, SpanNames, SpanStatusCode, setSpanOnContext, withActiveContext, withSpan } from '@enkaku/otel';
|
|
4
5
|
import { createRuntime } from '@enkaku/runtime';
|
|
5
6
|
import { createPipe, writeTo } from '@enkaku/stream';
|
|
6
7
|
import { createUnsignedToken, isSigningIdentity } from '@enkaku/token';
|
|
7
8
|
import { RequestError } from './error.js';
|
|
9
|
+
import { safeWrite } from './safe-write.js';
|
|
8
10
|
const defaultTracer = createTracer('client');
|
|
9
11
|
function createController(params, onDone) {
|
|
10
12
|
const deferred = defer();
|
|
@@ -73,11 +75,18 @@ export class Client extends Disposer {
|
|
|
73
75
|
#logger;
|
|
74
76
|
#tracer;
|
|
75
77
|
#transport;
|
|
78
|
+
#events = new EventEmitter();
|
|
76
79
|
constructor(params){
|
|
77
80
|
super({
|
|
78
81
|
dispose: async (reason)=>{
|
|
82
|
+
await this.#events.emit('disposing', {
|
|
83
|
+
reason
|
|
84
|
+
});
|
|
79
85
|
this.#abortControllers(reason);
|
|
80
86
|
await this.#transport.dispose(reason);
|
|
87
|
+
await this.#events.emit('disposed', {
|
|
88
|
+
reason
|
|
89
|
+
});
|
|
81
90
|
this.#logger.debug('disposed');
|
|
82
91
|
}
|
|
83
92
|
});
|
|
@@ -96,10 +105,11 @@ export class Client extends Disposer {
|
|
|
96
105
|
this.#setupTransport();
|
|
97
106
|
}
|
|
98
107
|
#abortControllers(reason) {
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
108
|
+
// Runs during dispose (where this.signal is already aborted by the base
|
|
109
|
+
// Disposer) and on transport replacement. Clearing the map after the loop
|
|
110
|
+
// makes re-entry a no-op, so no guard is needed.
|
|
111
|
+
for (const controller of Object.values(this.#controllers)){
|
|
112
|
+
controller.abort(reason);
|
|
103
113
|
}
|
|
104
114
|
this.#controllers = {};
|
|
105
115
|
}
|
|
@@ -118,18 +128,23 @@ export class Client extends Disposer {
|
|
|
118
128
|
// Abort running procedures and start using new transport
|
|
119
129
|
this.#abortControllers('TransportDisposed');
|
|
120
130
|
this.#transport = newTransport;
|
|
131
|
+
this.#events.emit('transportReplaced', {});
|
|
121
132
|
this.#setupTransport();
|
|
122
133
|
}
|
|
123
134
|
});
|
|
124
135
|
this.#read();
|
|
125
136
|
}
|
|
126
|
-
#endSpanOnResult(span, result,
|
|
137
|
+
#endSpanOnResult(span, result, meta) {
|
|
127
138
|
result.then(()=>{
|
|
128
139
|
span.setStatus({
|
|
129
140
|
code: SpanStatusCode.OK
|
|
130
141
|
});
|
|
131
142
|
span.end();
|
|
132
|
-
|
|
143
|
+
this.#events.emit('requestEnd', {
|
|
144
|
+
...meta,
|
|
145
|
+
status: 'ok'
|
|
146
|
+
});
|
|
147
|
+
delete this.#spans[meta.rid];
|
|
133
148
|
}, (error)=>{
|
|
134
149
|
if (error instanceof RequestError) {
|
|
135
150
|
span.setAttribute(AttributeKeys.ERROR_CODE, error.code);
|
|
@@ -141,7 +156,12 @@ export class Client extends Disposer {
|
|
|
141
156
|
});
|
|
142
157
|
span.recordException(error instanceof Error ? error : new Error(String(error)));
|
|
143
158
|
span.end();
|
|
144
|
-
|
|
159
|
+
const status = error === 'Close' || error?.name === 'AbortError' ? 'aborted' : 'error';
|
|
160
|
+
this.#events.emit('requestEnd', {
|
|
161
|
+
...meta,
|
|
162
|
+
status
|
|
163
|
+
});
|
|
164
|
+
delete this.#spans[meta.rid];
|
|
145
165
|
});
|
|
146
166
|
}
|
|
147
167
|
async #read() {
|
|
@@ -163,6 +183,9 @@ export class Client extends Disposer {
|
|
|
163
183
|
const error = new Error('Transport read failed', {
|
|
164
184
|
cause
|
|
165
185
|
});
|
|
186
|
+
this.#events.emit('transportError', {
|
|
187
|
+
error
|
|
188
|
+
});
|
|
166
189
|
const newTransport = this.#handleTransportError?.(error);
|
|
167
190
|
if (newTransport == null) {
|
|
168
191
|
this.#logger.warn('aborting following unhanded transport error');
|
|
@@ -173,6 +196,7 @@ export class Client extends Disposer {
|
|
|
173
196
|
// Abort running procedures and start using new transport
|
|
174
197
|
this.#abortControllers(error);
|
|
175
198
|
this.#transport = newTransport;
|
|
199
|
+
this.#events.emit('transportReplaced', {});
|
|
176
200
|
this.#setupTransport();
|
|
177
201
|
}
|
|
178
202
|
return;
|
|
@@ -228,7 +252,7 @@ export class Client extends Disposer {
|
|
|
228
252
|
}
|
|
229
253
|
}
|
|
230
254
|
}
|
|
231
|
-
async #write(payload, header) {
|
|
255
|
+
async #write(payload, header, rid) {
|
|
232
256
|
if (this.signal.aborted) {
|
|
233
257
|
throw new Error('Client aborted', {
|
|
234
258
|
cause: this.signal.reason
|
|
@@ -238,7 +262,46 @@ export class Client extends Disposer {
|
|
|
238
262
|
const enrichedHeader = otelInjectTraceContext(baseHeader);
|
|
239
263
|
const finalHeader = Object.keys(enrichedHeader).length > 0 ? enrichedHeader : undefined;
|
|
240
264
|
const message = await this.#createMessage(payload, finalHeader);
|
|
241
|
-
await
|
|
265
|
+
await safeWrite({
|
|
266
|
+
transport: this.#transport,
|
|
267
|
+
message,
|
|
268
|
+
rid,
|
|
269
|
+
events: this.#events,
|
|
270
|
+
signal: this.signal,
|
|
271
|
+
onFailure: (error)=>{
|
|
272
|
+
if (rid != null) {
|
|
273
|
+
// Surface the write failure on the per-rid controller so the
|
|
274
|
+
// awaited request/stream/channel promise rejects, instead of
|
|
275
|
+
// hanging on a server reply that will never arrive.
|
|
276
|
+
this.#controllers[rid]?.abort(error);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
// Fire-and-forget abort notification for `#handleSignal`. Never rejects:
|
|
282
|
+
// `safeWrite` already absorbs benign teardown errors and routes non-benign
|
|
283
|
+
// ones through `writeFailed` + the per-rid controller. The try/catch here
|
|
284
|
+
// only covers `#write`'s synchronous preflight (`this.signal.aborted` ->
|
|
285
|
+
// throw) and signing errors from `#createMessage`, which are the only paths
|
|
286
|
+
// that still reject. `requestError` surfaces those so callers can observe
|
|
287
|
+
// mid-abort failures without each abort site needing its own `.catch`.
|
|
288
|
+
#notifyAbort(rid, reason, header) {
|
|
289
|
+
void (async ()=>{
|
|
290
|
+
try {
|
|
291
|
+
await this.#write({
|
|
292
|
+
typ: 'abort',
|
|
293
|
+
rid,
|
|
294
|
+
rsn: reason
|
|
295
|
+
}, header, rid);
|
|
296
|
+
} catch (error) {
|
|
297
|
+
if (!this.signal.aborted) {
|
|
298
|
+
await this.#events.emit('requestError', {
|
|
299
|
+
rid,
|
|
300
|
+
error: error
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
})();
|
|
242
305
|
}
|
|
243
306
|
#handleSignal(rid, controller, providedSignal) {
|
|
244
307
|
const signal = providedSignal ? AbortSignal.any([
|
|
@@ -246,27 +309,24 @@ export class Client extends Disposer {
|
|
|
246
309
|
providedSignal
|
|
247
310
|
]) : controller.signal;
|
|
248
311
|
signal.addEventListener('abort', ()=>{
|
|
249
|
-
const reason = signal.reason
|
|
312
|
+
const reason = signal.reason?.name ?? signal.reason;
|
|
250
313
|
this.#logger.trace('abort {type} {procedure} with ID {rid} and reason: {reason}', {
|
|
251
314
|
type: controller.type,
|
|
252
315
|
procedure: controller.procedure,
|
|
253
316
|
rid,
|
|
254
317
|
reason
|
|
255
318
|
});
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
rsn: reason
|
|
260
|
-
}, controller.header);
|
|
261
|
-
if (signal.reason !== 'Close') {
|
|
262
|
-
controller.aborted(signal);
|
|
263
|
-
delete this.#controllers[rid];
|
|
264
|
-
}
|
|
319
|
+
this.#notifyAbort(rid, reason, controller.header);
|
|
320
|
+
controller.aborted(signal);
|
|
321
|
+
delete this.#controllers[rid];
|
|
265
322
|
}, {
|
|
266
323
|
once: true
|
|
267
324
|
});
|
|
268
325
|
return signal;
|
|
269
326
|
}
|
|
327
|
+
get events() {
|
|
328
|
+
return this.#events;
|
|
329
|
+
}
|
|
270
330
|
async sendEvent(procedure, ...args) {
|
|
271
331
|
const config = args[0] ?? {};
|
|
272
332
|
return withSpan(this.#tracer, SpanNames.CLIENT_CALL, {
|
|
@@ -357,8 +417,16 @@ export class Client extends Disposer {
|
|
|
357
417
|
param: prm
|
|
358
418
|
});
|
|
359
419
|
}
|
|
360
|
-
|
|
361
|
-
|
|
420
|
+
this.#events.emit('requestStart', {
|
|
421
|
+
rid,
|
|
422
|
+
procedure,
|
|
423
|
+
type: controller.type
|
|
424
|
+
});
|
|
425
|
+
const sent = withActiveContext(spanCtx, ()=>this.#write(payload, config.header, rid));
|
|
426
|
+
this.#endSpanOnResult(span, controller.result, {
|
|
427
|
+
rid,
|
|
428
|
+
procedure
|
|
429
|
+
});
|
|
362
430
|
const signal = this.#handleSignal(rid, controller, providedSignal);
|
|
363
431
|
return createRequest({
|
|
364
432
|
id: rid,
|
|
@@ -432,8 +500,16 @@ export class Client extends Disposer {
|
|
|
432
500
|
param: prm
|
|
433
501
|
});
|
|
434
502
|
}
|
|
435
|
-
|
|
436
|
-
|
|
503
|
+
this.#events.emit('requestStart', {
|
|
504
|
+
rid,
|
|
505
|
+
procedure,
|
|
506
|
+
type: controller.type
|
|
507
|
+
});
|
|
508
|
+
const sent = withActiveContext(spanCtx, ()=>this.#write(payload, config.header, rid));
|
|
509
|
+
this.#endSpanOnResult(span, controller.result, {
|
|
510
|
+
rid,
|
|
511
|
+
procedure
|
|
512
|
+
});
|
|
437
513
|
const signal = this.#handleSignal(rid, controller, providedSignal);
|
|
438
514
|
return createStream({
|
|
439
515
|
id: rid,
|
|
@@ -513,8 +589,16 @@ export class Client extends Disposer {
|
|
|
513
589
|
param: prm
|
|
514
590
|
});
|
|
515
591
|
}
|
|
516
|
-
|
|
517
|
-
|
|
592
|
+
this.#events.emit('requestStart', {
|
|
593
|
+
rid,
|
|
594
|
+
procedure,
|
|
595
|
+
type: controller.type
|
|
596
|
+
});
|
|
597
|
+
const sent = withActiveContext(spanCtx, ()=>this.#write(payload, config.header, rid));
|
|
598
|
+
this.#endSpanOnResult(span, controller.result, {
|
|
599
|
+
rid,
|
|
600
|
+
procedure
|
|
601
|
+
});
|
|
518
602
|
const signal = this.#handleSignal(rid, controller, providedSignal);
|
|
519
603
|
const send = async (val)=>{
|
|
520
604
|
const channelSpan = this.#spans[rid];
|
|
@@ -532,7 +616,7 @@ export class Client extends Disposer {
|
|
|
532
616
|
typ: 'send',
|
|
533
617
|
rid,
|
|
534
618
|
val
|
|
535
|
-
}, config.header);
|
|
619
|
+
}, config.header, rid);
|
|
536
620
|
};
|
|
537
621
|
return Object.assign(createStream({
|
|
538
622
|
id: rid,
|
package/lib/events.d.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { EventEmitter } from '@enkaku/event';
|
|
2
|
+
import type { RequestError } from './error.js';
|
|
3
|
+
export type ClientRequestStatus = 'ok' | 'error' | 'aborted';
|
|
4
|
+
export type ClientEvents = {
|
|
5
|
+
disposed: {
|
|
6
|
+
reason?: unknown;
|
|
7
|
+
};
|
|
8
|
+
disposing: {
|
|
9
|
+
reason?: unknown;
|
|
10
|
+
};
|
|
11
|
+
requestEnd: {
|
|
12
|
+
rid: string;
|
|
13
|
+
procedure: string;
|
|
14
|
+
status: ClientRequestStatus;
|
|
15
|
+
};
|
|
16
|
+
requestError: {
|
|
17
|
+
rid: string;
|
|
18
|
+
error: Error | RequestError;
|
|
19
|
+
};
|
|
20
|
+
requestStart: {
|
|
21
|
+
rid: string;
|
|
22
|
+
procedure: string;
|
|
23
|
+
type: string;
|
|
24
|
+
};
|
|
25
|
+
transportError: {
|
|
26
|
+
error: Error;
|
|
27
|
+
};
|
|
28
|
+
transportReplaced: Record<string, never>;
|
|
29
|
+
writeDropped: {
|
|
30
|
+
rid?: string;
|
|
31
|
+
reason: unknown;
|
|
32
|
+
error: Error;
|
|
33
|
+
};
|
|
34
|
+
writeFailed: {
|
|
35
|
+
error: Error;
|
|
36
|
+
rid?: string;
|
|
37
|
+
};
|
|
38
|
+
};
|
|
39
|
+
export type ClientEmitter = EventEmitter<ClientEvents>;
|
package/lib/events.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { };
|
package/lib/index.d.ts
CHANGED
|
@@ -12,3 +12,4 @@
|
|
|
12
12
|
export type { ChannelCall, ChannelDefinitionsType, ClientDefinitionsType, ClientParams, EventDefinitionsType, RequestCall, RequestDefinitionsType, StreamCall, StreamDefinitionsType, } from './client.js';
|
|
13
13
|
export { Client } from './client.js';
|
|
14
14
|
export { type ErrorObjectType, RequestError, type RequestErrorParams } from './error.js';
|
|
15
|
+
export type { ClientEmitter, ClientEvents, ClientRequestStatus } from './events.js';
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { ClientEmitter } from './events.js';
|
|
2
|
+
export type WriteTarget = {
|
|
3
|
+
write: (message: unknown) => Promise<void>;
|
|
4
|
+
};
|
|
5
|
+
export type SafeWriteParams = {
|
|
6
|
+
transport: WriteTarget;
|
|
7
|
+
message: unknown;
|
|
8
|
+
rid?: string;
|
|
9
|
+
events: ClientEmitter;
|
|
10
|
+
signal: AbortSignal;
|
|
11
|
+
/**
|
|
12
|
+
* Called with the raw transport error when a write fails for a reason that
|
|
13
|
+
* is NOT a benign teardown. Benign teardown errors are absorbed as
|
|
14
|
+
* `writeDropped` events and do NOT invoke this callback. The callback is
|
|
15
|
+
* the client's hook to propagate the failure into the per-rid controller so
|
|
16
|
+
* the request/stream/channel promise rejects rather than hanging.
|
|
17
|
+
*/
|
|
18
|
+
onFailure?: (error: Error) => void;
|
|
19
|
+
};
|
|
20
|
+
/**
|
|
21
|
+
* Send a client message through the transport. Classifies any failure as
|
|
22
|
+
* either a benign teardown (swallowed → `writeDropped` event) or a real
|
|
23
|
+
* transport failure (`writeFailed` event + `onFailure` hook for per-call
|
|
24
|
+
* surfacing). Never rejects, so fire-and-forget callers do not need a
|
|
25
|
+
* `.catch`; direct callers can still observe the failure by subscribing to
|
|
26
|
+
* the events emitter or by supplying `onFailure`.
|
|
27
|
+
*/
|
|
28
|
+
export declare function safeWrite(params: SafeWriteParams): Promise<void>;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { isBenignTeardownError } from '@enkaku/async';
|
|
2
|
+
/**
|
|
3
|
+
* Send a client message through the transport. Classifies any failure as
|
|
4
|
+
* either a benign teardown (swallowed → `writeDropped` event) or a real
|
|
5
|
+
* transport failure (`writeFailed` event + `onFailure` hook for per-call
|
|
6
|
+
* surfacing). Never rejects, so fire-and-forget callers do not need a
|
|
7
|
+
* `.catch`; direct callers can still observe the failure by subscribing to
|
|
8
|
+
* the events emitter or by supplying `onFailure`.
|
|
9
|
+
*/ export async function safeWrite(params) {
|
|
10
|
+
const { transport, message, rid, events, signal, onFailure } = params;
|
|
11
|
+
try {
|
|
12
|
+
await transport.write(message);
|
|
13
|
+
return;
|
|
14
|
+
} catch (error) {
|
|
15
|
+
if (isBenignTeardownError(error) && signal.aborted) {
|
|
16
|
+
await events.emit('writeDropped', {
|
|
17
|
+
rid,
|
|
18
|
+
reason: 'disposing',
|
|
19
|
+
error: error
|
|
20
|
+
});
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
await events.emit('writeFailed', {
|
|
24
|
+
error: error,
|
|
25
|
+
rid
|
|
26
|
+
});
|
|
27
|
+
onFailure?.(error);
|
|
28
|
+
}
|
|
29
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@enkaku/client",
|
|
3
|
-
"version": "0.14.
|
|
3
|
+
"version": "0.14.4",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"homepage": "https://enkaku.dev",
|
|
6
6
|
"description": "Enkaku RPC client",
|
|
@@ -26,23 +26,23 @@
|
|
|
26
26
|
],
|
|
27
27
|
"sideEffects": false,
|
|
28
28
|
"dependencies": {
|
|
29
|
-
"@enkaku/async": "^0.14.0",
|
|
30
29
|
"@enkaku/execution": "^0.14.0",
|
|
31
30
|
"@enkaku/runtime": "^0.14.0",
|
|
32
31
|
"@enkaku/log": "^0.14.0",
|
|
32
|
+
"@enkaku/event": "^0.14.1",
|
|
33
33
|
"@enkaku/otel": "^0.14.0",
|
|
34
34
|
"@enkaku/stream": "^0.14.1",
|
|
35
|
-
"@enkaku/token": "^0.14.1"
|
|
35
|
+
"@enkaku/token": "^0.14.1",
|
|
36
|
+
"@enkaku/async": "^0.14.1"
|
|
36
37
|
},
|
|
37
38
|
"devDependencies": {
|
|
38
39
|
"@enkaku/protocol": "^0.14.0",
|
|
39
|
-
"@enkaku/transport": "^0.14.
|
|
40
|
+
"@enkaku/transport": "^0.14.1"
|
|
40
41
|
},
|
|
41
42
|
"scripts": {
|
|
42
43
|
"build:clean": "del lib",
|
|
43
44
|
"build:js": "swc src -d ./lib --config-file ../../swc.json --strip-leading-paths",
|
|
44
45
|
"build:types": "tsc --emitDeclarationOnly --skipLibCheck",
|
|
45
|
-
"build:types:ci": "tsc --emitDeclarationOnly --skipLibCheck",
|
|
46
46
|
"build": "pnpm run build:clean && pnpm run build:js && pnpm run build:types",
|
|
47
47
|
"test:types": "tsc --noEmit --skipLibCheck -p tsconfig.test.json",
|
|
48
48
|
"test:unit": "vitest run",
|