@hile/message-modem 3.0.0 → 4.0.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/dist/index.d.ts CHANGED
@@ -3,15 +3,27 @@ export * from './exception.js';
3
3
  export declare enum MESSAGE_MODEM_TYPE {
4
4
  REQUEST = 0,
5
5
  RESPONSE = 1,
6
- ABORT = 2
6
+ ABORT = 2,
7
+ STREAM_CREDIT = 3
7
8
  }
8
9
  export interface MessageTransferFormat<T = any> {
9
10
  id: number;
10
11
  mode: MESSAGE_MODEM_TYPE;
11
12
  twoway: boolean;
12
13
  stream?: boolean;
14
+ streamVersion?: 1;
15
+ streamWindow?: number;
13
16
  data?: T;
14
17
  }
18
+ export interface MessageStreamOptions {
19
+ signal?: AbortSignal;
20
+ /** Maximum total stream lifetime in milliseconds. */
21
+ timeout?: number;
22
+ /** Maximum time between valid stream responses in milliseconds. */
23
+ idleTimeout?: number;
24
+ /** Maximum number of produced but not yet consumed chunks. */
25
+ window?: number;
26
+ }
15
27
  export interface MessageReturnFormat<T = any> {
16
28
  status: string | number;
17
29
  data: T;
@@ -28,6 +40,7 @@ export declare abstract class MessageModem {
28
40
  private readonly aborts;
29
41
  private readonly stacks;
30
42
  private readonly streams;
43
+ private readonly streamProducers;
31
44
  protected _dispose(): void;
32
45
  /**
33
46
  * 创建自增 ID
@@ -75,9 +88,7 @@ export declare abstract class MessageModem {
75
88
  timeout?: number;
76
89
  signal?: AbortSignal;
77
90
  }): void;
78
- protected _stream(data: any, options?: {
79
- signal?: AbortSignal;
80
- }): Readable;
91
+ protected _stream(data: any, options?: MessageStreamOptions): Readable;
81
92
  /**
82
93
  * 写入消息
83
94
  * @param data - 消息数据
package/dist/index.js CHANGED
@@ -6,12 +6,54 @@ export var MESSAGE_MODEM_TYPE;
6
6
  MESSAGE_MODEM_TYPE[MESSAGE_MODEM_TYPE["REQUEST"] = 0] = "REQUEST";
7
7
  MESSAGE_MODEM_TYPE[MESSAGE_MODEM_TYPE["RESPONSE"] = 1] = "RESPONSE";
8
8
  MESSAGE_MODEM_TYPE[MESSAGE_MODEM_TYPE["ABORT"] = 2] = "ABORT";
9
+ MESSAGE_MODEM_TYPE[MESSAGE_MODEM_TYPE["STREAM_CREDIT"] = 3] = "STREAM_CREDIT";
9
10
  })(MESSAGE_MODEM_TYPE || (MESSAGE_MODEM_TYPE = {}));
11
+ const MAX_STREAM_WINDOW = 64;
12
+ const MAX_TIMER_DELAY = 2_147_483_647;
13
+ function streamLimit(value, name) {
14
+ if (value === undefined)
15
+ return undefined;
16
+ if (!Number.isSafeInteger(value) || value < 1) {
17
+ throw new TypeError(`${name} must be a positive safe integer`);
18
+ }
19
+ return value;
20
+ }
21
+ function streamWindow(value) {
22
+ const normalized = streamLimit(value, 'Stream window') ?? 1;
23
+ if (normalized > MAX_STREAM_WINDOW) {
24
+ throw new TypeError(`Stream window must not exceed ${MAX_STREAM_WINDOW}`);
25
+ }
26
+ return normalized;
27
+ }
28
+ function streamTimeout(value, name) {
29
+ const normalized = streamLimit(value, name);
30
+ if (normalized !== undefined && normalized > MAX_TIMER_DELAY) {
31
+ throw new TypeError(`${name} must not exceed ${MAX_TIMER_DELAY}`);
32
+ }
33
+ return normalized;
34
+ }
35
+ class CreditReadable extends Readable {
36
+ onConsumed;
37
+ constructor(onConsumed) {
38
+ super({ objectMode: true });
39
+ this.onConsumed = onConsumed;
40
+ }
41
+ _read() {
42
+ // Credits are tied to actual read() results, not Node's eager buffer filling.
43
+ }
44
+ read(size) {
45
+ const chunk = super.read(size);
46
+ if (chunk !== null)
47
+ this.onConsumed();
48
+ return chunk;
49
+ }
50
+ }
10
51
  export class MessageModem {
11
52
  id = 0;
12
53
  aborts = new Map();
13
54
  stacks = new Map();
14
55
  streams = new Map();
56
+ streamProducers = new Map();
15
57
  _dispose() {
16
58
  for (const { reject } of this.stacks.values()) {
17
59
  reject(new AbortException());
@@ -19,12 +61,16 @@ export class MessageModem {
19
61
  for (const controller of this.aborts.values()) {
20
62
  controller.abort();
21
63
  }
22
- for (const stream of this.streams.values()) {
64
+ for (const { stream } of this.streams.values()) {
23
65
  stream.destroy(new AbortException());
24
66
  }
67
+ for (const producer of this.streamProducers.values()) {
68
+ producer.wake?.();
69
+ }
25
70
  this.aborts.clear();
26
71
  this.stacks.clear();
27
72
  this.streams.clear();
73
+ this.streamProducers.clear();
28
74
  }
29
75
  /**
30
76
  * 创建自增 ID
@@ -83,25 +129,105 @@ export class MessageModem {
83
129
  signal: options?.signal,
84
130
  });
85
131
  }
86
- _stream(data, options) {
132
+ _stream(data, options = {}) {
133
+ const window = streamWindow(options.window);
134
+ const timeout = streamTimeout(options.timeout, 'Stream timeout');
135
+ const idleTimeout = streamTimeout(options.idleTimeout, 'Stream idle timeout');
87
136
  const state = this.createPostData(MESSAGE_MODEM_TYPE.REQUEST, data, true, true);
88
- const stream = new Readable({ objectMode: true, read() { } });
89
- this.streams.set(state.id, stream);
90
- this.post(state);
137
+ state.streamVersion = 1;
138
+ if (window > 1)
139
+ state.streamWindow = window;
140
+ let consumer;
141
+ const stream = new CreditReadable(() => {
142
+ if (consumer.creditsOwed === 0 || consumer.completed || consumer.cancelled)
143
+ return;
144
+ consumer.creditsOwed--;
145
+ try {
146
+ this.post(this.createPostData(MESSAGE_MODEM_TYPE.STREAM_CREDIT, { id: state.id, seq: consumer.nextCreditSeq++ }, false));
147
+ }
148
+ catch (error) {
149
+ consumer.cancelled = true;
150
+ stream.destroy(error);
151
+ }
152
+ });
153
+ let totalTimer;
154
+ let idleTimer;
155
+ const clearTimers = () => {
156
+ if (totalTimer)
157
+ clearTimeout(totalTimer);
158
+ if (idleTimer)
159
+ clearTimeout(idleTimer);
160
+ totalTimer = undefined;
161
+ idleTimer = undefined;
162
+ };
163
+ const expire = (message) => {
164
+ if (consumer.completed || consumer.cancelled)
165
+ return;
166
+ sendAbort();
167
+ stream.destroy(new TimeoutException(message));
168
+ };
169
+ const touch = () => {
170
+ if (!idleTimeout || consumer.completed || consumer.cancelled)
171
+ return;
172
+ if (idleTimer)
173
+ clearTimeout(idleTimer);
174
+ idleTimer = setTimeout(() => expire('Stream idle timeout'), idleTimeout);
175
+ idleTimer.unref?.();
176
+ };
177
+ consumer = {
178
+ stream,
179
+ completed: false,
180
+ cancelled: false,
181
+ creditsOwed: 0,
182
+ maxCredits: window,
183
+ nextSeq: 0,
184
+ nextCreditSeq: 0,
185
+ touch,
186
+ clearTimers,
187
+ };
188
+ const sendAbort = () => {
189
+ if (consumer.completed || consumer.cancelled)
190
+ return;
191
+ consumer.cancelled = true;
192
+ try {
193
+ this.post(this.createPostData(MESSAGE_MODEM_TYPE.ABORT, state.id));
194
+ }
195
+ catch {
196
+ // The transport may already be closed.
197
+ }
198
+ };
91
199
  const onAbort = () => {
92
- this.post(this.createPostData(MESSAGE_MODEM_TYPE.ABORT, state.id));
200
+ sendAbort();
93
201
  stream.destroy(new AbortException());
94
- this.streams.delete(state.id);
95
202
  };
96
- if (options?.signal) {
97
- options.signal.addEventListener('abort', onAbort);
203
+ if (options?.signal?.aborted) {
204
+ consumer.cancelled = true;
205
+ queueMicrotask(() => stream.destroy(new AbortException()));
206
+ return stream;
207
+ }
208
+ this.streams.set(state.id, consumer);
209
+ options?.signal?.addEventListener('abort', onAbort, { once: true });
210
+ if (timeout) {
211
+ totalTimer = setTimeout(() => expire('Stream timeout'), timeout);
212
+ totalTimer.unref?.();
98
213
  }
214
+ touch();
99
215
  stream.on('close', () => {
216
+ sendAbort();
217
+ clearTimers();
100
218
  if (this.streams.has(state.id)) {
101
219
  this.streams.delete(state.id);
102
220
  }
103
221
  options?.signal?.removeEventListener('abort', onAbort);
104
222
  });
223
+ try {
224
+ this.post(state);
225
+ }
226
+ catch (error) {
227
+ consumer.cancelled = true;
228
+ this.streams.delete(state.id);
229
+ queueMicrotask(() => stream.destroy(error));
230
+ }
105
231
  return stream;
106
232
  }
107
233
  /**
@@ -116,26 +242,32 @@ export class MessageModem {
116
242
  const signal = options?.signal;
117
243
  // 创建请求消息数据
118
244
  const state = this.createPostData(MESSAGE_MODEM_TYPE.REQUEST, data, twoway);
119
- // 发送消息
120
- this.post(state);
121
245
  // 如果消息是单向的,则直接返回
122
- if (!twoway)
246
+ if (!twoway) {
247
+ if (!signal?.aborted)
248
+ this.post(state);
123
249
  return;
250
+ }
124
251
  return new Promise((resolve, reject) => {
252
+ let timer;
253
+ let posted = false;
125
254
  const clear = () => {
126
255
  if (this.stacks.has(state.id)) {
127
256
  this.stacks.delete(state.id);
128
257
  }
129
258
  };
130
259
  const clean = () => {
131
- clearTimeout(timer);
260
+ if (timer)
261
+ clearTimeout(timer);
132
262
  signal?.removeEventListener('abort', onAbort);
133
263
  clear();
134
264
  };
135
265
  const onAbort = () => {
136
- clearTimeout(timer);
266
+ if (timer)
267
+ clearTimeout(timer);
137
268
  try {
138
- this.post(this.createPostData(MESSAGE_MODEM_TYPE.ABORT, state.id));
269
+ if (posted)
270
+ this.post(this.createPostData(MESSAGE_MODEM_TYPE.ABORT, state.id));
139
271
  }
140
272
  catch {
141
273
  /* 例如 WebSocket 已关闭时 send 可能抛错 */
@@ -156,12 +288,23 @@ export class MessageModem {
156
288
  clean();
157
289
  reject(e);
158
290
  };
159
- const timer = setTimeout(() => _reject(new TimeoutException()), timeout).unref();
160
- signal?.addEventListener('abort', onAbort);
161
291
  this.stacks.set(state.id, {
162
292
  resolve: _resolve,
163
293
  reject: _reject,
164
294
  });
295
+ timer = setTimeout(() => _reject(new TimeoutException()), timeout).unref();
296
+ signal?.addEventListener('abort', onAbort, { once: true });
297
+ if (signal?.aborted) {
298
+ onAbort();
299
+ return;
300
+ }
301
+ try {
302
+ posted = true;
303
+ this.post(state);
304
+ }
305
+ catch (error) {
306
+ _reject(error);
307
+ }
165
308
  });
166
309
  }
167
310
  /**
@@ -238,30 +381,97 @@ export class MessageModem {
238
381
  }
239
382
  }
240
383
  onStreamRequest(msg) {
384
+ if (msg.streamVersion !== 1) {
385
+ this.post({
386
+ id: msg.id,
387
+ mode: MESSAGE_MODEM_TYPE.RESPONSE,
388
+ stream: true,
389
+ streamVersion: 1,
390
+ data: { status: 400, seq: 0, payload: 'Unsupported stream protocol', final: true },
391
+ twoway: false,
392
+ });
393
+ return;
394
+ }
395
+ let window;
396
+ try {
397
+ window = streamWindow(msg.streamWindow);
398
+ }
399
+ catch (error) {
400
+ this.post({
401
+ id: msg.id,
402
+ mode: MESSAGE_MODEM_TYPE.RESPONSE,
403
+ stream: true,
404
+ streamVersion: 1,
405
+ data: {
406
+ status: 400,
407
+ seq: 0,
408
+ payload: error instanceof Error ? error.message : 'Invalid stream window',
409
+ final: true,
410
+ },
411
+ twoway: false,
412
+ });
413
+ return;
414
+ }
415
+ if (this.streamProducers.has(msg.id) || this.streamProducers.size >= 128) {
416
+ this.post({
417
+ id: msg.id,
418
+ mode: MESSAGE_MODEM_TYPE.RESPONSE,
419
+ stream: true,
420
+ streamVersion: 1,
421
+ data: { status: 429, seq: 0, payload: 'Stream capacity exceeded', final: true },
422
+ twoway: false,
423
+ });
424
+ return;
425
+ }
241
426
  const controller = new AbortController();
427
+ const producer = {
428
+ credits: window,
429
+ maxCredits: window,
430
+ nextCreditSeq: 0,
431
+ };
432
+ let sequence = 0;
242
433
  this.aborts.set(msg.id, controller);
434
+ this.streamProducers.set(msg.id, producer);
243
435
  controller.signal.addEventListener('abort', () => {
436
+ producer.wake?.();
244
437
  if (this.aborts.has(msg.id)) {
245
438
  this.aborts.delete(msg.id);
246
439
  }
247
440
  });
441
+ const takeCredit = async () => {
442
+ while (producer.credits === 0 && !controller.signal.aborted) {
443
+ await new Promise((resolve) => {
444
+ producer.wake = resolve;
445
+ });
446
+ producer.wake = undefined;
447
+ }
448
+ if (controller.signal.aborted)
449
+ throw new AbortException();
450
+ producer.credits--;
451
+ };
248
452
  this.exec(msg.data, controller.signal)
249
453
  .then(async (value) => {
250
454
  if (!isAsyncIterable(value)) {
251
455
  throw new Exception(500, 'Invalid async iterable');
252
456
  }
253
- let i = 0;
254
- for await (const chunk of value) {
457
+ const iterator = value[Symbol.asyncIterator]();
458
+ producer.iterator = iterator;
459
+ while (!controller.signal.aborted) {
460
+ await takeCredit();
461
+ const next = await iterator.next();
255
462
  if (controller.signal.aborted)
256
463
  return;
464
+ if (next.done)
465
+ break;
257
466
  this.post({
258
467
  id: msg.id,
259
468
  mode: MESSAGE_MODEM_TYPE.RESPONSE,
260
469
  stream: true,
470
+ streamVersion: msg.streamVersion,
261
471
  data: {
262
472
  status: 200,
263
- seq: i++,
264
- payload: chunk,
473
+ seq: sequence++,
474
+ payload: next.value,
265
475
  final: false,
266
476
  },
267
477
  twoway: false,
@@ -273,9 +483,10 @@ export class MessageModem {
273
483
  id: msg.id,
274
484
  mode: MESSAGE_MODEM_TYPE.RESPONSE,
275
485
  stream: true,
486
+ streamVersion: msg.streamVersion,
276
487
  data: {
277
488
  status: 200,
278
- seq: i++,
489
+ seq: sequence++,
279
490
  payload: undefined,
280
491
  final: true,
281
492
  },
@@ -289,16 +500,22 @@ export class MessageModem {
289
500
  id: msg.id,
290
501
  mode: MESSAGE_MODEM_TYPE.RESPONSE,
291
502
  stream: true,
503
+ streamVersion: msg.streamVersion,
292
504
  data: {
293
505
  status: e instanceof Exception ? e.status : 500,
294
- seq: 0,
295
- payload: e instanceof Exception ? e.message : 'Unknown error',
506
+ seq: sequence,
507
+ payload: e instanceof Error ? e.message : 'Unknown error',
296
508
  final: true,
297
509
  },
298
510
  twoway: false,
299
511
  });
300
512
  })
301
513
  .finally(() => {
514
+ if (controller.signal.aborted && producer.iterator?.return) {
515
+ void Promise.resolve(producer.iterator.return()).catch(() => { });
516
+ }
517
+ producer.wake?.();
518
+ this.streamProducers.delete(msg.id);
302
519
  if (this.aborts.has(msg.id)) {
303
520
  this.aborts.delete(msg.id);
304
521
  }
@@ -309,22 +526,40 @@ export class MessageModem {
309
526
  const res = msg.data;
310
527
  // 如果栈中存在该消息,则处理响应消息
311
528
  if (this.streams.has(id)) {
312
- const stream = this.streams.get(id);
529
+ const consumer = this.streams.get(id);
530
+ const stream = consumer.stream;
313
531
  if (res) {
532
+ consumer.touch();
533
+ if (!Number.isSafeInteger(res.seq) || res.seq !== consumer.nextSeq) {
534
+ stream.destroy(new Exception(409, `Invalid stream sequence: expected ${consumer.nextSeq}, received ${String(res.seq)}`));
535
+ return;
536
+ }
537
+ consumer.nextSeq++;
314
538
  if (res.status === 200) {
315
539
  if (res.final) {
540
+ consumer.completed = true;
541
+ consumer.clearTimers();
316
542
  stream.push(null);
317
543
  }
318
544
  else {
545
+ if (consumer.creditsOwed >= consumer.maxCredits) {
546
+ stream.destroy(new Exception(429, 'Stream window exceeded'));
547
+ return;
548
+ }
549
+ consumer.creditsOwed++;
319
550
  stream.push(res.payload);
320
551
  }
321
552
  }
322
553
  else {
554
+ consumer.completed = true;
555
+ consumer.clearTimers();
323
556
  const err = new Exception(res.status, res.payload);
324
557
  setImmediate(() => stream.destroy(err));
325
558
  }
326
559
  }
327
560
  else {
561
+ consumer.completed = true;
562
+ consumer.clearTimers();
328
563
  const err = new Exception(404, 'Empty chunk data');
329
564
  setImmediate(() => stream.destroy(err));
330
565
  }
@@ -365,6 +600,24 @@ export class MessageModem {
365
600
  }
366
601
  }
367
602
  break;
603
+ case MESSAGE_MODEM_TYPE.STREAM_CREDIT: {
604
+ const credit = msg.data;
605
+ if (!credit
606
+ || !Number.isSafeInteger(credit.id)
607
+ || credit.id < 0
608
+ || !Number.isSafeInteger(credit.seq)
609
+ || credit.seq < 0)
610
+ break;
611
+ const producer = this.streamProducers.get(credit.id);
612
+ if (producer
613
+ && producer.credits < producer.maxCredits
614
+ && credit.seq === producer.nextCreditSeq) {
615
+ producer.credits++;
616
+ producer.nextCreditSeq++;
617
+ producer.wake?.();
618
+ }
619
+ break;
620
+ }
368
621
  }
369
622
  }
370
623
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hile/message-modem",
3
- "version": "3.0.0",
3
+ "version": "4.0.1",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "scripts": {
@@ -18,8 +18,9 @@
18
18
  "access": "public"
19
19
  },
20
20
  "devDependencies": {
21
+ "@types/node": "^26.2.0",
21
22
  "fix-esm-import-path": "^1.10.3",
22
23
  "vitest": "^4.0.18"
23
24
  },
24
- "gitHead": "0985b6f8abc1f4de0a36324063585fdc3ac1375b"
25
+ "gitHead": "fe98c6f8cca2860ccbb213c575cfda44588cbd06"
25
26
  }