@hile/message-ws 4.0.3 → 4.0.5

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/AI.md CHANGED
@@ -103,6 +103,42 @@ for await (const chunk of stream) {
103
103
  }
104
104
  ```
105
105
 
106
+ Streaming request body with a normal response:
107
+
108
+ ```ts
109
+ // src/messages/upload.msg.ts
110
+ import { defineMicroMessage } from '@hile/micro'
111
+
112
+ export default defineMicroMessage(async ({ data, input, invocation }) => {
113
+ if (!input) throw new Error('upload body is required')
114
+
115
+ let bytes = 0
116
+ for await (const chunk of input) bytes += Buffer.byteLength(chunk)
117
+
118
+ return {
119
+ filename: data.filename,
120
+ bytes,
121
+ requestId: invocation.context.values.requestId,
122
+ }
123
+ })
124
+ ```
125
+
126
+ ```ts
127
+ import { createReadStream } from 'node:fs'
128
+
129
+ const result = await app.call(
130
+ 'example.service',
131
+ '/upload',
132
+ { filename: 'archive.tar' },
133
+ { context, input: createReadStream('/tmp/archive.tar') },
134
+ )
135
+ ```
136
+
137
+ If no structured metadata is needed, pass an `AsyncIterable`, `Uint8Array`, or
138
+ `ArrayBuffer` directly as `data`; Hile sends it as the request input stream and
139
+ the handler receives `data === undefined`. `app.stream()` accepts the same
140
+ request input forms when both request and response need to stream.
141
+
106
142
  Custom WebSocket modem:
107
143
 
108
144
  ```ts
@@ -126,6 +162,68 @@ class RpcWs extends MessageWs {
126
162
 
127
163
  Notice that `request()` returns a `Promise<T>`. Await it directly.
128
164
 
165
+ ## Stream Wire Model
166
+
167
+ One request ID owns the structured request and both optional stream directions:
168
+
169
+ ```ts
170
+ type RequestFrame = {
171
+ id: number
172
+ mode: MESSAGE_MODEM_TYPE.REQUEST
173
+ twoway: boolean
174
+ data?: unknown
175
+ streams?: {
176
+ input?: true
177
+ output?: { window?: number }
178
+ }
179
+ }
180
+
181
+ type StreamDataFrame = {
182
+ id: number
183
+ mode: MESSAGE_MODEM_TYPE.STREAM_DATA
184
+ twoway: false
185
+ data: {
186
+ direction: 'input' | 'output'
187
+ seq: number
188
+ payload?: unknown
189
+ final: boolean
190
+ status?: string | number
191
+ message?: string
192
+ }
193
+ }
194
+
195
+ type StreamCreditFrame = {
196
+ id: number
197
+ mode: MESSAGE_MODEM_TYPE.STREAM_CREDIT
198
+ twoway: false
199
+ data: {
200
+ direction: 'input' | 'output'
201
+ seq: number
202
+ window?: number
203
+ }
204
+ }
205
+
206
+ type StreamCancelFrame = {
207
+ id: number
208
+ mode: MESSAGE_MODEM_TYPE.STREAM_CANCEL
209
+ twoway: false
210
+ data: {
211
+ direction: 'input' | 'output'
212
+ status?: string | number
213
+ message?: string
214
+ }
215
+ }
216
+ ```
217
+
218
+ `data` is the structured message payload; stream chunks never get embedded in
219
+ it. The request frame declares the active directions, and every later stream
220
+ frame reuses the request ID. Input and output sequence numbers, credits, and
221
+ cancellation are independent.
222
+
223
+ A non-final stream frame must carry a payload other than `null` or `undefined`.
224
+ Node `Readable` reserves those values for its own end/no-op semantics, so Hile
225
+ rejects them instead of silently losing a credit or ending a stream early.
226
+
129
227
  ## Use When
130
228
 
131
229
  Use the message packages for request/response messaging over WebSocket, process IPC, worker threads, file-system message handlers, service discovery, streaming RPC, and registry-backed pub/sub.
@@ -133,6 +231,7 @@ Use the message packages for request/response messaging over WebSocket, process
133
231
  ## Do Not Use When
134
232
 
135
233
  - Do not use `stream()` for normal single-result calls.
234
+ - Do not enable retries for a streamed request input. Input streams are consumed once and cannot be replayed safely.
136
235
  - Do not rely on message IDs for business idempotency. They are transport IDs.
137
236
  - Use `defineMicroMessage()` for Micro business handlers; reserve generic `defineMessage()` for transport-neutral loaders.
138
237
  - Do not pass zero, fractional, non-finite, or oversized message timeouts. Explicit timeout values must be safe integers from `1` through `2_147_483_647` milliseconds.
@@ -167,14 +266,24 @@ import { MessageWorkerThread } from '@hile/message-worker-thread'
167
266
  - `MessageLoader` maps `*.msg.*` files to routes using `@hile/loader`.
168
267
  - `MessageLoader.dispatch(path, data, extras?)` invokes the matched handler.
169
268
  - `MessageModem._send()` returns a `Promise`.
269
+ - `_send()` and `_stream()` accept `options.input` as an `AsyncIterable`, `Uint8Array`, or `ArrayBuffer`. Passing one of those values directly as `data` automatically selects request streaming and leaves the handler's structured `data` undefined.
170
270
  - `MessageModem._send()` and `_push()` use a `30_000` ms timeout when none is provided. An explicit timeout must be a safe integer from `1` through `2_147_483_647`; invalid values throw `TypeError` before a message is sent.
171
271
  - `MessageModem._stream()` returns a Node `Readable` in object mode.
172
- - Stream `timeout` and `idleTimeout` values use the same `1` through `2_147_483_647` ms range. The stream `window` must be a safe integer from `1` through `64` and defaults to `1`.
272
+ - Stream `timeout` and `idleTimeout` values use the same `1` through `2_147_483_647` ms range. Idle timeout is refreshed by valid input or output stream activity. The response stream `window` must be a safe integer from `1` through `64` and defaults to `1`; request input uses receiver-issued credit with a window of `1`.
173
273
  - Each modem schedules request, total-stream, and idle-stream deadlines through one internal deadline scheduler. This reduces active Node.js timers without changing timeout, cancellation, ordering, or error semantics.
174
- - `@hile/message-ws` keeps public `decodeMessageFrame()` payloads isolated from caller-owned input by default. Its owned WebSocket `RawData` path uses a zero-copy binary Flight payload view internally.
274
+ - Request and response chunks share `STREAM_DATA` frames and carry an explicit `direction: 'input' | 'output'`; each direction has independent sequence and credit state. `STREAM_CANCEL` stops one direction. A cancel carrying `status` fails the owning request immediately; `ABORT` cancels the whole invocation.
275
+ - This frame model replaces the former response-only `stream`, `streamVersion`, and `streamWindow` fields. All peers on one transport connection must use compatible `@hile/message-modem` and transport package versions; do not mix old and new peers during a rolling deployment.
276
+ - `@hile/message-ws` sends `Uint8Array` and `ArrayBuffer` request and response chunks as native binary frames rather than JSON/base64. Public `decodeMessageFrame()` payloads remain isolated from caller-owned input by default; the owned WebSocket `RawData` path uses a zero-copy binary view internally.
277
+ - `@hile/message-ipc` transparently Base64-wraps only binary `STREAM_DATA` payloads so request and response streams survive Node child-process IPC's default JSON serialization. Other IPC frames keep their ordinary object representation.
175
278
  - A stream request requires `exec()` to return an async iterable.
176
- - `Application.call(namespace, url, data, options)` requires `options.context` and returns a promise.
177
- - `Application.stream(namespace, url, data, options)` requires `options.context` and returns a readable stream.
279
+ - `defineMicroMessage()` handlers receive request streams as `input: Readable | undefined`, separately from structured `data` and `invocation`.
280
+ - `Application.call(namespace, url, data, options)` requires `options.context` and returns a promise. It accepts `options.input` for a streamed request with a normal response.
281
+ - `Application.stream(namespace, url, data, options)` requires `options.context` and returns a readable response stream; it can carry a request input stream at the same time.
282
+ - `Application.call()` and `Application.stream()` may target the application's own namespace. Self calls use the same Registry-discovered WebSocket and modem protocol as remote calls, so Context validation, request and response streams, cancellation, timeout, retry, circuit-breaker, and backpressure behavior stay uniform; business handlers do not need a local-call branch.
283
+ - A peer address (`host:port`) is the routing and reuse identity, not an individual WebSocket identity. If both peers dial each other concurrently, including an application dialing itself, the server preserves both physical connections while they are active instead of replacing a connection that may carry an in-flight request. Later calls still reuse the cached peer connection.
284
+ - This connection bookkeeping does not add or change wire frames; request, response, stream, credit, cancel, and abort protocol fields remain unchanged.
285
+ - `Application.call()` and `Application.stream()` default retries to `0` when request input is streamed. Explicit nonzero retries fail before service discovery because streamed input is non-replayable.
286
+ - A caller-owned request input source failure is surfaced as `MessageInputError`, aborts the peer invocation, and is not counted against that peer's circuit-breaker health.
178
287
  - `Application.publish(topic, payload)` returns an object with `update()` and `unpublish()`.
179
288
  - `Application.subscribe(topic, callback)` returns an unsubscribe function.
180
289
  - `Registry` stores service addresses and retained config/topic state under `~/.registry`.
@@ -183,6 +292,8 @@ import { MessageWorkerThread } from '@hile/message-worker-thread'
183
292
 
184
293
  - Appending a secondary response getter to `client.request('/x', data)`
185
294
  - Returning a plain object from a handler called through `stream()`.
295
+ - Retrying a consumed request stream or hiding it inside a replay-unsafe factory.
296
+ - Branching business code on whether a Micro target namespace is local or remote.
186
297
  - Using pub/sub as a durable queue.
187
298
  - Forgetting to register `shutdown(await app.listen(...))`.
188
299
 
@@ -190,7 +301,10 @@ import { MessageWorkerThread } from '@hile/message-worker-thread'
190
301
 
191
302
  - Micro message files default-export `defineMicroMessage(...)` and receive `invocation.context`.
192
303
  - RPC callers use `await app.call(..., { context })`.
304
+ - Same-namespace `call()` and `stream()` use normal Registry discovery, while `streamPeer()` keeps its exact-address selection; all three use the normal WebSocket/modem transport path and callers do not branch on locality.
193
305
  - Streaming handlers are async generators.
306
+ - Request-stream handlers consume `input` and callers either pass `options.input` alongside metadata or pass a stream directly as `data`.
307
+ - Streamed request calls do not configure nonzero retries.
194
308
  - Custom modem timeout values use the documented safe-integer range.
195
309
  - Registry is started before application nodes need discovery.
196
310
  - Micro apps use stable namespaces and advertise reachable hosts.
@@ -277,10 +391,13 @@ Use this recipe when services communicate over Hile registry-backed RPC.
277
391
  3. Default-export `defineMicroMessage()` handlers and load them through `app.load()`.
278
392
  4. Create context at ingress and call providers with `await app.call(namespace, url, data, { context })`.
279
393
  5. Use `app.stream()` only for async-generator handlers.
394
+ 6. For streamed request bodies, consume `input` in the `defineMicroMessage()` handler and pass the source through `options.input`; pass the stream as `data` only when no structured metadata is needed.
280
395
 
281
396
  ## Failure And Cleanup Behavior
282
397
 
283
398
  - `Application.call()` may retry; side-effecting handlers need idempotency.
399
+ - A streamed request body is non-replayable. Its retry default is `0`, and an explicit nonzero retry count is rejected before discovery.
400
+ - Request input and response output have independent credit-based backpressure and may be active together.
284
401
  - Registry disconnect triggers reconnect; apps re-declare topics and subscriptions.
285
402
  - Circuit breaker excludes failing nodes for cooldown.
286
403
 
@@ -290,6 +407,7 @@ Use this recipe when services communicate over Hile registry-backed RPC.
290
407
  - Provider namespace matches consumer call.
291
408
  - Handlers default-export `defineMicroMessage()` and consume explicit invocation context when needed.
292
409
  - Consumer code awaits `app.call(..., { context })` directly.
410
+ - Streamed request handlers consume `input: Readable`, preserve structured metadata in `data`, and do not enable retries.
293
411
 
294
412
 
295
413
 
@@ -305,6 +423,7 @@ Use this recipe when services communicate over Hile registry-backed RPC.
305
423
  - Do not assume `@hile/http` Zod validation mutates or coerces `ctx.query`, `ctx.params`, or `ctx.request.body`.
306
424
  - Do not put reusable business logic only in controllers, pages, queue workers, or message handlers.
307
425
  - Do not use old message examples that append a secondary response getter; current request APIs return promises directly.
426
+ - Do not invent service-specific HTTP-in-Micro envelopes or Base64 file bodies; use `@hile/http-over-micro` and its request/response streams.
308
427
  - Do not claim exactly-once delivery or execution from Redis locks, queues, idempotency, or rate limits.
309
428
  - Do not use queue `jobId` as the only side-effect idempotency boundary.
310
429
  - Do not log the entire async context by default.
package/README.md CHANGED
@@ -72,12 +72,15 @@ const result = await app.call('example.service', '/ping', { hello: 'world' }, {
72
72
  ## Boundaries
73
73
 
74
74
  - Do not use `stream()` for normal single-result calls.
75
+ - Do not enable retries for a streamed request input. Input streams are consumed once and cannot be replayed safely.
75
76
  - Do not rely on message IDs for business idempotency. They are transport IDs.
76
77
  - Use `defineMicroMessage()` for Micro business handlers; reserve generic `defineMessage()` for transport-neutral loaders.
77
78
  - Do not pass zero, fractional, non-finite, or oversized message timeouts. Explicit timeout values must be safe integers from `1` through `2_147_483_647` milliseconds.
78
79
 
79
80
  - Appending a secondary response getter to `client.request('/x', data)`
80
81
  - Returning a plain object from a handler called through `stream()`.
82
+ - Retrying a consumed request stream or hiding it inside a replay-unsafe factory.
83
+ - Branching business code on whether a Micro target namespace is local or remote.
81
84
  - Using pub/sub as a durable queue.
82
85
  - Forgetting to register `shutdown(await app.listen(...))`.
83
86
 
@@ -85,7 +88,10 @@ const result = await app.call('example.service', '/ping', { hello: 'world' }, {
85
88
 
86
89
  - Micro message files default-export `defineMicroMessage(...)` and receive `invocation.context`.
87
90
  - RPC callers use `await app.call(..., { context })`.
91
+ - Same-namespace `call()` and `stream()` use normal Registry discovery, while `streamPeer()` keeps its exact-address selection; all three use the normal WebSocket/modem transport path and callers do not branch on locality.
88
92
  - Streaming handlers are async generators.
93
+ - Request-stream handlers consume `input` and callers either pass `options.input` alongside metadata or pass a stream directly as `data`.
94
+ - Streamed request calls do not configure nonzero retries.
89
95
  - Custom modem timeout values use the documented safe-integer range.
90
96
  - Registry is started before application nodes need discovery.
91
97
  - Micro apps use stable namespaces and advertise reachable hosts.
package/dist/codec.js CHANGED
@@ -19,7 +19,7 @@ function isRecord(value) {
19
19
  return value !== null && typeof value === 'object' && !Array.isArray(value);
20
20
  }
21
21
  function isBinaryPayload(value) {
22
- return Buffer.isBuffer(value) || value instanceof Uint8Array;
22
+ return Buffer.isBuffer(value) || value instanceof Uint8Array || value instanceof ArrayBuffer;
23
23
  }
24
24
  function parseJson(value) {
25
25
  try {
@@ -40,11 +40,10 @@ function toBuffer(raw) {
40
40
  return Buffer.from(raw);
41
41
  return Buffer.from(raw.buffer, raw.byteOffset, raw.byteLength);
42
42
  }
43
- function isBinaryStreamResponse(message) {
44
- return message.mode === MESSAGE_MODEM_TYPE.RESPONSE
45
- && message.stream === true
46
- && message.streamVersion === 1
43
+ function isBinaryStreamData(message) {
44
+ return message.mode === MESSAGE_MODEM_TYPE.STREAM_DATA
47
45
  && isRecord(message.data)
46
+ && (message.data.direction === 'input' || message.data.direction === 'output')
48
47
  && isBinaryPayload(message.data.payload);
49
48
  }
50
49
  function validateBinaryEnvelope(value) {
@@ -53,12 +52,10 @@ function validateBinaryEnvelope(value) {
53
52
  }
54
53
  if (!Number.isSafeInteger(value.id)
55
54
  || value.id < 0
56
- || value.mode !== MESSAGE_MODEM_TYPE.RESPONSE
55
+ || value.mode !== MESSAGE_MODEM_TYPE.STREAM_DATA
57
56
  || value.twoway !== false
58
- || value.stream !== true
59
- || value.streamVersion !== 1
60
57
  || !isRecord(value.data)) {
61
- fail('ERR_MESSAGE_FRAME_ENVELOPE', 'binary frame header is not a stream response');
58
+ fail('ERR_MESSAGE_FRAME_ENVELOPE', 'binary frame header is not stream data');
62
59
  }
63
60
  if (Object.prototype.hasOwnProperty.call(value.data, 'payload')) {
64
61
  fail('ERR_MESSAGE_FRAME_ENVELOPE', 'binary frame header must not contain an inline payload');
@@ -66,15 +63,20 @@ function validateBinaryEnvelope(value) {
66
63
  if (!Number.isSafeInteger(value.data.seq)
67
64
  || value.data.seq < 0
68
65
  || typeof value.data.final !== 'boolean'
69
- || !(typeof value.data.status === 'string'
70
- || typeof value.data.status === 'number')) {
66
+ || (value.data.direction !== 'input' && value.data.direction !== 'output')
67
+ || (value.data.direction === 'output'
68
+ && typeof value.data.status !== 'string'
69
+ && typeof value.data.status !== 'number')) {
71
70
  fail('ERR_MESSAGE_FRAME_ENVELOPE', 'binary frame chunk metadata is invalid');
72
71
  }
73
72
  }
74
73
  export function encodeMessageFrame(message) {
75
- if (!isBinaryStreamResponse(message))
74
+ if (!isBinaryStreamData(message))
76
75
  return JSON.stringify(message);
77
- const payload = Buffer.from(message.data.payload.buffer, message.data.payload.byteOffset, message.data.payload.byteLength);
76
+ const source = message.data.payload;
77
+ const payload = source instanceof ArrayBuffer
78
+ ? Buffer.from(source)
79
+ : Buffer.from(source.buffer, source.byteOffset, source.byteLength);
78
80
  const { payload: _payload, ...chunkHeader } = message.data;
79
81
  const header = Buffer.from(JSON.stringify({
80
82
  ...message,
package/dist/index.d.ts CHANGED
@@ -6,7 +6,7 @@ export * from './codec.js';
6
6
  * exec 方法由子类实现,本类不做实现。
7
7
  *
8
8
  * 构造时传入已连接的 WebSocket 实例,自动绑定 message 事件。
9
- * 普通消息通过 JSON 传输;二进制 stream response 使用 Hile 二进制帧,避免 Base64 开销。
9
+ * 普通消息通过 JSON 传输;二进制 stream input/output 使用 Hile 二进制帧,避免 Base64 开销。
10
10
  *
11
11
  * @example
12
12
  * class MyWs extends MessageWs {
package/dist/index.js CHANGED
@@ -6,7 +6,7 @@ export * from './codec.js';
6
6
  * exec 方法由子类实现,本类不做实现。
7
7
  *
8
8
  * 构造时传入已连接的 WebSocket 实例,自动绑定 message 事件。
9
- * 普通消息通过 JSON 传输;二进制 stream response 使用 Hile 二进制帧,避免 Base64 开销。
9
+ * 普通消息通过 JSON 传输;二进制 stream input/output 使用 Hile 二进制帧,避免 Base64 开销。
10
10
  *
11
11
  * @example
12
12
  * class MyWs extends MessageWs {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hile/message-ws",
3
- "version": "4.0.3",
3
+ "version": "4.0.5",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "scripts": {
@@ -24,8 +24,8 @@
24
24
  "vitest": "^4.0.18"
25
25
  },
26
26
  "dependencies": {
27
- "@hile/message-modem": "^4.0.3",
27
+ "@hile/message-modem": "^4.0.5",
28
28
  "ws": "^8.21.0"
29
29
  },
30
- "gitHead": "3ea69973f9373ddb1f7d5d37338966fd7d081d66"
30
+ "gitHead": "f845e0eb6a9d56ef72d2305624ad44abcdff5063"
31
31
  }