@xmoxmo/bncr 0.3.6 → 0.3.7

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 (164) hide show
  1. package/README.md +5 -0
  2. package/dist/index.js +28 -5
  3. package/index.ts +55 -721
  4. package/package.json +8 -4
  5. package/scripts/check-pack.mjs +93 -18
  6. package/scripts/check-register-drift.mjs +35 -13
  7. package/scripts/selfcheck.mjs +80 -11
  8. package/src/bootstrap/channel-plugin-runtime.ts +81 -0
  9. package/src/bootstrap/cli.ts +97 -0
  10. package/src/bootstrap/register-runtime-gateway.ts +129 -0
  11. package/src/bootstrap/register-runtime-helpers.ts +140 -0
  12. package/src/bootstrap/register-runtime-singleton.ts +137 -0
  13. package/src/bootstrap/register-runtime.ts +201 -0
  14. package/src/bootstrap/runtime-discovery.ts +187 -0
  15. package/src/bootstrap/runtime-loader.ts +54 -0
  16. package/src/channel.ts +1590 -4967
  17. package/src/core/accounts.ts +23 -4
  18. package/src/core/dead-letter-diagnostics.ts +37 -5
  19. package/src/core/diagnostics.ts +31 -15
  20. package/src/core/downlink-health.ts +3 -11
  21. package/src/core/extended-diagnostics.ts +78 -36
  22. package/src/core/file-transfer-payloads.ts +1 -1
  23. package/src/core/logging.ts +1 -0
  24. package/src/core/outbox-enqueue.ts +13 -2
  25. package/src/core/outbox-entry-builders.ts +2 -0
  26. package/src/core/outbox-summary.ts +75 -3
  27. package/src/core/permissions.ts +15 -2
  28. package/src/core/persisted-outbox-entry.ts +21 -6
  29. package/src/core/policy.ts +45 -4
  30. package/src/core/probe.ts +3 -15
  31. package/src/core/register-trace.ts +3 -3
  32. package/src/core/status.ts +43 -4
  33. package/src/core/targets.ts +216 -205
  34. package/src/core/types.ts +221 -0
  35. package/src/core/value-sanitize.ts +29 -0
  36. package/src/messaging/inbound/commands.ts +147 -172
  37. package/src/messaging/inbound/context-facts.ts +4 -2
  38. package/src/messaging/inbound/contracts.ts +70 -0
  39. package/src/messaging/inbound/dispatch-prep.ts +303 -0
  40. package/src/messaging/inbound/dispatch.ts +49 -462
  41. package/src/messaging/inbound/gate.ts +18 -5
  42. package/src/messaging/inbound/last-route.ts +10 -4
  43. package/src/messaging/inbound/media-url-download.ts +109 -0
  44. package/src/messaging/inbound/native-command-runtime.ts +225 -0
  45. package/src/messaging/inbound/parse.ts +2 -1
  46. package/src/messaging/inbound/remote-media.ts +49 -0
  47. package/src/messaging/inbound/reply-config.ts +16 -4
  48. package/src/messaging/inbound/reply-dispatch.ts +162 -0
  49. package/src/messaging/inbound/runtime-compat.ts +31 -10
  50. package/src/messaging/inbound/session-label.ts +15 -7
  51. package/src/messaging/inbound/turn-context.ts +131 -0
  52. package/src/messaging/outbound/actions.ts +24 -10
  53. package/src/messaging/outbound/diagnostics-debug-builders.ts +365 -0
  54. package/src/messaging/outbound/diagnostics.ts +31 -355
  55. package/src/messaging/outbound/durable-message-adapter.ts +20 -16
  56. package/src/messaging/outbound/durable-queue-adapter.ts +20 -7
  57. package/src/messaging/outbound/media.ts +24 -13
  58. package/src/messaging/outbound/reply-enqueue-media.ts +181 -0
  59. package/src/messaging/outbound/reply-enqueue.ts +46 -155
  60. package/src/messaging/outbound/send-params.ts +3 -0
  61. package/src/messaging/outbound/send.ts +19 -10
  62. package/src/messaging/outbound/session-route.ts +18 -3
  63. package/src/openclaw/channel-runtime-contracts.ts +76 -0
  64. package/src/openclaw/config-runtime.ts +13 -7
  65. package/src/openclaw/inbound-session-runtime.ts +7 -3
  66. package/src/openclaw/ingress-runtime.ts +17 -27
  67. package/src/openclaw/reply-runtime.ts +54 -59
  68. package/src/openclaw/routing-runtime.ts +35 -18
  69. package/src/openclaw/runtime-surface.ts +156 -12
  70. package/src/openclaw/sdk-helpers.ts +8 -1
  71. package/src/openclaw/session-route-runtime.ts +12 -12
  72. package/src/plugin/ack-outbox-runtime-group.ts +264 -0
  73. package/src/plugin/bridge-ack-facade.ts +137 -0
  74. package/src/plugin/bridge-connection-facade.ts +111 -0
  75. package/src/plugin/bridge-diagnostics-facade.ts +23 -0
  76. package/src/plugin/bridge-drain-facade.ts +98 -0
  77. package/src/plugin/bridge-extended-diagnostics-facade.ts +149 -0
  78. package/src/plugin/bridge-file-transfer-push-facade.ts +140 -0
  79. package/src/plugin/bridge-lifecycle.ts +156 -0
  80. package/src/plugin/bridge-media-facade.ts +241 -0
  81. package/src/plugin/bridge-outbox-facade.ts +182 -0
  82. package/src/plugin/bridge-runtime-helpers.ts +266 -0
  83. package/src/plugin/bridge-runtime-snapshots.ts +104 -0
  84. package/src/plugin/bridge-runtime-surface-facade.ts +8 -0
  85. package/src/plugin/bridge-status-facade.ts +76 -0
  86. package/src/plugin/bridge-status-worker-facade.ts +72 -0
  87. package/src/plugin/bridge-support-runtime.ts +137 -0
  88. package/src/plugin/bridge-surface-handlers-group.ts +242 -0
  89. package/src/plugin/bridge-surface-helpers.ts +28 -0
  90. package/src/plugin/capabilities.ts +1 -3
  91. package/src/plugin/channel-components.ts +289 -0
  92. package/src/plugin/channel-inbound-helpers.ts +149 -0
  93. package/src/plugin/channel-plugin-bridge-group.ts +129 -0
  94. package/src/plugin/channel-plugin-surface-group.ts +202 -0
  95. package/src/plugin/channel-runtime-builders-delivery.ts +513 -0
  96. package/src/plugin/channel-runtime-builders-status.ts +331 -0
  97. package/src/plugin/channel-runtime-builders.ts +25 -0
  98. package/src/plugin/channel-runtime-constants.ts +40 -0
  99. package/src/plugin/channel-runtime-types.ts +146 -0
  100. package/src/plugin/channel-send-runtime-group.ts +37 -0
  101. package/src/plugin/channel-send.ts +226 -0
  102. package/src/plugin/channel-utils.ts +102 -0
  103. package/src/plugin/config.ts +24 -3
  104. package/src/plugin/connection-handlers-helpers.ts +254 -0
  105. package/src/plugin/connection-handlers.ts +440 -0
  106. package/src/plugin/connection-state-helpers.ts +159 -0
  107. package/src/plugin/connection-state-runtime-group.ts +51 -0
  108. package/src/plugin/connection-state.ts +527 -0
  109. package/src/plugin/diagnostics-handlers.ts +211 -0
  110. package/src/plugin/error-message.ts +15 -0
  111. package/src/plugin/file-ack-runtime.ts +284 -0
  112. package/src/plugin/file-inbound-abort.ts +112 -0
  113. package/src/plugin/file-inbound-chunk.ts +146 -0
  114. package/src/plugin/file-inbound-complete.ts +153 -0
  115. package/src/plugin/file-inbound-handlers.ts +19 -0
  116. package/src/plugin/file-inbound-init.ts +122 -0
  117. package/src/plugin/file-inbound-runtime.ts +51 -0
  118. package/src/plugin/file-inbound-state.ts +62 -0
  119. package/src/plugin/file-transfer-logs.ts +227 -0
  120. package/src/plugin/file-transfer-orchestrator-chunk.ts +135 -0
  121. package/src/plugin/file-transfer-orchestrator.ts +304 -0
  122. package/src/plugin/file-transfer-runtime-group.ts +102 -0
  123. package/src/plugin/file-transfer-send.ts +89 -0
  124. package/src/plugin/file-transfer-setup.ts +206 -0
  125. package/src/plugin/gateway-event-context.ts +41 -0
  126. package/src/plugin/gateway-runtime.ts +14 -4
  127. package/src/plugin/inbound-acceptance.ts +107 -0
  128. package/src/plugin/inbound-handlers.ts +248 -0
  129. package/src/plugin/inbound-surface-handlers-group.ts +152 -0
  130. package/src/plugin/media-dedupe-runtime.ts +90 -0
  131. package/src/plugin/media-orchestrators-runtime-group.ts +316 -0
  132. package/src/plugin/message-ack-runtime.ts +284 -0
  133. package/src/plugin/message-send.ts +16 -6
  134. package/src/plugin/messaging.ts +98 -36
  135. package/src/plugin/outbound.ts +50 -8
  136. package/src/plugin/outbox-ack-logs.ts +136 -0
  137. package/src/plugin/outbox-ack-outcome.ts +128 -0
  138. package/src/plugin/outbox-drain-ack.ts +145 -0
  139. package/src/plugin/outbox-drain-failure.ts +84 -0
  140. package/src/plugin/outbox-drain-loop.ts +554 -0
  141. package/src/plugin/outbox-drain-post-push.ts +159 -0
  142. package/src/plugin/outbox-drain-runtime.ts +141 -0
  143. package/src/plugin/outbox-drain-schedule.ts +116 -0
  144. package/src/plugin/outbox-file-push-flow.ts +69 -0
  145. package/src/plugin/outbox-push-route-runtime-group.ts +81 -0
  146. package/src/plugin/outbox-push.ts +267 -0
  147. package/src/plugin/outbox-route.ts +181 -0
  148. package/src/plugin/outbox-text-push-flow.ts +90 -0
  149. package/src/plugin/runtime-diagnostics-assembler.ts +183 -0
  150. package/src/plugin/runtime-diagnostics-helpers.ts +302 -0
  151. package/src/plugin/runtime-diagnostics-payload-builders.ts +171 -0
  152. package/src/plugin/runtime-diagnostics-snapshot.ts +31 -0
  153. package/src/plugin/setup.ts +33 -6
  154. package/src/plugin/state-store.ts +249 -0
  155. package/src/plugin/state-transient-runtime-group.ts +105 -0
  156. package/src/plugin/status-runtime.ts +251 -0
  157. package/src/plugin/status.ts +33 -7
  158. package/src/plugin/target-runtime.ts +141 -0
  159. package/src/plugin/target-status-runtime-group.ts +130 -0
  160. package/src/plugin/transient-state-runtime.ts +82 -0
  161. package/src/runtime/outbound-ack-timeout.ts +5 -3
  162. package/src/runtime/outbound-flags.ts +24 -8
  163. package/src/runtime/status-snapshots.ts +36 -7
  164. package/src/runtime/status-worker.ts +34 -4
@@ -0,0 +1,227 @@
1
+ import type { BncrRoute } from '../core/types.ts';
2
+
3
+ export type BncrFileTransferLogsRuntime = {
4
+ bridgeId: string;
5
+ now: () => number;
6
+ logInfo: (scope: string, message: string, options?: { debugOnly?: boolean }) => void;
7
+ logWarn: (scope: string, message: string, options?: { debugOnly?: boolean }) => void;
8
+ };
9
+
10
+ export function createBncrFileTransferLogs(runtime: BncrFileTransferLogsRuntime) {
11
+ const logFileChunkDiag = (args: {
12
+ accountId: string;
13
+ sessionKey: string;
14
+ mediaUrl: string;
15
+ hasGatewayContext: boolean;
16
+ activeConnectionKey?: string | null;
17
+ ownerConnId?: string;
18
+ ownerClientId?: string;
19
+ directConnIds: Iterable<string>;
20
+ recentInboundReachable: boolean;
21
+ recentConnIds: Iterable<string>;
22
+ accountConnections: Array<{
23
+ connId: string;
24
+ clientId?: string;
25
+ connectedAt: number;
26
+ lastSeenAt: number;
27
+ [key: string]: unknown;
28
+ }>;
29
+ }) => {
30
+ runtime.logInfo(
31
+ 'file-chunk-diag',
32
+ JSON.stringify({
33
+ bridge: runtime.bridgeId,
34
+ accountId: args.accountId,
35
+ sessionKey: args.sessionKey,
36
+ mediaUrl: args.mediaUrl,
37
+ hasGatewayContext: args.hasGatewayContext,
38
+ activeConnectionKey: args.activeConnectionKey || null,
39
+ ownerConnId: args.ownerConnId || null,
40
+ ownerClientId: args.ownerClientId || null,
41
+ directConnIds: Array.from(args.directConnIds),
42
+ recentInboundReachable: args.recentInboundReachable,
43
+ recentConnIds: Array.from(args.recentConnIds),
44
+ accountConnections: args.accountConnections,
45
+ }),
46
+ { debugOnly: true },
47
+ );
48
+ };
49
+
50
+ const logFileTransferStart = (args: {
51
+ transferId: string;
52
+ accountId: string;
53
+ sessionKey: string;
54
+ mediaUrl: string;
55
+ fileName: string;
56
+ mimeType?: string;
57
+ fileSize: number;
58
+ chunkSize: number;
59
+ totalChunks: number;
60
+ connIds: Iterable<string>;
61
+ ownerConnId?: string;
62
+ ownerClientId?: string;
63
+ }) => {
64
+ runtime.logInfo(
65
+ 'file-transfer-start',
66
+ JSON.stringify({
67
+ bridge: runtime.bridgeId,
68
+ transferId: args.transferId,
69
+ accountId: args.accountId,
70
+ sessionKey: args.sessionKey,
71
+ mediaUrl: args.mediaUrl,
72
+ fileName: args.fileName,
73
+ mimeType: args.mimeType,
74
+ fileSize: args.fileSize,
75
+ chunkSize: args.chunkSize,
76
+ totalChunks: args.totalChunks,
77
+ connIds: Array.from(args.connIds),
78
+ ownerConnId: args.ownerConnId || null,
79
+ ownerClientId: args.ownerClientId || null,
80
+ }),
81
+ { debugOnly: true },
82
+ );
83
+ };
84
+
85
+ const logFileTransferChunkSend = (args: {
86
+ transferId: string;
87
+ accountId: string;
88
+ chunkIndex: number;
89
+ attempt: number;
90
+ offset: number;
91
+ size: number;
92
+ connIds: Iterable<string>;
93
+ }) => {
94
+ runtime.logInfo(
95
+ 'file-transfer-chunk-send',
96
+ JSON.stringify({
97
+ bridge: runtime.bridgeId,
98
+ transferId: args.transferId,
99
+ accountId: args.accountId,
100
+ chunkIndex: args.chunkIndex,
101
+ attempt: args.attempt,
102
+ offset: args.offset,
103
+ size: args.size,
104
+ connIds: Array.from(args.connIds),
105
+ }),
106
+ { debugOnly: true },
107
+ );
108
+ };
109
+
110
+ const logFileTransferChunkAck = (args: {
111
+ transferId: string;
112
+ accountId: string;
113
+ chunkIndex: number;
114
+ attempt: number;
115
+ }) => {
116
+ runtime.logInfo(
117
+ 'file-transfer-chunk-ack',
118
+ JSON.stringify({
119
+ bridge: runtime.bridgeId,
120
+ transferId: args.transferId,
121
+ accountId: args.accountId,
122
+ chunkIndex: args.chunkIndex,
123
+ attempt: args.attempt,
124
+ }),
125
+ { debugOnly: true },
126
+ );
127
+ };
128
+
129
+ const logFileTransferChunkAckFail = (args: {
130
+ transferId: string;
131
+ accountId: string;
132
+ chunkIndex: number;
133
+ attempt: number;
134
+ error: unknown;
135
+ }) => {
136
+ runtime.logWarn(
137
+ 'file-transfer-chunk-ack-fail',
138
+ JSON.stringify({
139
+ bridge: runtime.bridgeId,
140
+ transferId: args.transferId,
141
+ accountId: args.accountId,
142
+ chunkIndex: args.chunkIndex,
143
+ attempt: args.attempt,
144
+ error: String((args.error as Error)?.message || args.error || ''),
145
+ }),
146
+ { debugOnly: true },
147
+ );
148
+ };
149
+
150
+ const logFileTransferCompleteSend = (args: {
151
+ transferId: string;
152
+ accountId: string;
153
+ connIds: Iterable<string>;
154
+ }) => {
155
+ runtime.logInfo(
156
+ 'file-transfer-complete-send',
157
+ JSON.stringify({
158
+ bridge: runtime.bridgeId,
159
+ transferId: args.transferId,
160
+ accountId: args.accountId,
161
+ connIds: Array.from(args.connIds),
162
+ }),
163
+ { debugOnly: true },
164
+ );
165
+ };
166
+
167
+ const logFileTransferCompleteAck = (args: {
168
+ transferId: string;
169
+ accountId: string;
170
+ payload: { path: string };
171
+ }) => {
172
+ runtime.logInfo(
173
+ 'file-transfer-complete-ack',
174
+ JSON.stringify({
175
+ bridge: runtime.bridgeId,
176
+ transferId: args.transferId,
177
+ accountId: args.accountId,
178
+ payload: args.payload,
179
+ }),
180
+ { debugOnly: true },
181
+ );
182
+ };
183
+
184
+ const buildInitialFileSendTransferState = (args: {
185
+ transferId: string;
186
+ accountId: string;
187
+ sessionKey: string;
188
+ route: BncrRoute;
189
+ fileName: string;
190
+ mimeType?: string;
191
+ fileSize: number;
192
+ chunkSize: number;
193
+ totalChunks: number;
194
+ fileSha256: string;
195
+ ownerConnId?: string;
196
+ ownerClientId?: string;
197
+ normalizeAccountId: (accountId: string) => string;
198
+ }) => ({
199
+ transferId: args.transferId,
200
+ accountId: args.normalizeAccountId(args.accountId),
201
+ sessionKey: args.sessionKey,
202
+ route: args.route,
203
+ fileName: args.fileName,
204
+ mimeType: args.mimeType || 'application/octet-stream',
205
+ fileSize: args.fileSize,
206
+ chunkSize: args.chunkSize,
207
+ totalChunks: args.totalChunks,
208
+ fileSha256: args.fileSha256,
209
+ startedAt: runtime.now(),
210
+ status: 'init',
211
+ ownerConnId: args.ownerConnId,
212
+ ownerClientId: args.ownerClientId,
213
+ ackedChunks: new Set<number>(),
214
+ failedChunks: new Map<number, string>(),
215
+ });
216
+
217
+ return {
218
+ logFileChunkDiag,
219
+ logFileTransferStart,
220
+ logFileTransferChunkSend,
221
+ logFileTransferChunkAck,
222
+ logFileTransferChunkAckFail,
223
+ logFileTransferCompleteSend,
224
+ logFileTransferCompleteAck,
225
+ buildInitialFileSendTransferState,
226
+ };
227
+ }
@@ -0,0 +1,135 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { buildFileTransferAbortPayload } from '../core/file-transfer-payloads.ts';
3
+ import type { FileSendTransferState } from '../core/types.ts';
4
+ import { clampFiniteNumber } from '../core/value-sanitize.ts';
5
+ import { getErrorMessage } from './error-message.ts';
6
+
7
+ const FILE_TRANSFER_ACK_TTL_MS = 30_000;
8
+ const INTERNAL_SLEEP_MAX_MS = 120_000;
9
+
10
+ async function sleepFileTransferMs(ms: number): Promise<void> {
11
+ await new Promise<void>((resolve) =>
12
+ setTimeout(resolve, clampFiniteNumber(ms, 0, 0, INTERNAL_SLEEP_MAX_MS)),
13
+ );
14
+ }
15
+
16
+ export function ensureChunkAckPending(args: {
17
+ state: FileSendTransferState;
18
+ chunkIndex: number;
19
+ }): boolean {
20
+ const { state, chunkIndex } = args;
21
+ if (state.failedChunks.has(chunkIndex)) {
22
+ throw new Error(state.failedChunks.get(chunkIndex) || `chunk ${chunkIndex} failed`);
23
+ }
24
+ return !state.ackedChunks.has(chunkIndex);
25
+ }
26
+
27
+ export async function sendChunkWithRetry(args: {
28
+ transferId: string;
29
+ accountId: string;
30
+ chunkIndex: number;
31
+ offset: number;
32
+ slice: Buffer;
33
+ connIds: Set<string>;
34
+ waitChunkAck: (params: {
35
+ transferId: string;
36
+ chunkIndex: number;
37
+ timeoutMs?: number;
38
+ }) => Promise<void>;
39
+ sendChunk: (args: {
40
+ transferId: string;
41
+ accountId: string;
42
+ chunkIndex: number;
43
+ attempt: number;
44
+ offset: number;
45
+ size: number;
46
+ chunkSha256: string;
47
+ base64: string;
48
+ connIds: ReadonlySet<string>;
49
+ }) => void;
50
+ logFileTransferChunkAck: (args: {
51
+ transferId: string;
52
+ accountId: string;
53
+ chunkIndex: number;
54
+ attempt: number;
55
+ }) => void;
56
+ logFileTransferChunkAckFail: (args: {
57
+ transferId: string;
58
+ accountId: string;
59
+ chunkIndex: number;
60
+ attempt: number;
61
+ error: unknown;
62
+ }) => void;
63
+ }): Promise<void> {
64
+ const { transferId, accountId, chunkIndex, offset, slice, connIds } = args;
65
+ const chunkSha256 = createHash('sha256').update(slice).digest('hex');
66
+
67
+ let lastErr: unknown = null;
68
+ for (let attempt = 1; attempt <= 3; attempt++) {
69
+ args.sendChunk({
70
+ transferId,
71
+ accountId,
72
+ chunkIndex,
73
+ attempt,
74
+ offset,
75
+ size: slice.byteLength,
76
+ chunkSha256,
77
+ base64: slice.toString('base64'),
78
+ connIds,
79
+ });
80
+
81
+ try {
82
+ await args.waitChunkAck({
83
+ transferId,
84
+ chunkIndex,
85
+ timeoutMs: FILE_TRANSFER_ACK_TTL_MS,
86
+ });
87
+ args.logFileTransferChunkAck({
88
+ transferId,
89
+ accountId,
90
+ chunkIndex,
91
+ attempt,
92
+ });
93
+ return;
94
+ } catch (err) {
95
+ lastErr = err;
96
+ args.logFileTransferChunkAckFail({
97
+ transferId,
98
+ accountId,
99
+ chunkIndex,
100
+ attempt,
101
+ error: err,
102
+ });
103
+ await sleepFileTransferMs(150 * attempt);
104
+ }
105
+ }
106
+
107
+ throw new Error(getErrorMessage(lastErr, `chunk-${chunkIndex}-failed`));
108
+ }
109
+
110
+ export function abortChunkTransfer(args: {
111
+ state: FileSendTransferState;
112
+ transferId: string;
113
+ reason: string;
114
+ connIds: Set<string>;
115
+ now: () => number;
116
+ fileSendTransfers: Map<string, FileSendTransferState>;
117
+ fileAbortEvent: string;
118
+ broadcastToConnIds: (event: string, payload: unknown, connIds: ReadonlySet<string>) => void;
119
+ }): never {
120
+ const { state, transferId, reason, connIds } = args;
121
+ state.status = 'aborted';
122
+ state.terminalAt = args.now();
123
+ state.error = reason;
124
+ args.fileSendTransfers.set(transferId, state);
125
+ args.broadcastToConnIds(
126
+ args.fileAbortEvent,
127
+ buildFileTransferAbortPayload({
128
+ transferId,
129
+ reason,
130
+ ts: args.now(),
131
+ }),
132
+ connIds,
133
+ );
134
+ throw new Error(reason);
135
+ }
@@ -0,0 +1,304 @@
1
+ import { buildFileTransferInitPayload } from '../core/file-transfer-payloads.ts';
2
+ import type { BncrRoute, FileSendTransferState } from '../core/types.ts';
3
+ import { asSanitizedString, clampFiniteNumber } from '../core/value-sanitize.ts';
4
+ import type { createBncrFileAckRuntime } from './file-ack-runtime.ts';
5
+ import {
6
+ abortChunkTransfer,
7
+ ensureChunkAckPending,
8
+ sendChunkWithRetry,
9
+ } from './file-transfer-orchestrator-chunk.ts';
10
+ import type { createBncrFileTransferSetup } from './file-transfer-setup.ts';
11
+
12
+ type PreparedOutboundTransfer = Awaited<
13
+ ReturnType<ReturnType<typeof createBncrFileTransferSetup>['prepareOutboundTransfer']>
14
+ >;
15
+ type FileAckPayload = Awaited<
16
+ ReturnType<ReturnType<typeof createBncrFileAckRuntime>['waitForFileAck']>
17
+ >;
18
+
19
+ const FILE_TRANSFER_ACK_TTL_MS = 30_000;
20
+
21
+ function getFileSendTransferForAck(
22
+ fileSendTransfers: ReadonlyMap<string, FileSendTransferState>,
23
+ transferId: string,
24
+ ): FileSendTransferState {
25
+ const state = fileSendTransfers.get(transferId);
26
+ if (!state) throw new Error('transfer state missing');
27
+ return state;
28
+ }
29
+
30
+ function getCompletedTransferPath(state?: FileSendTransferState): string | null {
31
+ if (state?.status !== 'completed') return null;
32
+ const path = asSanitizedString(state.completedPath || '').trim();
33
+ return path || null;
34
+ }
35
+
36
+ function buildBase64TransferResult(prepared: {
37
+ mimeType?: string;
38
+ fileName?: string;
39
+ mediaBase64?: string;
40
+ }) {
41
+ return {
42
+ mode: 'base64' as const,
43
+ mimeType: prepared.mimeType,
44
+ fileName: prepared.fileName,
45
+ mediaBase64: prepared.mediaBase64,
46
+ };
47
+ }
48
+
49
+ function buildChunkTransferResult(args: { mimeType: string; fileName: string; path?: string }) {
50
+ return {
51
+ mode: 'chunk' as const,
52
+ mimeType: args.mimeType,
53
+ fileName: args.fileName,
54
+ path: args.path,
55
+ };
56
+ }
57
+
58
+ function ensureTransferMimeType(mimeType: string | undefined, fileName: string): string {
59
+ const value = asSanitizedString(mimeType || '').trim();
60
+ if (value) return value;
61
+
62
+ const lowerFileName = fileName.toLowerCase();
63
+ if (lowerFileName.endsWith('.png')) return 'image/png';
64
+ if (lowerFileName.endsWith('.jpg') || lowerFileName.endsWith('.jpeg')) return 'image/jpeg';
65
+ if (lowerFileName.endsWith('.gif')) return 'image/gif';
66
+ if (lowerFileName.endsWith('.webp')) return 'image/webp';
67
+ if (lowerFileName.endsWith('.mp3')) return 'audio/mpeg';
68
+ if (lowerFileName.endsWith('.mp4')) return 'video/mp4';
69
+ return 'application/octet-stream';
70
+ }
71
+
72
+ export type BncrFileTransferOrchestratorRuntime = {
73
+ now: () => number;
74
+ fileSendTransfers: Map<string, FileSendTransferState>;
75
+ getGatewayContext: () => {
76
+ broadcastToConnIds: (event: string, payload: unknown, connIds: ReadonlySet<string>) => void;
77
+ } | null;
78
+ fileInitEvent: string;
79
+ fileAbortEvent: string;
80
+ prepareOutboundTransfer: (args: {
81
+ accountId: string;
82
+ sessionKey: string;
83
+ route: BncrRoute;
84
+ mediaUrl: string;
85
+ mediaLocalRoots?: readonly string[];
86
+ hasGatewayContext: boolean;
87
+ }) => Promise<PreparedOutboundTransfer>;
88
+ sendChunk: (args: {
89
+ transferId: string;
90
+ accountId: string;
91
+ chunkIndex: number;
92
+ attempt: number;
93
+ offset: number;
94
+ size: number;
95
+ chunkSha256: string;
96
+ base64: string;
97
+ connIds: ReadonlySet<string>;
98
+ }) => void;
99
+ sendComplete: (args: {
100
+ transferId: string;
101
+ accountId: string;
102
+ connIds: ReadonlySet<string>;
103
+ }) => void;
104
+ waitForFileAck: (params: {
105
+ transferId: string;
106
+ stage: string;
107
+ chunkIndex?: number;
108
+ timeoutMs: number;
109
+ }) => Promise<FileAckPayload>;
110
+ logFileTransferChunkAck: (args: {
111
+ transferId: string;
112
+ accountId: string;
113
+ chunkIndex: number;
114
+ attempt: number;
115
+ }) => void;
116
+ logFileTransferChunkAckFail: (args: {
117
+ transferId: string;
118
+ accountId: string;
119
+ chunkIndex: number;
120
+ attempt: number;
121
+ error: unknown;
122
+ }) => void;
123
+ logFileTransferCompleteAck: (args: {
124
+ transferId: string;
125
+ accountId: string;
126
+ payload: { path: string };
127
+ }) => void;
128
+ };
129
+
130
+ export function createBncrFileTransferOrchestrator(runtime: BncrFileTransferOrchestratorRuntime) {
131
+ const waitChunkAck = async (params: {
132
+ transferId: string;
133
+ chunkIndex: number;
134
+ timeoutMs?: number;
135
+ }): Promise<void> => {
136
+ const { transferId, chunkIndex } = params;
137
+ const st = getFileSendTransferForAck(runtime.fileSendTransfers, transferId);
138
+ if (!ensureChunkAckPending({ state: st, chunkIndex })) return;
139
+
140
+ await runtime.waitForFileAck({
141
+ transferId,
142
+ stage: 'chunk',
143
+ chunkIndex,
144
+ timeoutMs: clampFiniteNumber(params.timeoutMs, FILE_TRANSFER_ACK_TTL_MS, 1_000, 60_000),
145
+ });
146
+ };
147
+
148
+ const waitCompleteAck = async (params: {
149
+ transferId: string;
150
+ timeoutMs?: number;
151
+ }): Promise<{ path: string }> => {
152
+ const { transferId } = params;
153
+ const st = getFileSendTransferForAck(runtime.fileSendTransfers, transferId);
154
+ if (st.status === 'aborted') throw new Error(st.error || 'transfer aborted');
155
+ const completedPath = getCompletedTransferPath(st);
156
+ if (completedPath) return { path: completedPath };
157
+
158
+ const payload = await runtime.waitForFileAck({
159
+ transferId,
160
+ stage: 'complete',
161
+ timeoutMs: clampFiniteNumber(params.timeoutMs, 60_000, 2_000, 120_000),
162
+ });
163
+ const updated = runtime.fileSendTransfers.get(transferId);
164
+ const path = asSanitizedString(payload?.path || getCompletedTransferPath(updated) || '').trim();
165
+ if (!path) throw new Error('complete ack missing path');
166
+ return { path };
167
+ };
168
+
169
+ const finalizeChunkTransfer = async (params: {
170
+ transferId: string;
171
+ accountId: string;
172
+ connIds: Set<string>;
173
+ mimeType: string;
174
+ fileName: string;
175
+ }): Promise<{ mode: 'chunk'; mimeType: string; fileName: string; path?: string }> => {
176
+ const { transferId, accountId, connIds, mimeType, fileName } = params;
177
+ runtime.sendComplete({
178
+ transferId,
179
+ accountId,
180
+ connIds,
181
+ });
182
+
183
+ const done = await waitCompleteAck({ transferId, timeoutMs: 60_000 });
184
+
185
+ runtime.logFileTransferCompleteAck({
186
+ transferId,
187
+ accountId,
188
+ payload: done,
189
+ });
190
+
191
+ return buildChunkTransferResult({ mimeType, fileName, path: done.path });
192
+ };
193
+
194
+ const transferMediaToBncrClient = async (params: {
195
+ accountId: string;
196
+ sessionKey: string;
197
+ route: BncrRoute;
198
+ mediaUrl: string;
199
+ mediaLocalRoots?: readonly string[];
200
+ }): Promise<{
201
+ mode: 'base64' | 'chunk';
202
+ mimeType?: string;
203
+ fileName?: string;
204
+ mediaBase64?: string;
205
+ path?: string;
206
+ }> => {
207
+ const prepared = await runtime.prepareOutboundTransfer({
208
+ accountId: params.accountId,
209
+ sessionKey: params.sessionKey,
210
+ route: params.route,
211
+ mediaUrl: params.mediaUrl,
212
+ mediaLocalRoots: params.mediaLocalRoots,
213
+ hasGatewayContext: Boolean(runtime.getGatewayContext()),
214
+ });
215
+
216
+ if (prepared.mode === 'base64') {
217
+ return buildBase64TransferResult(prepared);
218
+ }
219
+
220
+ const ctx = runtime.getGatewayContext();
221
+ if (!ctx) throw new Error('gateway context unavailable');
222
+
223
+ const {
224
+ loaded,
225
+ size,
226
+ fileName,
227
+ fileSha256,
228
+ accountId,
229
+ connIds,
230
+ transferId,
231
+ chunkSize,
232
+ totalChunks,
233
+ state,
234
+ } = prepared;
235
+ const mimeType = ensureTransferMimeType(prepared.mimeType, fileName);
236
+ if (!connIds.size) throw new Error('no active bncr client for file chunk transfer');
237
+ const st = state as FileSendTransferState;
238
+ runtime.fileSendTransfers.set(transferId, st);
239
+
240
+ ctx.broadcastToConnIds(
241
+ runtime.fileInitEvent,
242
+ buildFileTransferInitPayload({
243
+ transferId,
244
+ sessionKey: params.sessionKey,
245
+ route: params.route,
246
+ fileName,
247
+ mimeType,
248
+ fileSize: size,
249
+ chunkSize,
250
+ totalChunks,
251
+ fileSha256,
252
+ ts: runtime.now(),
253
+ }),
254
+ connIds,
255
+ );
256
+
257
+ for (let idx = 0; idx < totalChunks; idx++) {
258
+ const start = idx * chunkSize;
259
+ const end = Math.min(start + chunkSize, size);
260
+ const slice = loaded.buffer.subarray(start, end);
261
+
262
+ try {
263
+ await sendChunkWithRetry({
264
+ transferId,
265
+ accountId,
266
+ chunkIndex: idx,
267
+ offset: start,
268
+ slice,
269
+ connIds,
270
+ waitChunkAck,
271
+ sendChunk: runtime.sendChunk,
272
+ logFileTransferChunkAck: runtime.logFileTransferChunkAck,
273
+ logFileTransferChunkAckFail: runtime.logFileTransferChunkAckFail,
274
+ });
275
+ } catch (err) {
276
+ abortChunkTransfer({
277
+ state: st,
278
+ transferId,
279
+ reason: err instanceof Error ? err.message : String(err),
280
+ connIds,
281
+ now: runtime.now,
282
+ fileSendTransfers: runtime.fileSendTransfers,
283
+ fileAbortEvent: runtime.fileAbortEvent,
284
+ broadcastToConnIds: (event, payload, connIds) =>
285
+ runtime.getGatewayContext()!.broadcastToConnIds(event, payload, connIds),
286
+ });
287
+ }
288
+ }
289
+
290
+ return finalizeChunkTransfer({
291
+ transferId,
292
+ accountId,
293
+ connIds,
294
+ mimeType,
295
+ fileName,
296
+ });
297
+ };
298
+
299
+ return {
300
+ waitChunkAck,
301
+ waitCompleteAck,
302
+ transferMediaToBncrClient,
303
+ };
304
+ }