@robota-sdk/agent-transport 3.0.0-beta.78 → 3.0.0-beta.81

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 (111) hide show
  1. package/README.md +84 -26
  2. package/dist/browser/client.d.ts +2 -0
  3. package/dist/browser/client.js +1 -0
  4. package/dist/browser/index.d.ts +520 -0
  5. package/dist/browser/index.d.ts.map +1 -0
  6. package/dist/browser/index.js +2 -0
  7. package/dist/browser/index.js.map +1 -0
  8. package/dist/browser/message-decoders-BB8n-H2X.d.ts +252 -0
  9. package/dist/browser/message-decoders-BB8n-H2X.d.ts.map +1 -0
  10. package/dist/browser/message-decoders-CDmDoZPR.js +2 -0
  11. package/dist/browser/message-decoders-CDmDoZPR.js.map +1 -0
  12. package/dist/node/index.cjs +1 -1
  13. package/dist/node/index.d.cts +766 -0
  14. package/dist/node/index.d.cts.map +1 -0
  15. package/dist/node/index.d.ts +765 -27
  16. package/dist/node/index.d.ts.map +1 -1
  17. package/dist/node/index.js +1 -1
  18. package/dist/node/index.js.map +1 -1
  19. package/dist/node/node.cjs +1 -0
  20. package/dist/node/node.d.cts +109 -0
  21. package/dist/node/node.d.cts.map +1 -0
  22. package/dist/node/node.d.ts +109 -0
  23. package/dist/node/node.d.ts.map +1 -0
  24. package/dist/node/node.js +2 -0
  25. package/dist/node/node.js.map +1 -0
  26. package/package.json +68 -47
  27. package/src/__tests__/access-token-verifier.test.ts +672 -0
  28. package/src/__tests__/admission.test.ts +130 -0
  29. package/src/__tests__/channel-frames.test.ts +214 -0
  30. package/src/__tests__/driver-attribution-capability.test.ts +83 -0
  31. package/src/__tests__/handoff-chunking.test.ts +215 -0
  32. package/src/__tests__/handoff-manifest.test.ts +52 -0
  33. package/src/__tests__/message-decoders.test.ts +249 -0
  34. package/src/__tests__/outbound-delivery.test.ts +558 -0
  35. package/src/__tests__/peer-message-ledger.test.ts +200 -0
  36. package/src/__tests__/personal-usage-report.test.ts +193 -0
  37. package/src/__tests__/resume-buffer.test.ts +93 -0
  38. package/src/__tests__/session-event-delivery.test.ts +101 -0
  39. package/src/__tests__/session-message-handler-e5.test.ts +99 -0
  40. package/src/__tests__/session-message-handler.test.ts +590 -0
  41. package/src/__tests__/session-resume-bridge.test.ts +406 -0
  42. package/src/__tests__/submit-prompt-shape.test.ts +78 -0
  43. package/src/__tests__/usage-report-carrier.test.ts +98 -0
  44. package/src/__tests__/ws-broadcast-events.test.ts +84 -0
  45. package/src/background-messages.ts +169 -0
  46. package/src/channel-frames.ts +182 -0
  47. package/src/client.ts +9 -0
  48. package/src/handoff-chunking.ts +235 -0
  49. package/src/index.ts +112 -3
  50. package/src/message-decoders.ts +229 -0
  51. package/src/message-parser.ts +33 -0
  52. package/src/node/access-token-verifier.ts +363 -0
  53. package/src/node/admission.ts +153 -0
  54. package/src/node/handoff-manifest.ts +77 -0
  55. package/src/node/index.ts +10 -0
  56. package/src/outbound-delivery.ts +231 -0
  57. package/src/peer-message-ledger.ts +193 -0
  58. package/src/protocol-session.ts +28 -0
  59. package/src/resume-buffer.ts +123 -0
  60. package/src/session-events.ts +198 -0
  61. package/src/session-message-handler.ts +282 -0
  62. package/src/session-query-messages.ts +42 -0
  63. package/src/session-resume-bridge.ts +252 -0
  64. package/src/usage-messages.ts +195 -0
  65. package/src/wire-messages.ts +165 -0
  66. package/dist/node/headless/index.cjs +0 -1
  67. package/dist/node/headless/index.d.ts +0 -2
  68. package/dist/node/headless/index.js +0 -1
  69. package/dist/node/headless-DG_MEbf0.js +0 -19
  70. package/dist/node/headless-DG_MEbf0.js.map +0 -1
  71. package/dist/node/headless-xq77JBq0.cjs +0 -18
  72. package/dist/node/index-CrJ2Lhn3.d.ts +0 -96
  73. package/dist/node/index-CrJ2Lhn3.d.ts.map +0 -1
  74. package/dist/node/index-JIvzrtaA.d.ts +0 -52
  75. package/dist/node/index-JIvzrtaA.d.ts.map +0 -1
  76. package/dist/node/index-JTD_xtEQ.d.ts +0 -96
  77. package/dist/node/index-JTD_xtEQ.d.ts.map +0 -1
  78. package/dist/node/index-y_HBOfzO.d.ts +0 -52
  79. package/dist/node/index-y_HBOfzO.d.ts.map +0 -1
  80. package/dist/node/programmatic/index.cjs +0 -1
  81. package/dist/node/programmatic/index.d.ts +0 -2
  82. package/dist/node/programmatic/index.js +0 -1
  83. package/dist/node/programmatic-6wLf68qJ.cjs +0 -1
  84. package/dist/node/programmatic-70dxIXtS.js +0 -2
  85. package/dist/node/programmatic-70dxIXtS.js.map +0 -1
  86. package/dist/node/testing/index.cjs +0 -1
  87. package/dist/node/testing/index.d.ts +0 -2
  88. package/dist/node/testing/index.js +0 -1
  89. package/src/__tests__/programmatic/programmatic-ask-user.test.ts +0 -32
  90. package/src/__tests__/programmatic/programmatic-driver.test.ts +0 -107
  91. package/src/__tests__/programmatic/programmatic-flows.test.ts +0 -140
  92. package/src/__tests__/transport-registry.stop-all.test.ts +0 -53
  93. package/src/headless/HeadlessInteractionChannel.ts +0 -129
  94. package/src/headless/__tests__/headless-channel-options.test.ts +0 -106
  95. package/src/headless/__tests__/headless-goal-runner.test.ts +0 -108
  96. package/src/headless/__tests__/headless-provider-failure.integration.test.ts +0 -143
  97. package/src/headless/__tests__/headless-runner-initialization.test.ts +0 -45
  98. package/src/headless/__tests__/headless-runner.test.ts +0 -508
  99. package/src/headless/__tests__/headless-skill-activation.integration.test.ts +0 -430
  100. package/src/headless/__tests__/headless-transport.test.ts +0 -267
  101. package/src/headless/cli-input.ts +0 -50
  102. package/src/headless/headless-runner.ts +0 -230
  103. package/src/headless/headless-stream-json.ts +0 -148
  104. package/src/headless/headless-transport.ts +0 -43
  105. package/src/headless/index.ts +0 -8
  106. package/src/headless/print-terminal.ts +0 -57
  107. package/src/programmatic/ProgrammaticInteractionChannel.ts +0 -82
  108. package/src/programmatic/createProgrammaticAgent.ts +0 -71
  109. package/src/programmatic/index.ts +0 -5
  110. package/src/testing/index.ts +0 -10
  111. package/src/transport-registry.ts +0 -103
package/src/index.ts CHANGED
@@ -1,3 +1,112 @@
1
- export * from './headless/index.js';
2
- export * from './programmatic/index.js';
3
- export { TransportRegistry } from './transport-registry.js';
1
+ /**
2
+ * Browser-safe entry point for `@robota-sdk/agent-transport`.
3
+ *
4
+ * ## Why this file exists
5
+ *
6
+ * The package's `./node` entry owns `admission.ts` and `handoff-manifest.ts`, the only modules
7
+ * that import `node:crypto`. Keeping those modules out of this root prevents a browser consumer
8
+ * from pulling a Node builtin into its bundle.
9
+ *
10
+ * ## What is in, and why
11
+ *
12
+ * Everything in `src/index.ts` EXCEPT the two modules that touch `node:crypto`. Read off the actual
13
+ * import graph, not off intent:
14
+ *
15
+ * node:crypto reachers `admission.ts`, `handoff-manifest.ts` — and nothing inside the package
16
+ * imports either of them, so removing both from this barrel severs the
17
+ * builtin from this entry's graph entirely.
18
+ * everything else `session-message-handler` → {`background-messages`, `message-decoders`,
19
+ * `session-events`}, `session-resume-bridge` → {`outbound-delivery`,
20
+ * `resume-buffer`, `session-message-handler`, `session-events`}, `channel-frames`,
21
+ * `peer-message-ledger`, `handoff-chunking`,
22
+ * `protocol-session`, `wire-messages` — no builtin on any path.
23
+ *
24
+ * Handoff authority, offer refusal, and inventory decisions live in
25
+ * `agent-interface-session-mobility`, outside this wire package.
26
+ *
27
+ * The handoff MANIFEST types are omitted along with their functions. Keeping the `export type` lines
28
+ * would be free at runtime, but it would put `node/handoff-manifest.ts` back on this entry's import graph
29
+ * — and the `browser-package-node-subpath` scan that guards this file walks EVERY import edge rather
30
+ * than trying to tell a type edge from a value one. A check that has to be clever about which edges
31
+ * count is a check that can be fooled; a browser consumer that needs those types is asking for the
32
+ * Node surface and should say so by importing `./node`.
33
+ */
34
+
35
+ export { createSessionMessageHandler } from './session-message-handler.js';
36
+ export type { ISessionMessageHandlerOptions } from './session-message-handler.js';
37
+ // ARCH-030: the connection-scoped outbound delivery boundary every carrier builds and passes down.
38
+ export {
39
+ createOutboundDelivery,
40
+ createPendingStallClock,
41
+ isOverPendingBudget,
42
+ DEFAULT_MAX_PENDING_BYTES,
43
+ DEFAULT_MAX_PENDING_MS,
44
+ } from './outbound-delivery.js';
45
+ export type {
46
+ IPendingStallClock,
47
+ TDeliveryErrorHandler,
48
+ TOutboundDeliver,
49
+ } from './outbound-delivery.js';
50
+ export { PROTOCOL_SESSION_EVENT_CLASSIFICATION } from './session-events.js';
51
+ export type { TProtocolSessionEventClassification } from './session-events.js';
52
+ export type { IProtocolSession } from './protocol-session.js';
53
+ export type { TClientMessage, TServerMessage, TSeqServerMessage } from './wire-messages.js';
54
+ // Issue #2045: the owner-side runtime decoders; carriers implement `raw → decodeFrame → typed`.
55
+ export {
56
+ MAX_INBOUND_FRAME_BYTES,
57
+ decodeClientMessage,
58
+ decodeFrame,
59
+ decodeServerMessage,
60
+ } from './message-decoders.js';
61
+ export type { TMessageDecodeResult } from './message-decoders.js';
62
+ export { ResumeBuffer } from './resume-buffer.js';
63
+ export type { IResumeBufferOptions, IBufferedFrame, TResumeTail } from './resume-buffer.js';
64
+ // TRANS-001 — payload-agnostic channel frame codec (transport-neutral, body-opaque).
65
+ export {
66
+ CHANNEL_FRAME_MAGIC,
67
+ CHANNEL_FRAME_VERSION,
68
+ decodeChannelFrame,
69
+ encodeBinaryFrame,
70
+ encodeChannelEventFrame,
71
+ isChannelFrame,
72
+ } from './channel-frames.js';
73
+ export { SessionResumeBridge } from './session-resume-bridge.js';
74
+ export type {
75
+ ISessionResumeBridgeOptions,
76
+ TResumeSink,
77
+ IAttachOptions,
78
+ } from './session-resume-bridge.js';
79
+
80
+ // SEC-008 admission is NOT here: `src/node/admission.ts` mints and compares transport tokens with
81
+ // `node:crypto`. A browser never mints one — it presents one it was handed — so the exclusion costs
82
+ // the browser surface nothing it could have used.
83
+
84
+ // PEER-001 (#1809): the receiver's record of what it has already taken responsibility for. Lives
85
+ // here rather than in a carrier because duplicate, retry and gap are questions about what was SEEN
86
+ // BEFORE, and no socket or frame codec has the memory to answer them.
87
+ export {
88
+ acknowledgePeerMessage,
89
+ admitPeerMessage,
90
+ createPeerMessageLedger,
91
+ forgetPeerOrigin,
92
+ } from './peer-message-ledger.js';
93
+ export type {
94
+ IPeerMessageLedger,
95
+ IPeerMessageRejection,
96
+ IPeerMessageVerdict,
97
+ } from './peer-message-ledger.js';
98
+
99
+ // The handoff integrity helpers (`sealHandoffRecord`, `verifyHandoffPayload`) are NOT
100
+ // here: it hashes with `node:crypto`. Chunking is, because it never hashes anything.
101
+ export {
102
+ chunkCountFor,
103
+ chunkHandoffPayload,
104
+ DEFAULT_MAX_CHUNK_BYTES,
105
+ HandoffChunkAssembler,
106
+ } from './handoff-chunking.js';
107
+ export type {
108
+ IChunkResult,
109
+ IHandoffChunk,
110
+ TChunkOutcome,
111
+ TChunkRejection,
112
+ } from './handoff-chunking.js';
@@ -0,0 +1,229 @@
1
+ /**
2
+ * Owner-side runtime decoders for the wire message unions (issue #2045 / TRANS-005 adjacent).
3
+ *
4
+ * `wire-messages.ts` owns the TypeScript unions; this file owns turning `JSON.parse` output into one
5
+ * of them. Before it, the server did `JSON.parse(data) as TClientMessage` and each client did the
6
+ * same for `TServerMessage`, so valid JSON with an invalid shape crossed the boundary wearing a
7
+ * protocol type. Carriers now implement `raw string → owner decoder → typed message` and answer a
8
+ * refusal with the protocol's own `protocol_error` (server side) or client error (client side).
9
+ *
10
+ * `decodeClientMessage` is TOTAL: every variant and every nested field it carries is validated.
11
+ * `decodeServerMessage` validates every variant's discriminator and declared top-level fields; a
12
+ * payload OWNED BY ANOTHER PACKAGE (`IToolState`, `IExecutionResult`, background events, …) is
13
+ * checked as a non-array record (or array of records) here — its own total decoding belongs to its
14
+ * owner and is tracked there, not duplicated in a transport package.
15
+ */
16
+
17
+ import type { TBackgroundControlAction, TClientMessage, TServerMessage } from './wire-messages.js';
18
+
19
+ export type TMessageDecodeResult<TMessage> =
20
+ | { readonly ok: true; readonly message: TMessage }
21
+ | { readonly ok: false; readonly reason: string };
22
+
23
+ /** Serialized frames larger than this are refused before any parse (a bounded inbound budget). */
24
+ export const MAX_INBOUND_FRAME_BYTES = 4 * 1024 * 1024;
25
+
26
+ type TRecord = Record<string, unknown>;
27
+
28
+ function isRecord(value: unknown): value is TRecord {
29
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
30
+ }
31
+ const isString = (v: unknown): v is string => typeof v === 'string';
32
+ const isNonEmptyString = (v: unknown): v is string => typeof v === 'string' && v.length > 0;
33
+ const isBoolean = (v: unknown): v is boolean => typeof v === 'boolean';
34
+ const isFiniteNumber = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v);
35
+ const isOptional =
36
+ (check: (v: unknown) => boolean) =>
37
+ (v: unknown): boolean =>
38
+ v === undefined || check(v);
39
+ const isRecordArray = (v: unknown): boolean => Array.isArray(v) && v.every(isRecord);
40
+ const isNullableRecord = (v: unknown): boolean => v === null || isRecord(v);
41
+ const oneOf =
42
+ (members: readonly string[]) =>
43
+ (v: unknown): boolean =>
44
+ typeof v === 'string' && members.includes(v);
45
+
46
+ type TFieldCheck = (value: unknown) => boolean;
47
+ type TVariantShape = Readonly<Record<string, TFieldCheck>>;
48
+
49
+ const BACKGROUND_TASK_KINDS = ['agent', 'process', 'scheduled'] as const;
50
+ const BACKGROUND_TASK_MODES = ['foreground', 'background'] as const;
51
+ const BACKGROUND_TASK_STATUSES = [
52
+ 'queued',
53
+ 'running',
54
+ 'waiting_permission',
55
+ 'sleeping',
56
+ 'paused',
57
+ 'completed',
58
+ 'failed',
59
+ 'cancelled',
60
+ ] as const;
61
+ const BACKGROUND_CONTROL_ACTIONS: readonly TBackgroundControlAction[] = ['cancel', 'close', 'send'];
62
+
63
+ const isBackgroundTaskListFilter: TFieldCheck = (v) =>
64
+ isRecord(v) &&
65
+ isOptional(oneOf(BACKGROUND_TASK_KINDS))(v['kind']) &&
66
+ isOptional(oneOf(BACKGROUND_TASK_STATUSES))(v['status']) &&
67
+ isOptional(oneOf(BACKGROUND_TASK_MODES))(v['mode']) &&
68
+ isOptional(isBoolean)(v['includeClosed']);
69
+ const isBackgroundTaskInput: TFieldCheck = (v) =>
70
+ isRecord(v) && isOptional(isString)(v['prompt']) && isOptional(isString)(v['stdin']);
71
+ const isBackgroundTaskLogCursor: TFieldCheck = (v) => isRecord(v) && isFiniteNumber(v['offset']);
72
+ const isPermissionResultValue: TFieldCheck = (v) =>
73
+ isBoolean(v) || v === 'allow-session' || v === 'allow-project';
74
+ const isActionResponse: TFieldCheck = (v) =>
75
+ isRecord(v) &&
76
+ ((v['type'] === 'answer' &&
77
+ Array.isArray(v['values']) &&
78
+ v['values'].every(isString) &&
79
+ isOptional(isString)(v['text'])) ||
80
+ (v['type'] === 'cancelled' && v['values'] === undefined && v['text'] === undefined));
81
+
82
+ /** One entry per `TClientMessage` variant — a variant added to the union without one fails the test. */
83
+ export const CLIENT_MESSAGE_SHAPES: Readonly<Record<TClientMessage['type'], TVariantShape>> = {
84
+ submit: { prompt: isNonEmptyString },
85
+ command: { name: isNonEmptyString, args: isOptional(isString) },
86
+ abort: {},
87
+ 'cancel-queue': {},
88
+ 'get-messages': {},
89
+ 'get-context': {},
90
+ 'get-usage-report': {},
91
+ 'get-personal-usage-report': {
92
+ requestId: isNonEmptyString,
93
+ period: oneOf(['7d', '30d']),
94
+ timezone: isNonEmptyString,
95
+ },
96
+ 'get-stored-session-usage-report': {
97
+ requestId: isNonEmptyString,
98
+ sessionId: isNonEmptyString,
99
+ },
100
+ 'get-executing': {},
101
+ 'get-pending': {},
102
+ 'get-execution-workspace': {},
103
+ 'get-background-tasks': { filter: isOptional(isBackgroundTaskListFilter) },
104
+ 'get-background-task': { taskId: isNonEmptyString },
105
+ 'get-background-job-groups': {},
106
+ 'get-background-job-group': { groupId: isNonEmptyString },
107
+ 'wait-background-job-group': { groupId: isNonEmptyString },
108
+ 'cancel-background-task': { taskId: isNonEmptyString, reason: isOptional(isString) },
109
+ 'close-background-task': { taskId: isNonEmptyString },
110
+ 'send-background-task': { taskId: isNonEmptyString, input: isBackgroundTaskInput },
111
+ 'read-background-task-log': {
112
+ taskId: isNonEmptyString,
113
+ cursor: isOptional(isBackgroundTaskLogCursor),
114
+ },
115
+ 'permission-response': { id: isNonEmptyString, result: isPermissionResultValue },
116
+ 'ask-response': { id: isNonEmptyString, response: isActionResponse },
117
+ resume: { lastSeq: isFiniteNumber },
118
+ ack: { seq: isFiniteNumber },
119
+ };
120
+
121
+ const authored: TVariantShape = { driverId: isOptional(isString) };
122
+
123
+ /** One entry per `TServerMessage` variant. Foreign-owned payloads are checked as records (see header). */
124
+ export const SERVER_MESSAGE_SHAPES: Readonly<Record<TServerMessage['type'], TVariantShape>> = {
125
+ text_delta: { delta: isString, ...authored },
126
+ user_message: { content: isString, ...authored },
127
+ tool_start: { state: isRecord, ...authored },
128
+ tool_end: { state: isRecord, ...authored },
129
+ thinking: { isThinking: isBoolean, ...authored },
130
+ complete: { result: isRecord, ...authored },
131
+ interrupted: { result: isRecord, ...authored },
132
+ error: { message: isString, ...authored },
133
+ command_result: { name: isString, message: isString, success: isBoolean },
134
+ messages: { messages: isRecordArray },
135
+ context: { state: isRecord },
136
+ usage_report: { report: isRecord },
137
+ personal_usage_report: { requestId: isNonEmptyString, report: isRecord },
138
+ personal_usage_report_error: {
139
+ requestId: isNonEmptyString,
140
+ code: oneOf(['not_available', 'report_failed']),
141
+ message: isString,
142
+ },
143
+ stored_session_usage_report: {
144
+ requestId: isNonEmptyString,
145
+ sessionId: isNonEmptyString,
146
+ report: isRecord,
147
+ },
148
+ stored_session_usage_report_error: {
149
+ requestId: isNonEmptyString,
150
+ sessionId: isNonEmptyString,
151
+ code: oneOf(['not_available', 'report_failed']),
152
+ message: isString,
153
+ },
154
+ executing: { executing: isBoolean },
155
+ pending: { pending: (v) => v === null || isString(v) },
156
+ execution_workspace_event: { snapshot: isRecord },
157
+ background_task_event: { event: isRecord },
158
+ background_job_group_event: { event: isRecord },
159
+ plan_event: { event: isRecord },
160
+ context_file_refreshed: { event: isRecord },
161
+ branch_event: { event: isRecord },
162
+ background_tasks: { tasks: isRecordArray },
163
+ background_task: { taskId: isString, task: isNullableRecord },
164
+ background_job_groups: { groups: isRecordArray },
165
+ background_job_group: { groupId: isString, group: isNullableRecord },
166
+ background_task_log: { taskId: isString, page: isRecord },
167
+ permission_request: { event: isRecord },
168
+ ask_request: { event: isRecord },
169
+ prompt_resolved: { event: isRecord },
170
+ ui_intent: { event: isRecord },
171
+ session_renamed: { event: isRecord },
172
+ history_cleared: {},
173
+ background_task_control_result: {
174
+ action: oneOf(BACKGROUND_CONTROL_ACTIONS),
175
+ taskId: isString,
176
+ success: isBoolean,
177
+ message: isOptional(isString),
178
+ },
179
+ protocol_error: { message: isString },
180
+ resume_gap: {},
181
+ };
182
+
183
+ function decodeVariant<TMessage extends { type: string }>(
184
+ value: unknown,
185
+ shapes: Readonly<Record<string, TVariantShape>>,
186
+ reasonFor: (field: string) => string,
187
+ ): TMessageDecodeResult<TMessage> {
188
+ if (!isRecord(value)) return { ok: false, reason: 'message must be a JSON object' };
189
+ const type = value['type'];
190
+ if (!isString(type)) return { ok: false, reason: 'message type must be a string' };
191
+ const shape = Object.prototype.hasOwnProperty.call(shapes, type) ? shapes[type] : undefined;
192
+ if (shape === undefined) return { ok: false, reason: `Unknown message type: ${type}` };
193
+ for (const [field, check] of Object.entries(shape)) {
194
+ if (!check(value[field])) return { ok: false, reason: reasonFor(field) };
195
+ }
196
+ return { ok: true, message: value as unknown as TMessage };
197
+ }
198
+
199
+ /** Total decode of a parsed client frame. */
200
+ export function decodeClientMessage(value: unknown): TMessageDecodeResult<TClientMessage> {
201
+ return decodeVariant<TClientMessage>(value, CLIENT_MESSAGE_SHAPES, (field) =>
202
+ field === 'prompt' ? 'prompt must be a non-empty string' : `${field} has an invalid shape`,
203
+ );
204
+ }
205
+
206
+ /** Variant-level decode of a parsed server frame (foreign-owned payloads as records, see header). */
207
+ export function decodeServerMessage(value: unknown): TMessageDecodeResult<TServerMessage> {
208
+ return decodeVariant<TServerMessage>(
209
+ value,
210
+ SERVER_MESSAGE_SHAPES,
211
+ (field) => `${field} has an invalid shape`,
212
+ );
213
+ }
214
+
215
+ /** `raw string → JSON → owner decoder`, the one path a carrier implements. */
216
+ export function decodeFrame<TMessage>(
217
+ data: string,
218
+ decode: (value: unknown) => TMessageDecodeResult<TMessage>,
219
+ ): TMessageDecodeResult<TMessage> {
220
+ if (data.length > MAX_INBOUND_FRAME_BYTES) return { ok: false, reason: 'Frame too large' };
221
+ let parsed: unknown;
222
+ try {
223
+ parsed = JSON.parse(data);
224
+ // allow-fallback: the parse failure is returned as a typed refusal the carrier answers on the wire
225
+ } catch {
226
+ return { ok: false, reason: 'Invalid JSON' };
227
+ }
228
+ return decode(parsed);
229
+ }
@@ -0,0 +1,33 @@
1
+ import { decodeClientMessage, decodeFrame } from './message-decoders.js';
2
+ import { malformedUsageRequest } from './usage-messages.js';
3
+
4
+ import type { TOutboundDeliver } from './outbound-delivery.js';
5
+ import type { TClientMessage } from './wire-messages.js';
6
+
7
+ /** Decode a client frame and preserve validated usage-request correlation on malformed payloads. */
8
+ export function parseClientMessage(data: string, deliver: TOutboundDeliver): TClientMessage | null {
9
+ const decoded = decodeFrame(data, decodeClientMessage);
10
+ if (decoded.ok) return decoded.message;
11
+ const malformedUsage = malformedUsageRequest(data);
12
+ if (malformedUsage?.type === 'personal') {
13
+ deliver({
14
+ type: 'personal_usage_report_error',
15
+ requestId: malformedUsage.requestId,
16
+ code: 'report_failed',
17
+ message: 'Invalid personal usage report request.',
18
+ });
19
+ return null;
20
+ }
21
+ if (malformedUsage?.type === 'stored-session') {
22
+ deliver({
23
+ type: 'stored_session_usage_report_error',
24
+ requestId: malformedUsage.requestId,
25
+ sessionId: malformedUsage.sessionId,
26
+ code: 'report_failed',
27
+ message: 'Invalid stored-session usage report request.',
28
+ });
29
+ return null;
30
+ }
31
+ deliver({ type: 'protocol_error', message: decoded.reason });
32
+ return null;
33
+ }