@hile/message-modem 2.1.1 → 4.0.0
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 +295 -0
- package/README.md +63 -188
- package/dist/index.d.ts +4 -1
- package/dist/index.js +183 -24
- package/package.json +4 -3
package/AI.md
ADDED
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
# AI Guide For @hile/message-modem
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
<!-- Generated by scripts/build-ai-context.mjs from docs/ai. Do not edit by hand. -->
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
Purpose: Implement request/response, push, abort, and stream protocol behavior independent of transport.
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
Use this file when an AI agent installs the npm package and needs package-local examples, package selection rules, boundaries, and verification steps.
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
## Package Selection
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
Use `@hile/message-modem` for: Implement request/response, push, abort, and stream protocol behavior independent of transport.
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# Messaging And Microservices
|
|
26
|
+
|
|
27
|
+
Packages: `@hile/message-modem`, `@hile/message-ws`, `@hile/message-ipc`, `@hile/message-worker-thread`, `@hile/message-loader`, `@hile/micro`, `@hile/micro-dynamic-configs`.
|
|
28
|
+
|
|
29
|
+
## Copy-Paste Example
|
|
30
|
+
|
|
31
|
+
Message handler file:
|
|
32
|
+
|
|
33
|
+
```ts
|
|
34
|
+
// src/messages/ping.msg.ts
|
|
35
|
+
import { defineMessage } from '@hile/message-loader'
|
|
36
|
+
|
|
37
|
+
export default defineMessage(async ({ data, params }) => {
|
|
38
|
+
return {
|
|
39
|
+
type: 'pong',
|
|
40
|
+
data,
|
|
41
|
+
params,
|
|
42
|
+
timestamp: Date.now(),
|
|
43
|
+
}
|
|
44
|
+
})
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Microservice boot file:
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
// src/services/app.boot.ts
|
|
51
|
+
import { defineService } from '@hile/core'
|
|
52
|
+
import { Application } from '@hile/micro'
|
|
53
|
+
|
|
54
|
+
export default defineService('micro.app', async (shutdown) => {
|
|
55
|
+
const app = new Application({
|
|
56
|
+
namespace: process.env.MICRO_NAMESPACE ?? 'example.service',
|
|
57
|
+
registry: {
|
|
58
|
+
host: process.env.REGISTRY_HOST ?? '127.0.0.1',
|
|
59
|
+
port: Number(process.env.REGISTRY_PORT ?? 9876),
|
|
60
|
+
},
|
|
61
|
+
advertiseHost: process.env.HILE_ADVERTISE_HOST ?? '127.0.0.1',
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
await app.load(new URL('../messages', import.meta.url).pathname)
|
|
65
|
+
const stop = await app.listen(Number(process.env.MICRO_PORT ?? 0))
|
|
66
|
+
shutdown(stop)
|
|
67
|
+
return app
|
|
68
|
+
})
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Caller:
|
|
72
|
+
|
|
73
|
+
```ts
|
|
74
|
+
const result = await app.call('example.service', '/ping', { hello: 'world' })
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## More Examples
|
|
78
|
+
|
|
79
|
+
Streaming handler:
|
|
80
|
+
|
|
81
|
+
```ts
|
|
82
|
+
// src/messages/events.msg.ts
|
|
83
|
+
import { defineMessage } from '@hile/message-loader'
|
|
84
|
+
|
|
85
|
+
export default defineMessage(async function* () {
|
|
86
|
+
for (let i = 0; i < 3; i++) {
|
|
87
|
+
yield { seq: i }
|
|
88
|
+
}
|
|
89
|
+
})
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Streaming caller:
|
|
93
|
+
|
|
94
|
+
```ts
|
|
95
|
+
const stream = await app.stream('example.service', '/events', {})
|
|
96
|
+
for await (const chunk of stream) {
|
|
97
|
+
console.log(chunk)
|
|
98
|
+
}
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Custom WebSocket modem:
|
|
102
|
+
|
|
103
|
+
```ts
|
|
104
|
+
import { MessageWs } from '@hile/message-ws'
|
|
105
|
+
import type WebSocket from 'ws'
|
|
106
|
+
|
|
107
|
+
class RpcWs extends MessageWs {
|
|
108
|
+
constructor(ws: WebSocket, private readonly dispatch: (url: string, data: unknown) => Promise<unknown>) {
|
|
109
|
+
super(ws)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
protected exec(data: { url: string; data: unknown }) {
|
|
113
|
+
return this.dispatch(data.url, data.data)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
request<T>(url: string, data: unknown, timeout = 30_000) {
|
|
117
|
+
return this._send<T>({ url, data }, { timeout })
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Notice that `request()` returns a `Promise<T>`. Await it directly.
|
|
123
|
+
|
|
124
|
+
## Use When
|
|
125
|
+
|
|
126
|
+
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.
|
|
127
|
+
|
|
128
|
+
## Do Not Use When
|
|
129
|
+
|
|
130
|
+
- Do not use `stream()` for normal single-result calls.
|
|
131
|
+
- Do not rely on message IDs for business idempotency. They are transport IDs.
|
|
132
|
+
- Do not bypass `defineMessage()` for file-loaded handlers.
|
|
133
|
+
|
|
134
|
+
## Install
|
|
135
|
+
|
|
136
|
+
```bash
|
|
137
|
+
pnpm add @hile/micro @hile/message-loader @hile/message-ws
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
Use transport-specific packages only when you need to build custom IPC or worker-thread bridges.
|
|
141
|
+
|
|
142
|
+
## Imports
|
|
143
|
+
|
|
144
|
+
```ts
|
|
145
|
+
import { defineMessage, MessageLoader } from '@hile/message-loader'
|
|
146
|
+
import { Application, Registry, Server } from '@hile/micro'
|
|
147
|
+
import { MessageWs } from '@hile/message-ws'
|
|
148
|
+
import { MessageIpc } from '@hile/message-ipc'
|
|
149
|
+
import { MessageWorkerThread } from '@hile/message-worker-thread'
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
## Compose With
|
|
153
|
+
|
|
154
|
+
- `@hile/context` propagates context in micro message metadata.
|
|
155
|
+
- `@hile/redis-idempotency` protects retryable side effects in message handlers.
|
|
156
|
+
- `@hile/redis-stream-queue` is better for durable background jobs.
|
|
157
|
+
|
|
158
|
+
## Runtime And Lifecycle Notes
|
|
159
|
+
|
|
160
|
+
- `MessageLoader` maps `*.msg.*` files to routes using `@hile/loader`.
|
|
161
|
+
- `MessageLoader.dispatch(path, data, extras?)` invokes the matched handler.
|
|
162
|
+
- `MessageModem._send()` returns a `Promise`.
|
|
163
|
+
- `MessageModem._stream()` returns a Node `Readable` in object mode.
|
|
164
|
+
- A stream request requires `exec()` to return an async iterable.
|
|
165
|
+
- `Application.call(namespace, url, data, options?)` returns a promise.
|
|
166
|
+
- `Application.stream(namespace, url, data, options?)` returns a readable stream.
|
|
167
|
+
- `Application.publish(topic, payload)` returns an object with `update()` and `unpublish()`.
|
|
168
|
+
- `Application.subscribe(topic, callback)` returns an unsubscribe function.
|
|
169
|
+
- `Registry` stores service addresses and retained config/topic state under `~/.registry`.
|
|
170
|
+
|
|
171
|
+
## Anti-Patterns
|
|
172
|
+
|
|
173
|
+
- Appending a secondary response getter to `client.request('/x', data)`
|
|
174
|
+
- Returning a plain object from a handler called through `stream()`.
|
|
175
|
+
- Using pub/sub as a durable queue.
|
|
176
|
+
- Forgetting to register `shutdown(await app.listen(...))`.
|
|
177
|
+
|
|
178
|
+
## Verification Checklist
|
|
179
|
+
|
|
180
|
+
- Message files default-export `defineMessage(...)`.
|
|
181
|
+
- RPC callers use `await app.call(...)`.
|
|
182
|
+
- Streaming handlers are async generators.
|
|
183
|
+
- Registry is started before application nodes need discovery.
|
|
184
|
+
- Micro apps use stable namespaces and advertise reachable hosts.
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
# Related Recipes
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
# Micro RPC With Message Loader
|
|
193
|
+
|
|
194
|
+
## Complete Example
|
|
195
|
+
|
|
196
|
+
Provider handler:
|
|
197
|
+
|
|
198
|
+
```ts
|
|
199
|
+
// src/messages/charge.msg.ts
|
|
200
|
+
import { defineMessage } from '@hile/message-loader'
|
|
201
|
+
|
|
202
|
+
export default defineMessage(async ({ data }) => {
|
|
203
|
+
return { charged: true, input: data }
|
|
204
|
+
})
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
Provider boot:
|
|
208
|
+
|
|
209
|
+
```ts
|
|
210
|
+
// src/services/app.boot.ts
|
|
211
|
+
import { defineService } from '@hile/core'
|
|
212
|
+
import { Application } from '@hile/micro'
|
|
213
|
+
|
|
214
|
+
export default defineService('billing.micro', async (shutdown) => {
|
|
215
|
+
const app = new Application({
|
|
216
|
+
namespace: 'billing',
|
|
217
|
+
registry: { host: '127.0.0.1', port: 9876 },
|
|
218
|
+
advertiseHost: '127.0.0.1',
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
await app.load(new URL('../messages', import.meta.url).pathname)
|
|
222
|
+
const stop = await app.listen(9101)
|
|
223
|
+
shutdown(stop)
|
|
224
|
+
return app
|
|
225
|
+
})
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
Consumer:
|
|
229
|
+
|
|
230
|
+
```ts
|
|
231
|
+
const result = await app.call('billing', '/charge', {
|
|
232
|
+
tenantId: 't1',
|
|
233
|
+
amount: 100,
|
|
234
|
+
})
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
## File Layout
|
|
238
|
+
|
|
239
|
+
```text
|
|
240
|
+
provider/
|
|
241
|
+
src/messages/charge.msg.ts
|
|
242
|
+
src/services/app.boot.ts
|
|
243
|
+
consumer/
|
|
244
|
+
src/models/payments/pay.model.ts
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
## User Intent
|
|
248
|
+
|
|
249
|
+
Use this recipe when services communicate over Hile registry-backed RPC.
|
|
250
|
+
|
|
251
|
+
## Packages To Use
|
|
252
|
+
|
|
253
|
+
- `@hile/micro`
|
|
254
|
+
- `@hile/message-loader`
|
|
255
|
+
- `@hile/context` when context must cross service boundaries
|
|
256
|
+
- `@hile/redis-idempotency` for retryable side effects
|
|
257
|
+
|
|
258
|
+
## Implementation Steps
|
|
259
|
+
|
|
260
|
+
1. Start a Registry with `hile registry`.
|
|
261
|
+
2. Start providers with stable namespaces.
|
|
262
|
+
3. Load `*.msg.ts` handlers through `app.load()`.
|
|
263
|
+
4. Call providers with `await app.call(namespace, url, data)`.
|
|
264
|
+
5. Use `app.stream()` only for async-generator handlers.
|
|
265
|
+
|
|
266
|
+
## Failure And Cleanup Behavior
|
|
267
|
+
|
|
268
|
+
- `Application.call()` may retry; side-effecting handlers need idempotency.
|
|
269
|
+
- Registry disconnect triggers reconnect; apps re-declare topics and subscriptions.
|
|
270
|
+
- Circuit breaker excludes failing nodes for cooldown.
|
|
271
|
+
|
|
272
|
+
## Verification Checklist
|
|
273
|
+
|
|
274
|
+
- Registry is reachable.
|
|
275
|
+
- Provider namespace matches consumer call.
|
|
276
|
+
- Handlers default-export `defineMessage()`.
|
|
277
|
+
- Consumer code awaits `app.call(...)` directly.
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
# Global Guardrails
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
## Never Generate These Patterns
|
|
286
|
+
|
|
287
|
+
- Do not call `loadService()` at module top level; it starts resources during import.
|
|
288
|
+
- Do not default-export plain functions from `*.boot.*` files; `hile start` expects a Hile service.
|
|
289
|
+
- Do not set `ctx.body` and also return a controller value.
|
|
290
|
+
- Do not assume `@hile/http` Zod validation mutates or coerces `ctx.query`, `ctx.params`, or `ctx.request.body`.
|
|
291
|
+
- Do not put reusable business logic only in controllers, pages, queue workers, or message handlers.
|
|
292
|
+
- Do not use old message examples that append a secondary response getter; current request APIs return promises directly.
|
|
293
|
+
- Do not claim exactly-once delivery or execution from Redis locks, queues, idempotency, or rate limits.
|
|
294
|
+
- Do not use queue `jobId` as the only side-effect idempotency boundary.
|
|
295
|
+
- Do not log the entire async context by default.
|
package/README.md
CHANGED
|
@@ -1,215 +1,90 @@
|
|
|
1
1
|
# @hile/message-modem
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
<!-- Generated by scripts/build-ai-context.mjs from docs/ai. Do not edit by hand. -->
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
Implement request/response, push, abort, and stream protocol behavior independent of transport.
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
pnpm add @hile/message-modem
|
|
9
|
-
```
|
|
10
|
-
|
|
11
|
-
## 核心特性
|
|
7
|
+
This README is intentionally short and example-first. The complete AI-facing guide ships in `AI.md` in this package.
|
|
12
8
|
|
|
13
|
-
|
|
14
|
-
- **双向请求/响应** — `_send` 发送请求并等待对端响应(`twoway: true`)
|
|
15
|
-
- **单向推送** — `_push` 发送消息无需对端响应(`twoway: false`)
|
|
16
|
-
- **流式传输** — `_stream` 发送流式请求,对端返回 async generator 时分块回传,发送方获得 `Readable` stream
|
|
17
|
-
- **请求/响应配对** — 自增 ID + Promise 栈,自动配对请求与响应
|
|
18
|
-
- **超时控制** — 默认 30 秒,可按请求自定义
|
|
19
|
-
- **主动中止** — 发送方可 abort 等待,接收方可取消正在执行的任务
|
|
20
|
-
- **错误传播** — `Exception` 携带 status 码透传;普通 Error 映射为 500
|
|
9
|
+
## When To Use
|
|
21
10
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
### 第一步:继承并实现抽象方法
|
|
25
|
-
|
|
26
|
-
```typescript
|
|
27
|
-
import { MessageModem, type MessageTransferFormat } from '@hile/message-modem';
|
|
28
|
-
|
|
29
|
-
class WebSocketModem extends MessageModem {
|
|
30
|
-
constructor(private ws: WebSocket) {
|
|
31
|
-
super();
|
|
32
|
-
ws.addEventListener('message', (e) => {
|
|
33
|
-
this.receive(JSON.parse(e.data));
|
|
34
|
-
});
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
protected post<T>(data: MessageTransferFormat<T>): void {
|
|
38
|
-
this.ws.send(JSON.stringify(data));
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
protected async exec(data: any): Promise<any> {
|
|
42
|
-
// 处理远端请求
|
|
43
|
-
return handleRequest(data);
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
// 暴露发送方法
|
|
47
|
-
public request<T>(data: T, timeout?: number) {
|
|
48
|
-
return this._send(data, timeout);
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
```
|
|
11
|
+
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.
|
|
52
12
|
|
|
53
|
-
|
|
13
|
+
## Install
|
|
54
14
|
|
|
55
|
-
```
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
const { abort, response } = modem.request({ action: 'getUser', id: 1 });
|
|
59
|
-
|
|
60
|
-
// 等待响应
|
|
61
|
-
const user = await response();
|
|
62
|
-
console.log(user);
|
|
63
|
-
|
|
64
|
-
// 或中止请求
|
|
65
|
-
abort();
|
|
15
|
+
```bash
|
|
16
|
+
pnpm add @hile/message-modem
|
|
66
17
|
```
|
|
67
18
|
|
|
68
|
-
|
|
19
|
+
## Copy-Paste Example
|
|
69
20
|
|
|
70
|
-
|
|
21
|
+
Message handler file:
|
|
71
22
|
|
|
72
|
-
```
|
|
73
|
-
|
|
23
|
+
```ts
|
|
24
|
+
// src/messages/ping.msg.ts
|
|
25
|
+
import { defineMessage } from '@hile/message-loader'
|
|
74
26
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
} else if (e instanceof Exception) {
|
|
82
|
-
console.log(`远端错误 [${e.status}]: ${e.message}`);
|
|
27
|
+
export default defineMessage(async ({ data, params }) => {
|
|
28
|
+
return {
|
|
29
|
+
type: 'pong',
|
|
30
|
+
data,
|
|
31
|
+
params,
|
|
32
|
+
timestamp: Date.now(),
|
|
83
33
|
}
|
|
84
|
-
}
|
|
85
|
-
```
|
|
86
|
-
|
|
87
|
-
## API
|
|
88
|
-
|
|
89
|
-
### `MessageModem`(抽象类)
|
|
90
|
-
|
|
91
|
-
| 方法 | 可见性 | 说明 |
|
|
92
|
-
|------|--------|------|
|
|
93
|
-
| `post(data)` | `protected abstract` | 子类实现:如何将消息发送到远端 |
|
|
94
|
-
| `exec(data)` | `protected abstract` | 子类实现:如何处理收到的请求,返回 Promise |
|
|
95
|
-
| `_send(data, opts?)` | `protected` | 发送双向请求(`twoway: true`),返回 `{ abort, response }` |
|
|
96
|
-
| `_push(data, opts?)` | `protected` | 发送单向推送(`twoway: false`),无返回值,接收方不回复 RESPONSE |
|
|
97
|
-
| `_stream(data, opts?)` | `protected` | 发送流式请求,返回 `Readable` stream。对端 `exec()` 须返回 async generator |
|
|
98
|
-
| `receive(msg)` | `public` | 接收消息入口,根据 mode 分发处理 |
|
|
99
|
-
|
|
100
|
-
### `_send` 返回值
|
|
101
|
-
|
|
102
|
-
| 属性 | 类型 | 说明 |
|
|
103
|
-
|------|------|------|
|
|
104
|
-
| `abort` | `() => void` | 中止本次请求 |
|
|
105
|
-
| `response` | `<U>() => Promise<U>` | 等待远端响应 |
|
|
106
|
-
|
|
107
|
-
### 消息类型
|
|
108
|
-
|
|
109
|
-
| 枚举值 | 说明 |
|
|
110
|
-
|--------|------|
|
|
111
|
-
| `MESSAGE_MODEM_TYPE.REQUEST` | 请求消息 |
|
|
112
|
-
| `MESSAGE_MODEM_TYPE.RESPONSE` | 响应消息 |
|
|
113
|
-
| `MESSAGE_MODEM_TYPE.ABORT` | 中止消息 |
|
|
114
|
-
|
|
115
|
-
### 异常类
|
|
116
|
-
|
|
117
|
-
| 类 | status | 默认 message | 说明 |
|
|
118
|
-
|------|--------|------|------|
|
|
119
|
-
| `Exception` | 自定义 | 自定义 | 基础异常,携带 status |
|
|
120
|
-
| `TimeoutException` | `ETIMEDOUT` | `Timeout` | 超时异常 |
|
|
121
|
-
| `AbortException` | `ECONNABORTED` | `Abort` | 中止异常 |
|
|
122
|
-
|
|
123
|
-
### 消息格式
|
|
124
|
-
|
|
125
|
-
```typescript
|
|
126
|
-
// 传输格式
|
|
127
|
-
interface MessageTransferFormat<T = any> {
|
|
128
|
-
id: number;
|
|
129
|
-
mode: MESSAGE_MODEM_TYPE;
|
|
130
|
-
twoway: boolean;
|
|
131
|
-
stream?: boolean; // true → 流式模式
|
|
132
|
-
data?: T;
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
// 响应数据格式
|
|
136
|
-
interface MessageReturnFormat<T = any> {
|
|
137
|
-
status: string | number;
|
|
138
|
-
data: T;
|
|
139
|
-
message: string;
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
// 流式分块格式(stream=true 时 RESPONSE 携带)
|
|
143
|
-
interface MessageStreamChunk<T = any> {
|
|
144
|
-
status: string | number;
|
|
145
|
-
seq: number; // 块序号,从 0 递增
|
|
146
|
-
payload: T; // 块数据
|
|
147
|
-
final: boolean; // true → 最后一块
|
|
148
|
-
}
|
|
34
|
+
})
|
|
149
35
|
```
|
|
150
36
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
37
|
+
Microservice boot file:
|
|
38
|
+
|
|
39
|
+
```ts
|
|
40
|
+
// src/services/app.boot.ts
|
|
41
|
+
import { defineService } from '@hile/core'
|
|
42
|
+
import { Application } from '@hile/micro'
|
|
43
|
+
|
|
44
|
+
export default defineService('micro.app', async (shutdown) => {
|
|
45
|
+
const app = new Application({
|
|
46
|
+
namespace: process.env.MICRO_NAMESPACE ?? 'example.service',
|
|
47
|
+
registry: {
|
|
48
|
+
host: process.env.REGISTRY_HOST ?? '127.0.0.1',
|
|
49
|
+
port: Number(process.env.REGISTRY_PORT ?? 9876),
|
|
50
|
+
},
|
|
51
|
+
advertiseHost: process.env.HILE_ADVERTISE_HOST ?? '127.0.0.1',
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
await app.load(new URL('../messages', import.meta.url).pathname)
|
|
55
|
+
const stop = await app.listen(Number(process.env.MICRO_PORT ?? 0))
|
|
56
|
+
shutdown(stop)
|
|
57
|
+
return app
|
|
58
|
+
})
|
|
168
59
|
```
|
|
169
60
|
|
|
170
|
-
|
|
61
|
+
Caller:
|
|
171
62
|
|
|
172
|
-
```
|
|
173
|
-
|
|
174
|
-
│ │
|
|
175
|
-
│ _push(data) │
|
|
176
|
-
│──── REQUEST (!twoway) ─────►│
|
|
177
|
-
│ │ exec(data)
|
|
178
|
-
│ │ (不回复 RESPONSE)
|
|
63
|
+
```ts
|
|
64
|
+
const result = await app.call('example.service', '/ping', { hello: 'world' })
|
|
179
65
|
```
|
|
180
66
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
```
|
|
184
|
-
发送方 接收方
|
|
185
|
-
│ │
|
|
186
|
-
│ _stream(data) │
|
|
187
|
-
│──── REQUEST (stream) ──────►│
|
|
188
|
-
│ │ exec(data) → async generator
|
|
189
|
-
│ │
|
|
190
|
-
│◄─── RESPONSE (seq:0) ───────│ for await (chunk of gen)
|
|
191
|
-
│◄─── RESPONSE (seq:1) ───────│
|
|
192
|
-
│◄─── RESPONSE (seq:2) ───────│
|
|
193
|
-
│ ... │
|
|
194
|
-
│◄─── RESPONSE (final) ───────│ 迭代结束
|
|
195
|
-
│ │
|
|
196
|
-
│ abort() │
|
|
197
|
-
│──── ABORT ─────────────────►│ 取消迭代
|
|
198
|
-
```
|
|
67
|
+
## Boundaries
|
|
199
68
|
|
|
200
|
-
|
|
69
|
+
- Do not use `stream()` for normal single-result calls.
|
|
70
|
+
- Do not rely on message IDs for business idempotency. They are transport IDs.
|
|
71
|
+
- Do not bypass `defineMessage()` for file-loaded handlers.
|
|
201
72
|
|
|
202
|
-
|
|
203
|
-
|
|
73
|
+
- Appending a secondary response getter to `client.request('/x', data)`
|
|
74
|
+
- Returning a plain object from a handler called through `stream()`.
|
|
75
|
+
- Using pub/sub as a durable queue.
|
|
76
|
+
- Forgetting to register `shutdown(await app.listen(...))`.
|
|
204
77
|
|
|
205
|
-
##
|
|
78
|
+
## Verify
|
|
206
79
|
|
|
207
|
-
-
|
|
208
|
-
-
|
|
209
|
-
-
|
|
210
|
-
-
|
|
211
|
-
-
|
|
80
|
+
- Message files default-export `defineMessage(...)`.
|
|
81
|
+
- RPC callers use `await app.call(...)`.
|
|
82
|
+
- Streaming handlers are async generators.
|
|
83
|
+
- Registry is started before application nodes need discovery.
|
|
84
|
+
- Micro apps use stable namespaces and advertise reachable hosts.
|
|
212
85
|
|
|
213
|
-
##
|
|
86
|
+
## More Context
|
|
214
87
|
|
|
215
|
-
|
|
88
|
+
- `AI.md` in this package: full package-local AI guide.
|
|
89
|
+
- Root `llms-full.txt`: full monorepo AI context.
|
|
90
|
+
- Root `references/`: source files copied from `docs/ai`.
|
package/dist/index.d.ts
CHANGED
|
@@ -3,13 +3,15 @@ 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;
|
|
13
15
|
data?: T;
|
|
14
16
|
}
|
|
15
17
|
export interface MessageReturnFormat<T = any> {
|
|
@@ -28,6 +30,7 @@ export declare abstract class MessageModem {
|
|
|
28
30
|
private readonly aborts;
|
|
29
31
|
private readonly stacks;
|
|
30
32
|
private readonly streams;
|
|
33
|
+
private readonly streamProducers;
|
|
31
34
|
protected _dispose(): void;
|
|
32
35
|
/**
|
|
33
36
|
* 创建自增 ID
|
package/dist/index.js
CHANGED
|
@@ -6,12 +6,30 @@ 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
|
+
class CreditReadable extends Readable {
|
|
12
|
+
onConsumed;
|
|
13
|
+
constructor(onConsumed) {
|
|
14
|
+
super({ objectMode: true });
|
|
15
|
+
this.onConsumed = onConsumed;
|
|
16
|
+
}
|
|
17
|
+
_read() {
|
|
18
|
+
// Credits are tied to actual read() results, not Node's eager buffer filling.
|
|
19
|
+
}
|
|
20
|
+
read(size) {
|
|
21
|
+
const chunk = super.read(size);
|
|
22
|
+
if (chunk !== null)
|
|
23
|
+
this.onConsumed();
|
|
24
|
+
return chunk;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
10
27
|
export class MessageModem {
|
|
11
28
|
id = 0;
|
|
12
29
|
aborts = new Map();
|
|
13
30
|
stacks = new Map();
|
|
14
31
|
streams = new Map();
|
|
32
|
+
streamProducers = new Map();
|
|
15
33
|
_dispose() {
|
|
16
34
|
for (const { reject } of this.stacks.values()) {
|
|
17
35
|
reject(new AbortException());
|
|
@@ -19,12 +37,16 @@ export class MessageModem {
|
|
|
19
37
|
for (const controller of this.aborts.values()) {
|
|
20
38
|
controller.abort();
|
|
21
39
|
}
|
|
22
|
-
for (const stream of this.streams.values()) {
|
|
40
|
+
for (const { stream } of this.streams.values()) {
|
|
23
41
|
stream.destroy(new AbortException());
|
|
24
42
|
}
|
|
43
|
+
for (const producer of this.streamProducers.values()) {
|
|
44
|
+
producer.wake?.();
|
|
45
|
+
}
|
|
25
46
|
this.aborts.clear();
|
|
26
47
|
this.stacks.clear();
|
|
27
48
|
this.streams.clear();
|
|
49
|
+
this.streamProducers.clear();
|
|
28
50
|
}
|
|
29
51
|
/**
|
|
30
52
|
* 创建自增 ID
|
|
@@ -85,23 +107,64 @@ export class MessageModem {
|
|
|
85
107
|
}
|
|
86
108
|
_stream(data, options) {
|
|
87
109
|
const state = this.createPostData(MESSAGE_MODEM_TYPE.REQUEST, data, true, true);
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
110
|
+
state.streamVersion = 1;
|
|
111
|
+
let consumer;
|
|
112
|
+
const stream = new CreditReadable(() => {
|
|
113
|
+
if (!consumer.creditOwed || consumer.completed || consumer.cancelled)
|
|
114
|
+
return;
|
|
115
|
+
consumer.creditOwed = false;
|
|
116
|
+
try {
|
|
117
|
+
this.post(this.createPostData(MESSAGE_MODEM_TYPE.STREAM_CREDIT, { id: state.id, seq: consumer.nextSeq - 1 }, false));
|
|
118
|
+
}
|
|
119
|
+
catch (error) {
|
|
120
|
+
consumer.cancelled = true;
|
|
121
|
+
stream.destroy(error);
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
consumer = {
|
|
125
|
+
stream,
|
|
126
|
+
completed: false,
|
|
127
|
+
cancelled: false,
|
|
128
|
+
creditOwed: false,
|
|
129
|
+
nextSeq: 0,
|
|
130
|
+
};
|
|
131
|
+
const sendAbort = () => {
|
|
132
|
+
if (consumer.completed || consumer.cancelled)
|
|
133
|
+
return;
|
|
134
|
+
consumer.cancelled = true;
|
|
135
|
+
try {
|
|
136
|
+
this.post(this.createPostData(MESSAGE_MODEM_TYPE.ABORT, state.id));
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
// The transport may already be closed.
|
|
140
|
+
}
|
|
141
|
+
};
|
|
91
142
|
const onAbort = () => {
|
|
92
|
-
|
|
143
|
+
sendAbort();
|
|
93
144
|
stream.destroy(new AbortException());
|
|
94
|
-
this.streams.delete(state.id);
|
|
95
145
|
};
|
|
96
|
-
if (options?.signal) {
|
|
97
|
-
|
|
146
|
+
if (options?.signal?.aborted) {
|
|
147
|
+
consumer.cancelled = true;
|
|
148
|
+
queueMicrotask(() => stream.destroy(new AbortException()));
|
|
149
|
+
return stream;
|
|
98
150
|
}
|
|
151
|
+
this.streams.set(state.id, consumer);
|
|
152
|
+
options?.signal?.addEventListener('abort', onAbort, { once: true });
|
|
99
153
|
stream.on('close', () => {
|
|
154
|
+
sendAbort();
|
|
100
155
|
if (this.streams.has(state.id)) {
|
|
101
156
|
this.streams.delete(state.id);
|
|
102
157
|
}
|
|
103
158
|
options?.signal?.removeEventListener('abort', onAbort);
|
|
104
159
|
});
|
|
160
|
+
try {
|
|
161
|
+
this.post(state);
|
|
162
|
+
}
|
|
163
|
+
catch (error) {
|
|
164
|
+
consumer.cancelled = true;
|
|
165
|
+
this.streams.delete(state.id);
|
|
166
|
+
queueMicrotask(() => stream.destroy(error));
|
|
167
|
+
}
|
|
105
168
|
return stream;
|
|
106
169
|
}
|
|
107
170
|
/**
|
|
@@ -116,26 +179,32 @@ export class MessageModem {
|
|
|
116
179
|
const signal = options?.signal;
|
|
117
180
|
// 创建请求消息数据
|
|
118
181
|
const state = this.createPostData(MESSAGE_MODEM_TYPE.REQUEST, data, twoway);
|
|
119
|
-
// 发送消息
|
|
120
|
-
this.post(state);
|
|
121
182
|
// 如果消息是单向的,则直接返回
|
|
122
|
-
if (!twoway)
|
|
183
|
+
if (!twoway) {
|
|
184
|
+
if (!signal?.aborted)
|
|
185
|
+
this.post(state);
|
|
123
186
|
return;
|
|
187
|
+
}
|
|
124
188
|
return new Promise((resolve, reject) => {
|
|
189
|
+
let timer;
|
|
190
|
+
let posted = false;
|
|
125
191
|
const clear = () => {
|
|
126
192
|
if (this.stacks.has(state.id)) {
|
|
127
193
|
this.stacks.delete(state.id);
|
|
128
194
|
}
|
|
129
195
|
};
|
|
130
196
|
const clean = () => {
|
|
131
|
-
|
|
197
|
+
if (timer)
|
|
198
|
+
clearTimeout(timer);
|
|
132
199
|
signal?.removeEventListener('abort', onAbort);
|
|
133
200
|
clear();
|
|
134
201
|
};
|
|
135
202
|
const onAbort = () => {
|
|
136
|
-
|
|
203
|
+
if (timer)
|
|
204
|
+
clearTimeout(timer);
|
|
137
205
|
try {
|
|
138
|
-
|
|
206
|
+
if (posted)
|
|
207
|
+
this.post(this.createPostData(MESSAGE_MODEM_TYPE.ABORT, state.id));
|
|
139
208
|
}
|
|
140
209
|
catch {
|
|
141
210
|
/* 例如 WebSocket 已关闭时 send 可能抛错 */
|
|
@@ -156,12 +225,23 @@ export class MessageModem {
|
|
|
156
225
|
clean();
|
|
157
226
|
reject(e);
|
|
158
227
|
};
|
|
159
|
-
const timer = setTimeout(() => _reject(new TimeoutException()), timeout).unref();
|
|
160
|
-
signal?.addEventListener('abort', onAbort);
|
|
161
228
|
this.stacks.set(state.id, {
|
|
162
229
|
resolve: _resolve,
|
|
163
230
|
reject: _reject,
|
|
164
231
|
});
|
|
232
|
+
timer = setTimeout(() => _reject(new TimeoutException()), timeout).unref();
|
|
233
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
234
|
+
if (signal?.aborted) {
|
|
235
|
+
onAbort();
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
try {
|
|
239
|
+
posted = true;
|
|
240
|
+
this.post(state);
|
|
241
|
+
}
|
|
242
|
+
catch (error) {
|
|
243
|
+
_reject(error);
|
|
244
|
+
}
|
|
165
245
|
});
|
|
166
246
|
}
|
|
167
247
|
/**
|
|
@@ -238,30 +318,76 @@ export class MessageModem {
|
|
|
238
318
|
}
|
|
239
319
|
}
|
|
240
320
|
onStreamRequest(msg) {
|
|
321
|
+
if (msg.streamVersion !== 1) {
|
|
322
|
+
this.post({
|
|
323
|
+
id: msg.id,
|
|
324
|
+
mode: MESSAGE_MODEM_TYPE.RESPONSE,
|
|
325
|
+
stream: true,
|
|
326
|
+
streamVersion: 1,
|
|
327
|
+
data: { status: 400, seq: 0, payload: 'Unsupported stream protocol', final: true },
|
|
328
|
+
twoway: false,
|
|
329
|
+
});
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
if (this.streamProducers.has(msg.id) || this.streamProducers.size >= 128) {
|
|
333
|
+
this.post({
|
|
334
|
+
id: msg.id,
|
|
335
|
+
mode: MESSAGE_MODEM_TYPE.RESPONSE,
|
|
336
|
+
stream: true,
|
|
337
|
+
streamVersion: 1,
|
|
338
|
+
data: { status: 429, seq: 0, payload: 'Stream capacity exceeded', final: true },
|
|
339
|
+
twoway: false,
|
|
340
|
+
});
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
241
343
|
const controller = new AbortController();
|
|
344
|
+
const producer = {
|
|
345
|
+
credits: 1,
|
|
346
|
+
nextCreditSeq: 0,
|
|
347
|
+
};
|
|
348
|
+
let sequence = 0;
|
|
242
349
|
this.aborts.set(msg.id, controller);
|
|
350
|
+
this.streamProducers.set(msg.id, producer);
|
|
243
351
|
controller.signal.addEventListener('abort', () => {
|
|
352
|
+
producer.wake?.();
|
|
244
353
|
if (this.aborts.has(msg.id)) {
|
|
245
354
|
this.aborts.delete(msg.id);
|
|
246
355
|
}
|
|
247
356
|
});
|
|
357
|
+
const takeCredit = async () => {
|
|
358
|
+
while (producer.credits === 0 && !controller.signal.aborted) {
|
|
359
|
+
await new Promise((resolve) => {
|
|
360
|
+
producer.wake = resolve;
|
|
361
|
+
});
|
|
362
|
+
producer.wake = undefined;
|
|
363
|
+
}
|
|
364
|
+
if (controller.signal.aborted)
|
|
365
|
+
throw new AbortException();
|
|
366
|
+
producer.credits--;
|
|
367
|
+
};
|
|
248
368
|
this.exec(msg.data, controller.signal)
|
|
249
369
|
.then(async (value) => {
|
|
250
370
|
if (!isAsyncIterable(value)) {
|
|
251
371
|
throw new Exception(500, 'Invalid async iterable');
|
|
252
372
|
}
|
|
253
|
-
|
|
254
|
-
|
|
373
|
+
const iterator = value[Symbol.asyncIterator]();
|
|
374
|
+
producer.iterator = iterator;
|
|
375
|
+
while (!controller.signal.aborted) {
|
|
376
|
+
await takeCredit();
|
|
377
|
+
const next = await iterator.next();
|
|
255
378
|
if (controller.signal.aborted)
|
|
256
379
|
return;
|
|
380
|
+
if (next.done)
|
|
381
|
+
break;
|
|
257
382
|
this.post({
|
|
258
383
|
id: msg.id,
|
|
259
384
|
mode: MESSAGE_MODEM_TYPE.RESPONSE,
|
|
260
385
|
stream: true,
|
|
386
|
+
streamVersion: msg.streamVersion,
|
|
261
387
|
data: {
|
|
262
388
|
status: 200,
|
|
263
|
-
seq:
|
|
264
|
-
payload:
|
|
389
|
+
seq: sequence++,
|
|
390
|
+
payload: next.value,
|
|
265
391
|
final: false,
|
|
266
392
|
},
|
|
267
393
|
twoway: false,
|
|
@@ -273,9 +399,10 @@ export class MessageModem {
|
|
|
273
399
|
id: msg.id,
|
|
274
400
|
mode: MESSAGE_MODEM_TYPE.RESPONSE,
|
|
275
401
|
stream: true,
|
|
402
|
+
streamVersion: msg.streamVersion,
|
|
276
403
|
data: {
|
|
277
404
|
status: 200,
|
|
278
|
-
seq:
|
|
405
|
+
seq: sequence++,
|
|
279
406
|
payload: undefined,
|
|
280
407
|
final: true,
|
|
281
408
|
},
|
|
@@ -289,16 +416,22 @@ export class MessageModem {
|
|
|
289
416
|
id: msg.id,
|
|
290
417
|
mode: MESSAGE_MODEM_TYPE.RESPONSE,
|
|
291
418
|
stream: true,
|
|
419
|
+
streamVersion: msg.streamVersion,
|
|
292
420
|
data: {
|
|
293
421
|
status: e instanceof Exception ? e.status : 500,
|
|
294
|
-
seq:
|
|
295
|
-
payload: e instanceof
|
|
422
|
+
seq: sequence,
|
|
423
|
+
payload: e instanceof Error ? e.message : 'Unknown error',
|
|
296
424
|
final: true,
|
|
297
425
|
},
|
|
298
426
|
twoway: false,
|
|
299
427
|
});
|
|
300
428
|
})
|
|
301
429
|
.finally(() => {
|
|
430
|
+
if (controller.signal.aborted && producer.iterator?.return) {
|
|
431
|
+
void Promise.resolve(producer.iterator.return()).catch(() => { });
|
|
432
|
+
}
|
|
433
|
+
producer.wake?.();
|
|
434
|
+
this.streamProducers.delete(msg.id);
|
|
302
435
|
if (this.aborts.has(msg.id)) {
|
|
303
436
|
this.aborts.delete(msg.id);
|
|
304
437
|
}
|
|
@@ -309,22 +442,32 @@ export class MessageModem {
|
|
|
309
442
|
const res = msg.data;
|
|
310
443
|
// 如果栈中存在该消息,则处理响应消息
|
|
311
444
|
if (this.streams.has(id)) {
|
|
312
|
-
const
|
|
445
|
+
const consumer = this.streams.get(id);
|
|
446
|
+
const stream = consumer.stream;
|
|
313
447
|
if (res) {
|
|
448
|
+
if (!Number.isSafeInteger(res.seq) || res.seq !== consumer.nextSeq) {
|
|
449
|
+
stream.destroy(new Exception(409, `Invalid stream sequence: expected ${consumer.nextSeq}, received ${String(res.seq)}`));
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
consumer.nextSeq++;
|
|
314
453
|
if (res.status === 200) {
|
|
315
454
|
if (res.final) {
|
|
455
|
+
consumer.completed = true;
|
|
316
456
|
stream.push(null);
|
|
317
457
|
}
|
|
318
458
|
else {
|
|
459
|
+
consumer.creditOwed = true;
|
|
319
460
|
stream.push(res.payload);
|
|
320
461
|
}
|
|
321
462
|
}
|
|
322
463
|
else {
|
|
464
|
+
consumer.completed = true;
|
|
323
465
|
const err = new Exception(res.status, res.payload);
|
|
324
466
|
setImmediate(() => stream.destroy(err));
|
|
325
467
|
}
|
|
326
468
|
}
|
|
327
469
|
else {
|
|
470
|
+
consumer.completed = true;
|
|
328
471
|
const err = new Exception(404, 'Empty chunk data');
|
|
329
472
|
setImmediate(() => stream.destroy(err));
|
|
330
473
|
}
|
|
@@ -365,6 +508,22 @@ export class MessageModem {
|
|
|
365
508
|
}
|
|
366
509
|
}
|
|
367
510
|
break;
|
|
511
|
+
case MESSAGE_MODEM_TYPE.STREAM_CREDIT: {
|
|
512
|
+
const credit = msg.data;
|
|
513
|
+
if (!credit
|
|
514
|
+
|| !Number.isSafeInteger(credit.id)
|
|
515
|
+
|| credit.id < 0
|
|
516
|
+
|| !Number.isSafeInteger(credit.seq)
|
|
517
|
+
|| credit.seq < 0)
|
|
518
|
+
break;
|
|
519
|
+
const producer = this.streamProducers.get(credit.id);
|
|
520
|
+
if (producer && producer.credits === 0 && credit.seq === producer.nextCreditSeq) {
|
|
521
|
+
producer.credits = 1;
|
|
522
|
+
producer.nextCreditSeq++;
|
|
523
|
+
producer.wake?.();
|
|
524
|
+
}
|
|
525
|
+
break;
|
|
526
|
+
}
|
|
368
527
|
}
|
|
369
528
|
}
|
|
370
529
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hile/message-modem",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "4.0.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"scripts": {
|
|
@@ -11,15 +11,16 @@
|
|
|
11
11
|
"files": [
|
|
12
12
|
"dist",
|
|
13
13
|
"README.md",
|
|
14
|
-
"
|
|
14
|
+
"AI.md"
|
|
15
15
|
],
|
|
16
16
|
"license": "MIT",
|
|
17
17
|
"publishConfig": {
|
|
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": "
|
|
25
|
+
"gitHead": "b46cb7f3705a226f58e4d65a2ff985ea54b9a159"
|
|
25
26
|
}
|