@zhin.js/host-http 1.1.16 → 1.2.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.
Files changed (89) hide show
  1. package/README.md +60 -21
  2. package/lib/access-control.d.ts +127 -0
  3. package/lib/access-control.js +148 -0
  4. package/lib/addresses.d.ts +9 -0
  5. package/lib/addresses.js +9 -0
  6. package/lib/bridge/bridged-host.d.ts +43 -0
  7. package/lib/bridge/bridged-host.js +497 -0
  8. package/lib/bridge/control-channel.d.ts +59 -0
  9. package/lib/bridge/control-channel.js +131 -0
  10. package/lib/bridge/flow.d.ts +58 -0
  11. package/lib/bridge/flow.js +188 -0
  12. package/lib/bridge/index.d.ts +6 -0
  13. package/lib/bridge/index.js +6 -0
  14. package/lib/bridge/instance-transport.d.ts +112 -0
  15. package/lib/bridge/instance-transport.js +532 -0
  16. package/lib/bridge/protocol.d.ts +242 -0
  17. package/lib/bridge/protocol.js +369 -0
  18. package/lib/console-events.d.ts +5 -3
  19. package/lib/console-events.js +53 -27
  20. package/lib/console-plugin-rpc.d.ts +5 -1
  21. package/lib/console-plugin-rpc.js +5 -40
  22. package/lib/console-rest-pages-helpers.d.ts +4 -5
  23. package/lib/console-rest-pages-helpers.js +62 -79
  24. package/lib/console-rest-pages.js +80 -65
  25. package/lib/console-rpc-extended/dispatcher.js +1 -6
  26. package/lib/console-rpc-handlers.d.ts +0 -2
  27. package/lib/console-rpc-handlers.js +2 -40
  28. package/lib/console-rpc.d.ts +6 -14
  29. package/lib/console-rpc.js +6 -6
  30. package/lib/host-installer.d.ts +6 -3
  31. package/lib/host-installer.js +10 -40
  32. package/lib/host-options.d.ts +47 -0
  33. package/lib/host-options.js +1 -0
  34. package/lib/http-host.d.ts +124 -48
  35. package/lib/http-host.js +319 -332
  36. package/lib/index.d.ts +10 -5
  37. package/lib/index.js +9 -5
  38. package/lib/json-body.d.ts +1 -2
  39. package/lib/json-body.js +1 -2
  40. package/lib/marketplace-registry.d.ts +17 -0
  41. package/lib/marketplace-registry.js +52 -0
  42. package/lib/node/index.d.ts +10 -0
  43. package/lib/node/index.js +10 -0
  44. package/lib/{listener-group.d.ts → node/listener-group.d.ts} +2 -3
  45. package/lib/{listener-group.js → node/listener-group.js} +6 -10
  46. package/lib/node/listener.d.ts +26 -0
  47. package/lib/node/listener.js +231 -0
  48. package/lib/node/node-http.d.ts +38 -0
  49. package/lib/node/node-http.js +272 -0
  50. package/lib/node/server.d.ts +13 -0
  51. package/lib/node/server.js +64 -0
  52. package/lib/protocol-auth.d.ts +24 -0
  53. package/lib/protocol-auth.js +48 -0
  54. package/lib/public-api.d.ts +1 -1
  55. package/lib/token-registry.d.ts +54 -36
  56. package/lib/token-registry.js +133 -126
  57. package/lib/trust-proxy.d.ts +22 -0
  58. package/lib/trust-proxy.js +168 -0
  59. package/package.json +14 -6
  60. package/src/access-control.ts +255 -0
  61. package/src/addresses.ts +16 -0
  62. package/src/bridge/bridged-host.ts +548 -0
  63. package/src/bridge/control-channel.ts +173 -0
  64. package/src/bridge/flow.ts +208 -0
  65. package/src/bridge/index.ts +31 -0
  66. package/src/bridge/instance-transport.ts +627 -0
  67. package/src/bridge/protocol.ts +574 -0
  68. package/src/console-events.ts +59 -28
  69. package/src/console-plugin-rpc.ts +5 -51
  70. package/src/console-rest-pages-helpers.ts +60 -82
  71. package/src/console-rest-pages.ts +87 -67
  72. package/src/console-rpc-extended/dispatcher.ts +1 -6
  73. package/src/console-rpc-handlers.ts +2 -31
  74. package/src/console-rpc.ts +10 -19
  75. package/src/host-installer.ts +12 -44
  76. package/src/host-options.ts +47 -0
  77. package/src/http-host.ts +467 -421
  78. package/src/index.ts +55 -13
  79. package/src/json-body.ts +7 -2
  80. package/src/marketplace-registry.ts +62 -0
  81. package/src/node/index.ts +21 -0
  82. package/src/{listener-group.ts → node/listener-group.ts} +13 -24
  83. package/src/node/listener.ts +265 -0
  84. package/src/node/node-http.ts +263 -0
  85. package/src/node/server.ts +82 -0
  86. package/src/protocol-auth.ts +52 -0
  87. package/src/public-api.ts +2 -0
  88. package/src/token-registry.ts +143 -146
  89. package/src/trust-proxy.ts +178 -0
@@ -16,27 +16,60 @@ export function createConsoleEventHub(options = {}) {
16
16
  const historyPageByteLimit = positiveInteger(options.historyPageByteLimit, DEFAULT_HISTORY_PAGE_BYTE_LIMIT);
17
17
  const journal = [];
18
18
  let journalBytes = 0;
19
- const subscribers = new Set();
19
+ /**
20
+ * `null` while a subscriber is live. During replay catch-up it holds the frames
21
+ * (replay suffix, then live events) still waiting for the socket to drain.
22
+ */
23
+ const subscribers = new Map();
20
24
  const listeners = new Set();
21
25
  const remove = (response) => {
22
26
  subscribers.delete(response);
23
27
  };
24
- const write = (response, entry) => {
25
- let failed = false;
26
- const accepted = response.write(entry.frame, (error) => {
27
- if (error) {
28
- failed = true;
29
- remove(response);
28
+ const drop = (response) => {
29
+ remove(response);
30
+ response.error(new Error('Console event subscriber backpressure limit reached'));
31
+ };
32
+ /** Returns false when the consumer asks the producer to wait for drain. */
33
+ const write = (response, entry) => response.write(entry.frame);
34
+ /** Write queued frames until the socket pushes back again or the queue is empty. */
35
+ const flush = (response) => {
36
+ const pending = subscribers.get(response);
37
+ if (!pending)
38
+ return;
39
+ try {
40
+ while (pending.length > 0) {
41
+ if (!write(response, pending.shift())) {
42
+ const stop = response.onDrain(() => {
43
+ stop();
44
+ flush(response);
45
+ });
46
+ return;
47
+ }
30
48
  }
31
- });
32
- if (failed)
33
- throw new Error('Console event subscriber rejected replay');
34
- if (!accepted) {
49
+ subscribers.set(response, null);
50
+ }
51
+ catch {
35
52
  remove(response);
36
- response.destroy(new Error('Console event subscriber backpressure limit reached'));
37
- throw new Error('Console event subscriber backpressure limit reached');
38
53
  }
39
54
  };
55
+ const deliver = (response, entry) => {
56
+ const pending = subscribers.get(response);
57
+ if (pending === undefined)
58
+ return;
59
+ if (pending === null) {
60
+ // A live subscriber that cannot keep up is disconnected; it resumes from history.
61
+ if (!write(response, entry))
62
+ drop(response);
63
+ return;
64
+ }
65
+ pending.push(entry);
66
+ // Catch-up may not outgrow what the journal itself is allowed to retain.
67
+ let bytes = 0;
68
+ for (const queued of pending)
69
+ bytes += queued.bytes;
70
+ if (pending.length > historyLimit || bytes > historyByteLimit)
71
+ drop(response);
72
+ };
40
73
  return {
41
74
  runtimeId,
42
75
  get subscriberCount() {
@@ -58,9 +91,9 @@ export function createConsoleEventHub(options = {}) {
58
91
  while (journal.length > historyLimit || journalBytes > historyByteLimit) {
59
92
  journalBytes -= journal.shift()?.bytes ?? 0;
60
93
  }
61
- for (const response of [...subscribers]) {
94
+ for (const response of [...subscribers.keys()]) {
62
95
  try {
63
- write(response, entry);
96
+ deliver(response, entry);
64
97
  }
65
98
  catch {
66
99
  remove(response);
@@ -121,18 +154,11 @@ export function createConsoleEventHub(options = {}) {
121
154
  || requestedAfter > nextId
122
155
  ? 0
123
156
  : requestedAfter;
124
- try {
125
- for (const entry of journal) {
126
- if (entry.event.eventId > after)
127
- write(response, entry);
128
- }
129
- subscribers.add(response);
130
- }
131
- catch {
132
- remove(response);
133
- }
134
- response.once('close', () => remove(response));
135
- response.once('error', () => remove(response));
157
+ // Backpressure during replay is expected for a large suffix: the remaining frames
158
+ // (and any event published meanwhile) wait in order for the socket to drain.
159
+ subscribers.set(response, journal.filter((entry) => entry.event.eventId > after));
160
+ response.onClose(() => remove(response));
161
+ flush(response);
136
162
  return () => remove(response);
137
163
  },
138
164
  };
@@ -1,3 +1,7 @@
1
1
  import type { RuntimeConsoleRpcContext, RuntimeConsoleRpcMessage, RuntimeConsoleRpcReply } from './console-rpc.js';
2
- /** Dispatches the plugin-management slice; undefined means the type belongs elsewhere. */
2
+ /**
3
+ * Dispatches the plugin-management slice; undefined means the type belongs elsewhere. Package
4
+ * installs / updates / uninstalls are not handled in the instance: they are gateway dependency
5
+ * transactions (`gateway.packages.*`, ADR 0054 §4) that stop the instance first.
6
+ */
3
7
  export declare function dispatchPluginConsoleRpc(type: string, message: RuntimeConsoleRpcMessage, ctx: RuntimeConsoleRpcContext): Promise<RuntimeConsoleRpcReply | undefined>;
@@ -1,5 +1,9 @@
1
1
  import { PLUGIN_RPC } from '@zhin.js/console-protocol';
2
- /** Dispatches the plugin-management slice; undefined means the type belongs elsewhere. */
2
+ /**
3
+ * Dispatches the plugin-management slice; undefined means the type belongs elsewhere. Package
4
+ * installs / updates / uninstalls are not handled in the instance: they are gateway dependency
5
+ * transactions (`gateway.packages.*`, ADR 0054 §4) that stop the instance first.
6
+ */
3
7
  export async function dispatchPluginConsoleRpc(type, message, ctx) {
4
8
  const requestId = message.requestId;
5
9
  try {
@@ -71,17 +75,6 @@ export async function dispatchPluginConsoleRpc(type, message, ctx) {
71
75
  return { requestId, error: 'Plugin management is not configured' };
72
76
  return { requestId, data: await ctx.pluginManagement.planInstall(packageName) };
73
77
  }
74
- case PLUGIN_RPC.INSTALL: {
75
- const packageName = stringField(message, 'packageName');
76
- if (!packageName)
77
- return { requestId, error: 'packageName is required' };
78
- if (!ctx.pluginManagement?.install) {
79
- return { requestId, error: 'Plugin installation is not configured' };
80
- }
81
- const result = await ctx.pluginManagement.install(packageName, optionalString(message.expectedRevision), optionalString(message.expectedProjectRevision));
82
- ctx.publishEvent?.('plugin:installed', { packageName, restartRequired: result.restartRequired });
83
- return { requestId, data: { success: true, ...result } };
84
- }
85
78
  case PLUGIN_RPC.PLAN_UNINSTALL: {
86
79
  const packageName = stringField(message, 'packageName');
87
80
  if (!packageName)
@@ -91,18 +84,6 @@ export async function dispatchPluginConsoleRpc(type, message, ctx) {
91
84
  }
92
85
  return { requestId, data: await ctx.pluginManagement.planUninstall(packageName) };
93
86
  }
94
- case PLUGIN_RPC.UNINSTALL: {
95
- const packageName = stringField(message, 'packageName');
96
- if (!packageName || stringField(message, 'confirmation') !== packageName) {
97
- return { requestId, error: 'confirmation must exactly match packageName' };
98
- }
99
- if (!ctx.pluginManagement?.uninstall) {
100
- return { requestId, error: 'Plugin uninstall is not configured' };
101
- }
102
- const result = await ctx.pluginManagement.uninstall(packageName, optionalString(message.expectedRevision), optionalString(message.expectedProjectRevision));
103
- ctx.publishEvent?.('plugin:uninstalled', { packageName, restartRequired: result.restartRequired });
104
- return { requestId, data: { success: true, ...result } };
105
- }
106
87
  case PLUGIN_RPC.PLAN_UPDATE: {
107
88
  const packageName = stringField(message, 'packageName');
108
89
  const targetVersion = stringField(message, 'targetVersion');
@@ -114,22 +95,6 @@ export async function dispatchPluginConsoleRpc(type, message, ctx) {
114
95
  }
115
96
  return { requestId, data: await ctx.pluginManagement.planUpdate(packageName, targetVersion) };
116
97
  }
117
- case PLUGIN_RPC.UPDATE: {
118
- const packageName = stringField(message, 'packageName');
119
- const targetVersion = stringField(message, 'targetVersion');
120
- if (!packageName || !targetVersion) {
121
- return { requestId, error: 'packageName and targetVersion are required' };
122
- }
123
- if (!ctx.pluginManagement?.update)
124
- return { requestId, error: 'Plugin update is not configured' };
125
- const result = await ctx.pluginManagement.update(packageName, targetVersion, optionalString(message.expectedRevision), optionalString(message.expectedProjectRevision));
126
- ctx.publishEvent?.('plugin:updated', {
127
- packageName,
128
- version: result.installedVersion,
129
- restartRequired: result.restartRequired,
130
- });
131
- return { requestId, data: { success: true, ...result } };
132
- }
133
98
  case PLUGIN_RPC.VALIDATE_CONFIG: {
134
99
  const pluginName = stringField(message, 'pluginName');
135
100
  if (!pluginName)
@@ -1,6 +1,6 @@
1
- import type { ServerResponse } from 'node:http';
2
1
  import type { HttpHost } from './http-host.js';
3
- import type { AuthScope } from './token-registry.js';
2
+ /** Logs an unexpected failure server-side; clients only get a generic message. */
3
+ export declare function logInternalError(op: string, error: unknown): void;
4
4
  import type { ConsoleRestCtx, ConsoleRestPagesOptions } from './console-rest-pages.js';
5
5
  export declare function registerIntrospectionRoutes(route: HttpHost['route'], base: string, ctx: ConsoleRestCtx, options: ConsoleRestPagesOptions): void;
6
6
  export declare function clampPositiveInt(value: number | undefined, fallback: number, ceiling: number): number;
@@ -15,9 +15,8 @@ export declare function paginateItems<T>(all: T[], page: number, pageSize: numbe
15
15
  export declare function filterByFields<T>(items: T[], filter: string | undefined, fields: Array<(item: T) => string | undefined>): T[];
16
16
  export declare function stringProp(value: unknown, key: string): string | undefined;
17
17
  export declare function parsePositiveInt(raw: string | null, fallback: number): number;
18
- /** 写操作门禁:demo 部署(ctx.fullScope=false)或 demo token 均 403。 */
19
- export declare function requireWriteScope(response: ServerResponse, ctx: ConsoleRestCtx, authScope: AuthScope): boolean;
18
+ /** 写操作门禁:demo 部署(ctx.fullScope=false)返回 403 响应,放行时返回 undefined。 */
19
+ export declare function requireWriteScope(ctx: ConsoleRestCtx): Response | undefined;
20
20
  export declare function decodePathParam(raw: string): string;
21
21
  export declare function trimTrailingSlashes(s: string): string;
22
22
  export declare function normalizeBase(value: string): string;
23
- export declare function writeJson(response: ServerResponse, status: number, body: unknown): void;
@@ -1,4 +1,11 @@
1
1
  import { HttpBodyError, readJsonBody } from './json-body.js';
2
+ import { jsonResponse } from '@zhin.js/host-http-contract';
3
+ import { formatCompact, getLogger } from '@zhin.js/logger';
4
+ const logger = getLogger('console');
5
+ /** Logs an unexpected failure server-side; clients only get a generic message. */
6
+ export function logInternalError(op, error) {
7
+ logger.warn(formatCompact({ op, error: error instanceof Error ? error.message : String(error) }));
8
+ }
2
9
  const INTROSPECTION_PAGE_SIZES = {
3
10
  commands: 25,
4
11
  middlewares: 30,
@@ -102,7 +109,7 @@ export function registerIntrospectionRoutes(route, base, ctx, options) {
102
109
  },
103
110
  ];
104
111
  for (const { kind, collect, fields } of collectors) {
105
- route('GET', `${base}/introspection/${kind}`, async (_request, response, url) => {
112
+ route('GET', `${base}/introspection/${kind}`, async (_request, { url }) => {
106
113
  const lease = kind === 'endpoints' ? null : ctx.acquireAgentRuntime?.() ?? null;
107
114
  try {
108
115
  const introspection = lease?.value.introspection;
@@ -119,16 +126,17 @@ export function registerIntrospectionRoutes(route, base, ctx, options) {
119
126
  ? 'Endpoints 数据源未接线(ctx.getEndpoints 缺失)'
120
127
  : 'Agent runtime 未装配(basic/cli 未接线 acquireAgentRuntime)'
121
128
  : undefined);
122
- writeJson(response, 200, {
129
+ return jsonResponse(200, {
123
130
  success: true,
124
131
  data: { ...slice, filter, note: degradedNote },
125
132
  });
126
133
  }
127
134
  catch (err) {
128
- writeJson(response, 503, {
135
+ logInternalError(`introspection_${kind}_failed`, err);
136
+ return jsonResponse(503, {
129
137
  success: false,
130
138
  data: { items: [], page: 1, pageSize: 0, total: 0, totalPages: 0 },
131
- error: err instanceof Error ? err.message : String(err),
139
+ error: 'Introspection unavailable',
132
140
  });
133
141
  }
134
142
  finally {
@@ -137,28 +145,26 @@ export function registerIntrospectionRoutes(route, base, ctx, options) {
137
145
  }, { summary: `Introspection: ${kind}`, tags: ['console', 'introspection'] });
138
146
  }
139
147
  let activeComponentPreviews = 0;
140
- route('POST', `${base}/introspection/components/render`, async (request, response, _url, authScope) => {
141
- if (!requireWriteScope(response, ctx, authScope))
142
- return;
148
+ route('POST', `${base}/introspection/components/render`, async (request) => {
149
+ const denied = requireWriteScope(ctx);
150
+ if (denied)
151
+ return denied;
143
152
  const body = (await readJsonBody(request)) ?? {};
144
153
  const requester = typeof body.requester === 'string' ? body.requester.trim() : '';
145
154
  const name = typeof body.name === 'string' ? body.name.trim() : '';
146
155
  if (!requester || !name || requester.length > 256 || name.length > 160) {
147
- writeJson(response, 400, { success: false, error: '请提供有效的 requester 与 component name' });
148
- return;
156
+ return jsonResponse(400, { success: false, error: '请提供有效的 requester 与 component name' });
149
157
  }
150
158
  const maxConcurrent = clampPositiveInt(options.componentPreviewMaxConcurrent, 2, 8);
151
159
  if (activeComponentPreviews >= maxConcurrent) {
152
- writeJson(response, 503, { success: false, error: 'Component preview 正忙,请稍后重试' });
153
- return;
160
+ return jsonResponse(503, { success: false, error: 'Component preview 正忙,请稍后重试' });
154
161
  }
155
162
  const lease = ctx.acquireAgentRuntime?.();
156
163
  const render = lease?.value.introspection?.renderComponent;
157
164
  const previewDelivery = lease?.value.introspection?.previewComponentDelivery;
158
165
  if (!lease || !render) {
159
166
  lease?.release();
160
- writeJson(response, 503, { success: false, error: 'Component Runtime 未就绪' });
161
- return;
167
+ return jsonResponse(503, { success: false, error: 'Component Runtime 未就绪' });
162
168
  }
163
169
  const controller = new AbortController();
164
170
  const timeoutMs = clampPositiveInt(options.componentPreviewTimeoutMs, 5_000, 30_000);
@@ -187,15 +193,19 @@ export function registerIntrospectionRoutes(route, base, ctx, options) {
187
193
  try {
188
194
  const preview = await raceWithAbort(renderPromise, controller.signal);
189
195
  assertPreviewOutput(preview, options.componentPreviewMaxBytes ?? 256 * 1024);
190
- writeJson(response, 200, { success: true, data: { requester, name, ...preview } });
196
+ return jsonResponse(200, { success: true, data: { requester, name, ...preview } });
191
197
  }
192
198
  catch (error) {
193
199
  const status = error instanceof ComponentPreviewTimeoutError
194
200
  ? 408
195
201
  : error instanceof ComponentPreviewLimitError ? 413 : 422;
196
- writeJson(response, status, {
202
+ // Timeout / size limit messages are ours; a component's own failure detail stays in the log.
203
+ const known = error instanceof ComponentPreviewTimeoutError || error instanceof ComponentPreviewLimitError;
204
+ if (!known)
205
+ logInternalError('component_preview_failed', error);
206
+ return jsonResponse(status, {
197
207
  success: false,
198
- error: error instanceof Error ? error.message : String(error),
208
+ error: known ? error.message : 'Component preview failed',
199
209
  });
200
210
  }
201
211
  finally {
@@ -280,77 +290,67 @@ export function registerAgentSessionRoutes(route, base, ctx) {
280
290
  }
281
291
  return { tree, release: () => lease.release() };
282
292
  };
283
- const unavailable = (response) => {
284
- writeJson(response, 503, {
293
+ const unavailable = (headers) => {
294
+ return jsonResponse(503, {
285
295
  success: false,
286
296
  error: 'Agent session tree runtime 未就绪(新 Runtime 需经 acquireAgentRuntime 装配 sessionTree)',
287
- });
297
+ }, headers);
288
298
  };
289
299
  // HttpHost 无 `:param` 路由,用前缀路由匹配 `<sessionKey>/tree|message|leaf`。
290
- route('GET', `${base}/agent/sessions/*`, async (_request, response, url) => {
300
+ route('GET', `${base}/agent/sessions/*`, async (_request, { url }) => {
291
301
  const messageAction = parseSessionAction(url.pathname, prefix, 'message');
292
- if (messageAction)
293
- response.setHeader('Cache-Control', 'no-store');
302
+ const headers = messageAction ? { 'cache-control': 'no-store' } : undefined;
303
+ const reply = (status, body) => jsonResponse(status, body, headers);
294
304
  const parsed = messageAction ?? parseSessionAction(url.pathname, prefix, 'tree');
295
305
  if (!parsed) {
296
- writeJson(response, 404, { success: false, error: 'Not found' });
297
- return;
306
+ return reply(404, { success: false, error: 'Not found' });
298
307
  }
299
308
  const messageId = Number(url.searchParams.get('messageId'));
300
309
  const expectedSessionId = url.searchParams.get('sessionId');
301
310
  if (messageAction && (!Number.isSafeInteger(messageId) || messageId < 1 || !expectedSessionId)) {
302
- writeJson(response, 400, { success: false, error: '需要 sessionId 和正安全整数 messageId' });
303
- return;
311
+ return reply(400, { success: false, error: '需要 sessionId 和正安全整数 messageId' });
304
312
  }
305
313
  const lease = acquireSessionTree();
306
314
  if (!lease) {
307
- unavailable(response);
308
- return;
315
+ return unavailable(headers);
309
316
  }
310
317
  const runtime = lease.tree;
311
318
  try {
312
319
  if (messageAction && !runtime.readBranchMessage) {
313
- writeJson(response, 501, { success: false, error: '当前 Agent runtime 不支持读取完整消息' });
314
- return;
320
+ return reply(501, { success: false, error: '当前 Agent runtime 不支持读取完整消息' });
315
321
  }
316
322
  const sessionId = await runtime.resolveActiveSessionId(parsed.sessionKey);
317
323
  if (messageAction) {
318
324
  if (!sessionId) {
319
- writeJson(response, 404, { success: false, error: '未找到活跃会话' });
320
- return;
325
+ return reply(404, { success: false, error: '未找到活跃会话' });
321
326
  }
322
327
  if (sessionId !== expectedSessionId) {
323
- writeJson(response, 409, { success: false, error: '会话已变化,请重新读取会话列表' });
324
- return;
328
+ return reply(409, { success: false, error: '会话已变化,请重新读取会话列表' });
325
329
  }
326
330
  const text = await runtime.readBranchMessage(sessionId, messageId);
327
331
  if (text === null) {
328
- writeJson(response, 404, { success: false, error: '未找到此会话的用户消息' });
329
- return;
332
+ return reply(404, { success: false, error: '未找到此会话的用户消息' });
330
333
  }
331
- writeJson(response, 200, { success: true, data: { messageId, text } });
332
- return;
334
+ return reply(200, { success: true, data: { messageId, text } });
333
335
  }
334
336
  if (!sessionId) {
335
337
  if (!isRuntimeImSessionKey(parsed.sessionKey)) {
336
- writeJson(response, 404, {
338
+ return reply(404, {
337
339
  success: false,
338
340
  error: `未找到会话:${parsed.sessionKey}`,
339
341
  });
340
- return;
341
342
  }
342
343
  const knownConversation = await ctx.isKnownConversationSession?.(parsed.sessionKey);
343
344
  if (knownConversation === false) {
344
- writeJson(response, 404, {
345
+ return reply(404, {
345
346
  success: false,
346
347
  error: `未找到会话:${parsed.sessionKey}`,
347
348
  });
348
- return;
349
349
  }
350
350
  // 渠道会话与 Agent 会话的生命周期不同:用户可以先在 Console
351
351
  // 选中一个真实渠道会话,而该会话尚未触发过 Agent。树查询是只读操作,
352
352
  // 因而将这种正常状态返回为可渲染空态,而不是误报资源不存在。
353
- writeJson(response, 200, {
353
+ return reply(200, {
354
354
  success: true,
355
355
  data: {
356
356
  state: 'not_started',
@@ -360,11 +360,10 @@ export function registerAgentSessionRoutes(route, base, ctx) {
360
360
  points: [],
361
361
  },
362
362
  });
363
- return;
364
363
  }
365
364
  const activeLeafMessageId = await runtime.getActiveLeafMessageId(sessionId);
366
365
  const points = await runtime.listBranchPoints(sessionId);
367
- writeJson(response, 200, {
366
+ return reply(200, {
368
367
  success: true,
369
368
  data: {
370
369
  state: 'active',
@@ -379,22 +378,21 @@ export function registerAgentSessionRoutes(route, base, ctx) {
379
378
  lease.release();
380
379
  }
381
380
  }, { summary: 'Get agent session tree or message', tags: ['console', 'agent'] });
382
- route('POST', `${base}/agent/sessions/*`, async (request, response, url, authScope) => {
381
+ route('POST', `${base}/agent/sessions/*`, async (request, { url }) => {
383
382
  const parsed = parseSessionAction(url.pathname, prefix, 'leaf');
384
383
  if (!parsed) {
385
- writeJson(response, 404, { success: false, error: 'Not found' });
386
- return;
384
+ return jsonResponse(404, { success: false, error: 'Not found' });
387
385
  }
388
- if (!requireWriteScope(response, ctx, authScope))
389
- return;
386
+ const denied = requireWriteScope(ctx);
387
+ if (denied)
388
+ return denied;
390
389
  let body;
391
390
  try {
392
391
  body = (await readJsonBody(request) ?? {});
393
392
  }
394
393
  catch (error) {
395
394
  if (error instanceof HttpBodyError) {
396
- writeJson(response, error.statusCode, { success: false, error: error.message });
397
- return;
395
+ return jsonResponse(error.statusCode, { success: false, error: error.message });
398
396
  }
399
397
  throw error;
400
398
  }
@@ -405,42 +403,37 @@ export function registerAgentSessionRoutes(route, base, ctx) {
405
403
  if (messageIdRaw != null && messageIdRaw !== '') {
406
404
  const value = Number(messageIdRaw);
407
405
  if (!Number.isFinite(value) || value < 1) {
408
- writeJson(response, 400, { success: false, error: 'messageId 须为正整数' });
409
- return;
406
+ return jsonResponse(400, { success: false, error: 'messageId 须为正整数' });
410
407
  }
411
408
  messageId = value;
412
409
  }
413
410
  else if (indexRaw != null && indexRaw !== '') {
414
411
  const value = Number(indexRaw);
415
412
  if (!Number.isFinite(value) || value < 1) {
416
- writeJson(response, 400, { success: false, error: 'index 须为正整数' });
417
- return;
413
+ return jsonResponse(400, { success: false, error: 'index 须为正整数' });
418
414
  }
419
415
  branchIndex = value;
420
416
  }
421
417
  else {
422
- writeJson(response, 400, { success: false, error: '需要 messageId 或 index 之一' });
423
- return;
418
+ return jsonResponse(400, { success: false, error: '需要 messageId 或 index 之一' });
424
419
  }
425
420
  const lease = acquireSessionTree();
426
421
  if (!lease) {
427
- unavailable(response);
428
- return;
422
+ return unavailable();
429
423
  }
430
424
  const runtime = lease.tree;
431
425
  try {
432
426
  const sessionId = await runtime.resolveActiveSessionId(parsed.sessionKey);
433
427
  if (!sessionId) {
434
- writeJson(response, 404, {
428
+ return jsonResponse(404, {
435
429
  success: false,
436
430
  error: `未找到活跃会话:${parsed.sessionKey}`,
437
431
  });
438
- return;
439
432
  }
440
433
  if (branchIndex !== undefined) {
441
434
  const result = await runtime.jumpToBranchIndex(sessionId, branchIndex);
442
435
  const activeLeafMessageId = await runtime.getActiveLeafMessageId(sessionId);
443
- writeJson(response, result.ok ? 200 : 400, {
436
+ return jsonResponse(result.ok ? 200 : 400, {
444
437
  success: result.ok,
445
438
  message: result.message,
446
439
  data: {
@@ -449,11 +442,10 @@ export function registerAgentSessionRoutes(route, base, ctx) {
449
442
  activeLeafMessageId,
450
443
  },
451
444
  });
452
- return;
453
445
  }
454
446
  const ok = await runtime.switchActiveLeaf(sessionId, messageId);
455
447
  const activeLeafMessageId = await runtime.getActiveLeafMessageId(sessionId);
456
- writeJson(response, ok ? 200 : 400, {
448
+ return jsonResponse(ok ? 200 : 400, {
457
449
  success: ok,
458
450
  message: ok ? `已切换 active leaf 至消息 #${messageId}` : '切换失败',
459
451
  data: {
@@ -531,15 +523,14 @@ export function parsePositiveInt(raw, fallback) {
531
523
  // ---------------------------------------------------------------------------
532
524
  // helpers
533
525
  // ---------------------------------------------------------------------------
534
- /** 写操作门禁:demo 部署(ctx.fullScope=false)或 demo token 均 403。 */
535
- export function requireWriteScope(response, ctx, authScope) {
536
- if (ctx.fullScope && authScope === 'full')
537
- return true;
538
- writeJson(response, 403, {
526
+ /** 写操作门禁:demo 部署(ctx.fullScope=false)返回 403 响应,放行时返回 undefined。 */
527
+ export function requireWriteScope(ctx) {
528
+ if (ctx.fullScope)
529
+ return undefined;
530
+ return jsonResponse(403, {
539
531
  success: false,
540
532
  error: 'Demo scope: 只读访问,写操作被拒绝',
541
533
  });
542
- return false;
543
534
  }
544
535
  export function decodePathParam(raw) {
545
536
  try {
@@ -560,11 +551,3 @@ export function normalizeBase(value) {
560
551
  return `/${value}`;
561
552
  return trimTrailingSlashes(value) || '/api';
562
553
  }
563
- export function writeJson(response, status, body) {
564
- const payload = JSON.stringify(body);
565
- response.writeHead(status, {
566
- 'content-type': 'application/json; charset=utf-8',
567
- 'content-length': Buffer.byteLength(payload),
568
- });
569
- response.end(payload);
570
- }