@aiworker/web 1.22.2 → 1.23.1

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 (217) hide show
  1. package/bin/cli.js +7 -0
  2. package/dist/assets/{CodeMirrorCommentWidgets-CSICpWQD.js → CodeMirrorCommentWidgets-teB3HmBT.js} +1 -1
  3. package/dist/assets/DiffView-DC9RX3MF.js +4 -0
  4. package/dist/assets/{DirtyBranchSwitchDialog-C22HdWj4.js → DirtyBranchSwitchDialog-DwPkzXsS.js} +1 -1
  5. package/dist/assets/FilesView-BFZkuu4O.js +13 -0
  6. package/dist/assets/{GitView-VQQkYuUw.js → GitView-B_x52MHY.js} +4 -4
  7. package/dist/assets/{JsonTreeView-6lrhwD3q.js → JsonTreeView-cmAzUTiw.js} +1 -1
  8. package/dist/assets/{LinearIssuesView-Ba6Q9569.js → LinearIssuesView-Dehh5afm.js} +1 -1
  9. package/dist/assets/MarkdownImageGallery-DgNOqqhE.js +1 -0
  10. package/dist/assets/MarkdownRendererImpl-DusmMZ_p.js +12 -0
  11. package/dist/assets/OnboardingScreen-B8fm1H4m.js +1 -0
  12. package/dist/assets/{PatchDiff-BzcZPlOq.js → PatchDiff-BVr7gJU0.js} +1 -1
  13. package/dist/assets/PierreDiffViewer-DzLt-nel.js +161 -0
  14. package/dist/assets/PlanView-BmQUYT_z.js +5 -0
  15. package/dist/assets/SettingsView-ChWmAcZT.js +28 -0
  16. package/dist/assets/{SettingsWindow-DDQYaKWQ.js → SettingsWindow-Dy3M6ZB9.js} +1 -1
  17. package/dist/assets/SymbolsNerdFontMono-Regular-aK5vsLov.woff2 +0 -0
  18. package/dist/assets/{ToolOutputDialog-iYF8Y2bz.js → ToolOutputDialog-cTl_eLIH.js} +5 -5
  19. package/dist/assets/{ToolPartDiffPreview-CdjbOVMM.js → ToolPartDiffPreview-C_3iYNJQ.js} +1 -1
  20. package/dist/assets/{VirtualizedFileDiff-fFFm2QgH.js → VirtualizedFileDiff-CAWthkoy.js} +4 -4
  21. package/dist/assets/WalkthroughView-C7AHOpCA.js +4 -0
  22. package/dist/assets/{computeVirtualFileMetrics-CzgLum_K.js → computeVirtualFileMetrics-BkiDSlTW.js} +2 -2
  23. package/dist/assets/contextPanelEmbeddedChat-BdA6bdky.js +1 -0
  24. package/dist/assets/de-DJsQr-p7.js +10 -0
  25. package/dist/assets/es-BuYMlA9g.js +16 -0
  26. package/dist/assets/fr-ePDay33C.js +14 -0
  27. package/dist/assets/ghostty-vt-DdA0Zryv.wasm +0 -0
  28. package/dist/assets/index-BD8ILbxF.js +40 -0
  29. package/dist/assets/{index-Bl3LSbjn.js → index-n6O3hklM.js} +1 -1
  30. package/dist/assets/index-zhgg-SDz.css +1 -0
  31. package/dist/assets/{isDiffPlainText-Dx-dB_8l.js → isDiffPlainText-GMdPz-71.js} +1 -1
  32. package/dist/assets/{isFilePlainText-CWFPwmnU.js → isFilePlainText-BVuH1zH9.js} +1 -1
  33. package/dist/assets/ja-BzR9xOY_.js +16 -0
  34. package/dist/assets/ko-BEjHAm14.js +16 -0
  35. package/dist/assets/main-BydQ1tr_.js +930 -0
  36. package/dist/assets/{main-DnBAoktx.js → main-D3ZDQglc.js} +2 -2
  37. package/dist/assets/markdownCore-Cb2MdZnV.js +5 -0
  38. package/dist/assets/{miniChat-BKyvLseN.js → miniChat-d01k801U.js} +2 -2
  39. package/dist/assets/{mobile-DKjeFgMN.js → mobile-D7VV-Ufz.js} +2 -2
  40. package/dist/assets/patchFileDiff-CdztVMz0.js +13 -0
  41. package/dist/assets/pl-rQmpxNIF.js +16 -0
  42. package/dist/assets/pt-BR-CorbsSW-.js +16 -0
  43. package/dist/assets/renderElectronMiniChatApp-BGG9UIlh.js +2 -0
  44. package/dist/assets/renderMobileApp-BRP3J1iv.js +25 -0
  45. package/dist/assets/runtimeEndpointReset-BhT_-c52.js +41 -0
  46. package/dist/assets/scheduledTasksApi-BEuUF_cj.js +1 -0
  47. package/dist/assets/{shikiHighlight-TEQCmzW8.js → shikiHighlight-BzXUjlby.js} +1 -1
  48. package/dist/assets/surface-COKb77Ua.js +1 -0
  49. package/dist/assets/tr-CoAMXPtI.js +7 -0
  50. package/dist/assets/uk-YlAX0rRU.js +16 -0
  51. package/dist/assets/{useAppFontEffects-D7sNpuL2.js → useAppFontEffects-Cwyhjfl5.js} +141 -140
  52. package/dist/assets/useGitComparison-BCZVefdE.js +1 -0
  53. package/dist/assets/useInlineCommentController-DDkpBEU8.js +2 -0
  54. package/dist/assets/useRootScrollLock-CwjoWuC4.js +1 -0
  55. package/dist/assets/{vendor-base-ui-BeMD8X_w.js → vendor-base-ui-BY_5qla_.js} +1 -1
  56. package/dist/assets/{vendor-codemirror-language-data-Dp3KMRQG.js → vendor-codemirror-language-data-Cb1YnICj.js} +2 -2
  57. package/dist/assets/vendor-legendapp-list-BuBPBC3R.js +1 -0
  58. package/dist/assets/vendor-zod-B6551dwG.js +39 -0
  59. package/dist/assets/{web-components-_mNEMjFY.js → web-components-LV3vjTxy.js} +1 -1
  60. package/dist/assets/zh-CN-DDYg0Jl7.js +16 -0
  61. package/dist/assets/zh-TW-CQKaCayr.js +16 -0
  62. package/dist/index.html +5 -43
  63. package/dist/mini-chat.html +4 -4
  64. package/dist/mobile.html +4 -4
  65. package/package.json +2 -3
  66. package/server/index.d.ts +15 -0
  67. package/server/index.js +48 -22
  68. package/server/lib/agent-memory/runtime.js +7 -2
  69. package/server/lib/agent-memory/runtime.test.js +22 -0
  70. package/server/lib/data-dir-migration.js +40 -0
  71. package/server/lib/data-dir-migration.test.js +59 -0
  72. package/server/lib/event-stream/DOCUMENTATION.md +3 -0
  73. package/server/lib/event-stream/global-hub.js +29 -4
  74. package/server/lib/event-stream/global-hub.test.js +22 -0
  75. package/server/lib/event-stream/global-ws-bridge.js +15 -18
  76. package/server/lib/event-stream/protocol.js +21 -7
  77. package/server/lib/event-stream/runtime.js +9 -6
  78. package/server/lib/event-stream/runtime.test.js +72 -12
  79. package/server/lib/fs/DOCUMENTATION.md +3 -0
  80. package/server/lib/fs/routes.js +31 -0
  81. package/server/lib/fs/routes.test.js +110 -1
  82. package/server/lib/git/DOCUMENTATION.md +39 -6
  83. package/server/lib/git/routes.js +45 -12
  84. package/server/lib/git/routes.test.js +78 -0
  85. package/server/lib/git/service.js +343 -202
  86. package/server/lib/git/service.test.js +496 -25
  87. package/server/lib/github/DOCUMENTATION.md +5 -0
  88. package/server/lib/github/routes.js +2 -2
  89. package/server/lib/inherited-env.js +16 -9
  90. package/server/lib/inherited-env.test.js +8 -9
  91. package/server/lib/message-queue/DOCUMENTATION.md +11 -1
  92. package/server/lib/message-queue/runtime.js +13 -0
  93. package/server/lib/message-queue/runtime.test.js +31 -1
  94. package/server/lib/network-defaults.js +23 -0
  95. package/server/lib/network-defaults.test.js +52 -0
  96. package/server/lib/notifications/DOCUMENTATION.md +1 -1
  97. package/server/lib/notifications/emitter-runtime.js +4 -3
  98. package/server/lib/opencode/DOCUMENTATION.md +35 -5
  99. package/server/lib/opencode/bootstrap-runtime.js +2 -0
  100. package/server/lib/opencode/env-runtime.js +5 -1
  101. package/server/lib/opencode/env-runtime.test.js +72 -0
  102. package/server/lib/opencode/feature-routes-runtime.js +17 -1
  103. package/server/lib/opencode/lifecycle.js +4 -3
  104. package/server/lib/opencode/lifecycle.test.js +29 -0
  105. package/server/lib/opencode/managed-plugin-config.js +1 -1
  106. package/server/lib/opencode/openchamber-routes.js +64 -4
  107. package/server/lib/opencode/openchamber-routes.test.js +128 -1
  108. package/server/lib/opencode/proxy.js +33 -0
  109. package/server/lib/opencode/routes.js +5 -3
  110. package/server/lib/opencode/settings-files.js +214 -0
  111. package/server/lib/opencode/settings-helpers.js +88 -41
  112. package/server/lib/opencode/settings-helpers.test.js +176 -1
  113. package/server/lib/opencode/settings-registry.json +750 -0
  114. package/server/lib/opencode/settings-runtime.js +175 -27
  115. package/server/lib/opencode/settings-runtime.test.js +321 -7
  116. package/server/lib/project-context/DOCUMENTATION.md +38 -4
  117. package/server/lib/project-context/routes.http.test.js +18 -0
  118. package/server/lib/project-context/routes.js +15 -0
  119. package/server/lib/project-context/runtime.js +256 -25
  120. package/server/lib/project-context/runtime.test.js +160 -3
  121. package/server/lib/projects/DOCUMENTATION.md +77 -0
  122. package/server/lib/projects/project-config.js +179 -17
  123. package/server/lib/projects/project-config.test.js +97 -0
  124. package/server/lib/projects/project-id.js +43 -0
  125. package/server/lib/projects/project-setup.js +409 -0
  126. package/server/lib/projects/project-setup.test.js +557 -0
  127. package/server/lib/projects/routes.js +59 -0
  128. package/server/lib/quota/DOCUMENTATION.md +31 -1
  129. package/server/lib/quota/providers/cline-pass.js +125 -0
  130. package/server/lib/quota/providers/cline-pass.test.js +103 -0
  131. package/server/lib/quota/providers/index.js +7 -0
  132. package/server/lib/quota/providers/neuralwatt.js +3 -6
  133. package/server/lib/quota/providers/neuralwatt.test.js +7 -7
  134. package/server/lib/quota/providers/ollama-cloud.js +31 -0
  135. package/server/lib/quota/providers/ollama-cloud.test.js +33 -1
  136. package/server/lib/quota/providers/opencode-go.js +1 -1
  137. package/server/lib/quota/providers/opencode-go.test.js +26 -1
  138. package/server/lib/quota/providers/openrouter.js +119 -24
  139. package/server/lib/quota/providers/openrouter.test.js +330 -0
  140. package/server/lib/relay/DOCUMENTATION.md +75 -0
  141. package/server/lib/relay/cross-compat.test.js +26 -0
  142. package/server/lib/relay/downstream-scheduler.js +154 -0
  143. package/server/lib/relay/downstream-scheduler.test.js +162 -0
  144. package/server/lib/relay/e2ee.js +10 -8
  145. package/server/lib/relay/flow-control.test.js +177 -0
  146. package/server/lib/relay/host-client.js +44 -8
  147. package/server/lib/relay/host-client.test.js +14 -0
  148. package/server/lib/relay/tunnel-codec.js +9 -0
  149. package/server/lib/relay/tunnel-host.js +66 -17
  150. package/server/lib/scheduled-tasks/DOCUMENTATION.md +9 -1
  151. package/server/lib/scheduled-tasks/issue-2710-double-execution.test.js +28 -0
  152. package/server/lib/scheduled-tasks/runtime.js +12 -2
  153. package/server/lib/scheduled-tasks/runtime.test.js +53 -0
  154. package/server/lib/session-assist/DOCUMENTATION.md +131 -66
  155. package/server/lib/session-assist/context.js +127 -0
  156. package/server/lib/session-assist/context.test.js +132 -0
  157. package/server/lib/session-assist/prompt.js +54 -0
  158. package/server/lib/session-assist/runtime.js +117 -267
  159. package/server/lib/session-assist/runtime.test.js +203 -0
  160. package/server/lib/session-goal/runtime.js +4 -9
  161. package/server/lib/small-model/DOCUMENTATION.md +3 -2
  162. package/server/lib/small-model/call.js +9 -4
  163. package/server/lib/small-model/call.test.js +29 -0
  164. package/server/lib/small-model/index.js +5 -10
  165. package/server/lib/small-model/runtime-providers.js +15 -7
  166. package/server/lib/small-model/runtime-providers.test.js +7 -1
  167. package/server/lib/terminal/DOCUMENTATION.md +4 -4
  168. package/server/lib/terminal/runtime.js +11 -6
  169. package/server/lib/terminal/runtime.test.js +20 -13
  170. package/server/lib/walkthrough/DOCUMENTATION.md +49 -20
  171. package/server/lib/walkthrough/model-settings.js +8 -12
  172. package/server/lib/walkthrough/pr-source.test.js +21 -0
  173. package/server/lib/walkthrough/prompt.js +3 -1
  174. package/server/lib/walkthrough/pull-request.js +7 -4
  175. package/server/lib/walkthrough/pull-request.test.js +16 -0
  176. package/server/lib/walkthrough/routes.js +19 -0
  177. package/server/lib/walkthrough/routes.test.js +26 -0
  178. package/server/lib/walkthrough/sources.js +36 -4
  179. package/server/opencode-worktree-readiness.test.js +130 -0
  180. package/dist/assets/DiffView-hDBKesA_.js +0 -4
  181. package/dist/assets/FilesView-CdWzeOp9.js +0 -17
  182. package/dist/assets/MarkdownImageGallery-Czv_mhvS.js +0 -1
  183. package/dist/assets/MarkdownRendererImpl-BrMcswiy.js +0 -12
  184. package/dist/assets/OnboardingScreen-ChxQoAkx.js +0 -1
  185. package/dist/assets/PierreDiffViewer-BchBtZlp.js +0 -157
  186. package/dist/assets/PlanView-DyDt_nZ_.js +0 -5
  187. package/dist/assets/SettingsView-BKf5xBsP.js +0 -28
  188. package/dist/assets/WalkthroughView-DOeCsMju.js +0 -4
  189. package/dist/assets/contextPanelEmbeddedChat-C0ZLAepL.js +0 -1
  190. package/dist/assets/de-B5JaA3SA.js +0 -10
  191. package/dist/assets/es-EPIH88ii.js +0 -16
  192. package/dist/assets/fr-CAMfxUdZ.js +0 -14
  193. package/dist/assets/index-DF5kMP-i.js +0 -39
  194. package/dist/assets/index-Dl3xbAB_.css +0 -1
  195. package/dist/assets/ja-CifOE1VN.js +0 -16
  196. package/dist/assets/ko-BBDyAyYQ.js +0 -16
  197. package/dist/assets/main-MhSaOZoB.js +0 -930
  198. package/dist/assets/markdownCore-BrBwOOMn.js +0 -3
  199. package/dist/assets/patchFileDiff-o8ms1me8.js +0 -10
  200. package/dist/assets/pl-DgEHYfFM.js +0 -16
  201. package/dist/assets/pt-BR-DuWHojUw.js +0 -16
  202. package/dist/assets/renderElectronMiniChatApp-CDgBwwCS.js +0 -2
  203. package/dist/assets/renderMobileApp-DJIKIK1r.js +0 -25
  204. package/dist/assets/runtimeEndpointReset-CYJ9O_xX.js +0 -17
  205. package/dist/assets/scheduledTasksApi-D8yamh1b.js +0 -1
  206. package/dist/assets/tr-D5m2yIWL.js +0 -7
  207. package/dist/assets/uk-D_q60oog.js +0 -16
  208. package/dist/assets/useInlineCommentController-Brz_cLcQ.js +0 -2
  209. package/dist/assets/useRootScrollLock-lXEnWlaU.js +0 -1
  210. package/dist/assets/vendor-ghostty-web-CrxinGGH.js +0 -13
  211. package/dist/assets/vendor-legendapp-list-gg7YLRW7.js +0 -1
  212. package/dist/assets/vendor-zod-CK21ihK-.js +0 -39
  213. package/dist/assets/zh-CN-D8QvsVeg.js +0 -16
  214. package/dist/assets/zh-TW-B9t7rQQO.js +0 -16
  215. package/server/lib/mcp-reconnect/DOCUMENTATION.md +0 -55
  216. package/server/lib/mcp-reconnect/runtime.js +0 -122
  217. package/server/lib/mcp-reconnect/runtime.test.js +0 -140
@@ -0,0 +1,154 @@
1
+ // Downstream credit belongs to the end client, not the relay's TCP connection.
2
+ // Producers await send(), so HTTP readers stop before building a plaintext queue.
3
+ import { decodeTunnelFrame } from './tunnel-codec.js';
4
+ import { MAX_PLAINTEXT_FRAME_BYTES } from './e2ee.js';
5
+
6
+ export const DOWNSTREAM_CHUNK_BYTES = 8 * 1024;
7
+ const MIN_WINDOW_BYTES = 64 * 1024;
8
+ const MAX_WINDOW_BYTES = 1024 * 1024;
9
+ const MAX_QUEUED_BYTES = 4 * 1024 * 1024;
10
+ const MAX_QUEUED_FRAMES = 4096;
11
+ const MAX_OUTSTANDING_FRAMES = 4096;
12
+ const QUEUE_DELAY_TARGET_MS = 100;
13
+
14
+ export const createDownstreamScheduler = ({ sendBatch, onError, maxBatchFrames = 32, now = () => performance.now() }) => {
15
+ const queues = new Map();
16
+ const outstanding = [];
17
+ let queuedBytes = 0;
18
+ let queuedFrames = 0;
19
+ let sentBytes = 0;
20
+ let acknowledgedBytes = 0;
21
+ let windowBytes = MIN_WINDOW_BYTES;
22
+ let minRtt = Infinity;
23
+ let adjustmentBoundary = MIN_WINDOW_BYTES;
24
+ let windowLimited = false;
25
+ let draining = false;
26
+ let closed = false;
27
+ let sending = [];
28
+
29
+ const close = () => {
30
+ closed = true;
31
+ for (const queue of queues.values()) for (const entry of queue) entry.resolve();
32
+ for (const entry of sending) entry.resolve();
33
+ sending = [];
34
+ queues.clear();
35
+ outstanding.length = 0;
36
+ queuedBytes = 0;
37
+ queuedFrames = 0;
38
+ };
39
+
40
+ const fail = error => {
41
+ close();
42
+ onError(error);
43
+ };
44
+
45
+ const drain = async () => {
46
+ if (draining || closed) return;
47
+ draining = true;
48
+ try {
49
+ while (!closed) {
50
+ const selected = [];
51
+ let batchBytes = 1;
52
+ while (selected.length < maxBatchFrames) {
53
+ // Stream zero carries only tiny keepalives; it must not wait for credit.
54
+ const next = queues.has(0) ? [0, queues.get(0)] : queues.entries().next().value;
55
+ if (!next) break;
56
+ const [streamId, queue] = next;
57
+ const entry = queue[0];
58
+ // Do not skip a larger head indefinitely in favour of smaller frames.
59
+ if (streamId !== 0 && sentBytes - acknowledgedBytes + entry.frame.length > windowBytes) {
60
+ windowLimited = true;
61
+ break;
62
+ }
63
+ if (streamId !== 0 && outstanding.length >= MAX_OUTSTANDING_FRAMES) break;
64
+ if (batchBytes + 4 + entry.frame.length > MAX_PLAINTEXT_FRAME_BYTES) break;
65
+ queue.shift();
66
+ queues.delete(streamId);
67
+ if (queue.length) queues.set(streamId, queue);
68
+ queuedBytes -= entry.frame.length;
69
+ queuedFrames -= 1;
70
+ if (streamId !== 0) {
71
+ sentBytes += entry.frame.length;
72
+ outstanding.push({ end: sentBytes, at: now() });
73
+ }
74
+ selected.push(entry);
75
+ batchBytes += 4 + entry.frame.length;
76
+ }
77
+ if (!selected.length) break;
78
+ sending = selected;
79
+ try {
80
+ // Selection precedes encryption. Never reorder encrypted IV counters.
81
+ await sendBatch(selected.map(entry => entry.frame));
82
+ } finally {
83
+ for (const entry of selected) entry.resolve();
84
+ sending = [];
85
+ }
86
+ }
87
+ } catch (error) {
88
+ fail(error);
89
+ } finally {
90
+ draining = false;
91
+ }
92
+ };
93
+
94
+ return {
95
+ send(frame) {
96
+ if (closed) return Promise.resolve();
97
+ const { streamId } = decodeTunnelFrame(frame);
98
+ if (queuedBytes + frame.length > MAX_QUEUED_BYTES || queuedFrames >= MAX_QUEUED_FRAMES) {
99
+ fail(new Error('relay downstream queue limit exceeded'));
100
+ return Promise.resolve();
101
+ }
102
+ return new Promise(resolve => {
103
+ const queue = queues.get(streamId) ?? [];
104
+ queue.push({ frame, resolve, streamId });
105
+ queues.set(streamId, queue);
106
+ queuedBytes += frame.length;
107
+ queuedFrames += 1;
108
+ // Let concurrent streams join this round before selecting the next frame.
109
+ queueMicrotask(() => { void drain(); });
110
+ });
111
+ },
112
+ acknowledge(bytes) {
113
+ if (closed) return;
114
+ if (!Number.isSafeInteger(bytes) || bytes <= acknowledgedBytes || bytes > sentBytes) {
115
+ throw new Error('invalid relay delivery acknowledgement');
116
+ }
117
+ // An ACK may cover several complete frames, never a partial frame.
118
+ const endIndex = outstanding.findIndex(entry => entry.end === bytes);
119
+ if (endIndex < 0) throw new Error('relay acknowledgement is not a frame boundary');
120
+ const rtt = Math.max(1, now() - outstanding[endIndex].at);
121
+ outstanding.splice(0, endIndex + 1);
122
+ acknowledgedBytes = bytes;
123
+ minRtt = Math.min(minRtt, rtt);
124
+ // Grow on a clear path, reduce when the client's delivery delay grows.
125
+ // Adjust once per window, not once per frame in a burst of ACKs.
126
+ if (bytes >= adjustmentBoundary) {
127
+ if (rtt > minRtt + QUEUE_DELAY_TARGET_MS) {
128
+ windowBytes = Math.max(MIN_WINDOW_BYTES, Math.floor(windowBytes / 2));
129
+ } else if (windowLimited) {
130
+ // Sparse token traffic must not inflate a future bash burst's window.
131
+ windowBytes = Math.min(MAX_WINDOW_BYTES, windowBytes * 2);
132
+ }
133
+ windowLimited = false;
134
+ adjustmentBoundary = bytes + windowBytes;
135
+ }
136
+ void drain();
137
+ },
138
+ cancel(streamId) {
139
+ // Already-selected bytes may still arrive and must still be ACKed, but
140
+ // cancelled producers need not wait for an in-progress encryption call.
141
+ for (const entry of sending) if (entry.streamId === streamId) entry.resolve();
142
+ const queue = queues.get(streamId);
143
+ if (!queue) return;
144
+ queues.delete(streamId);
145
+ for (const entry of queue) {
146
+ queuedBytes -= entry.frame.length;
147
+ queuedFrames -= 1;
148
+ entry.resolve();
149
+ }
150
+ void drain();
151
+ },
152
+ close,
153
+ };
154
+ };
@@ -0,0 +1,162 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { createDownstreamScheduler, DOWNSTREAM_CHUNK_BYTES } from './downstream-scheduler.js';
3
+ import { encodeTunnelFrame, decodeTunnelFrame, TunnelFrameType } from './tunnel-codec.js';
4
+
5
+ const tick = () => new Promise(resolve => setTimeout(resolve, 0));
6
+ const body = (streamId, value = 0, size = DOWNSTREAM_CHUNK_BYTES) =>
7
+ encodeTunnelFrame(TunnelFrameType.HttpBody, streamId, new Uint8Array(size).fill(value));
8
+
9
+ function fixture() {
10
+ const sent = [];
11
+ const errors = [];
12
+ let time = 0;
13
+ const scheduler = createDownstreamScheduler({
14
+ sendBatch: async frames => { sent.push(...frames); },
15
+ onError: error => errors.push(error.message),
16
+ now: () => time,
17
+ });
18
+ return { scheduler, sent, errors, advance: ms => { time += ms; } };
19
+ }
20
+
21
+ describe('relay downstream scheduling', () => {
22
+ test('stops without end-client ACKs even when the relay socket accepts everything', async () => {
23
+ const { scheduler, sent } = fixture();
24
+ const pending = Array.from({ length: 100 }, () => scheduler.send(body(1)));
25
+ await tick();
26
+ const bytes = sent.reduce((sum, frame) => sum + frame.length, 0);
27
+ expect(bytes).toBeLessThanOrEqual(64 * 1024);
28
+ expect(bytes).toBeGreaterThan(48 * 1024);
29
+ await tick();
30
+ expect(sent.reduce((sum, frame) => sum + frame.length, 0)).toBe(bytes);
31
+ scheduler.acknowledge(bytes);
32
+ await tick();
33
+ expect(sent.reduce((sum, frame) => sum + frame.length, 0)).toBeGreaterThan(bytes);
34
+ scheduler.close();
35
+ await Promise.all(pending);
36
+ });
37
+
38
+ test('rotates streams while preserving every delta and each stream end', async () => {
39
+ const { scheduler, sent } = fixture();
40
+ const pending = [];
41
+ for (let value = 0; value < 3; value++) pending.push(scheduler.send(body(1, value)));
42
+ pending.push(scheduler.send(encodeTunnelFrame(TunnelFrameType.StreamEnd, 1, new Uint8Array())));
43
+ pending.push(scheduler.send(body(3, 99, 10)));
44
+ await Promise.all(pending);
45
+ expect(sent.map(frame => decodeTunnelFrame(frame).streamId)).toEqual([1, 3, 1, 1, 1]);
46
+ expect(sent.filter(frame => decodeTunnelFrame(frame).streamId === 1).map(frame => decodeTunnelFrame(frame).payload[0])).toEqual([0, 1, 2, undefined]);
47
+ scheduler.close();
48
+ });
49
+
50
+ test('keepalives bypass credit; cancellation and close release blocked producers', async () => {
51
+ const { scheduler, sent } = fixture();
52
+ const pending = Array.from({ length: 20 }, () => scheduler.send(body(1)));
53
+ await tick();
54
+ const before = sent.length;
55
+ await scheduler.send(encodeTunnelFrame(TunnelFrameType.Pong, 0, new Uint8Array()));
56
+ expect(sent.length).toBe(before + 1);
57
+ scheduler.cancel(1);
58
+ await Promise.all(pending);
59
+ const blocked = scheduler.send(body(3));
60
+ scheduler.close();
61
+ await blocked;
62
+ // Teardown makes late ACKs harmless.
63
+ scheduler.acknowledge(123);
64
+ });
65
+
66
+ test('rejects forged, replayed, regressing and partial-frame ACKs', async () => {
67
+ const { scheduler, sent } = fixture();
68
+ await scheduler.send(body(1));
69
+ const bytes = sent[0].length;
70
+ for (const value of [0, -1, 1, bytes + 1, NaN, Infinity]) {
71
+ expect(() => scheduler.acknowledge(value)).toThrow();
72
+ }
73
+ scheduler.acknowledge(bytes);
74
+ expect(() => scheduler.acknowledge(bytes)).toThrow();
75
+ scheduler.close();
76
+ });
77
+
78
+ test('bounds pending memory and reports overflow instead of silently dropping deltas', async () => {
79
+ const { scheduler, errors } = fixture();
80
+ const pending = Array.from({ length: 600 }, () => scheduler.send(body(1)));
81
+ await Promise.all(pending);
82
+ expect(errors).toEqual(['relay downstream queue limit exceeded']);
83
+ });
84
+
85
+ test('send failure settles selected and queued producers', async () => {
86
+ const errors = [];
87
+ const scheduler = createDownstreamScheduler({
88
+ sendBatch: async () => { throw new Error('wire failed'); },
89
+ onError: error => errors.push(error.message),
90
+ });
91
+ await Promise.all([scheduler.send(body(1)), scheduler.send(body(3))]);
92
+ expect(errors).toEqual(['wire failed']);
93
+ });
94
+
95
+ test('teardown releases a producer even while encryption is still pending', async () => {
96
+ let release;
97
+ let started = false;
98
+ const scheduler = createDownstreamScheduler({
99
+ sendBatch: () => new Promise(resolve => { started = true; release = resolve; }),
100
+ onError: error => { throw error; },
101
+ });
102
+ const pending = scheduler.send(body(1));
103
+ await tick();
104
+ expect(started).toBe(true);
105
+ scheduler.close();
106
+ await pending;
107
+ release();
108
+ await tick();
109
+ });
110
+
111
+ test('sparse traffic cannot inflate the window for a later bulk burst', async () => {
112
+ const { scheduler, sent, advance } = fixture();
113
+ let acknowledged = 0;
114
+ for (let i = 0; i < 100; i++) {
115
+ const frame = body(1);
116
+ await scheduler.send(frame);
117
+ advance(50);
118
+ acknowledged += frame.length;
119
+ scheduler.acknowledge(acknowledged);
120
+ }
121
+ const before = sent.length;
122
+ const pending = Array.from({ length: 100 }, () => scheduler.send(body(1)));
123
+ await tick();
124
+ expect(sent.slice(before).reduce((sum, frame) => sum + frame.length, 0)).toBeLessThanOrEqual(64 * 1024);
125
+ scheduler.close();
126
+ await Promise.all(pending);
127
+ });
128
+
129
+ test('adapts to a clear high-RTT path, then contracts when delivery backs up', async () => {
130
+ const { scheduler, sent, advance, errors } = fixture();
131
+ let acknowledged = 0;
132
+ let maxFlight = 0;
133
+ const pending = Array.from({ length: 400 }, () => scheduler.send(body(1)));
134
+ for (let round = 0; round < 12; round++) {
135
+ // Keep enough queued output to exercise growth all the way to the cap.
136
+ while (pending.length < sent.length + 400) pending.push(scheduler.send(body(1)));
137
+ await tick();
138
+ const delivered = sent.reduce((sum, frame) => sum + frame.length, 0);
139
+ maxFlight = Math.max(maxFlight, delivered - acknowledged);
140
+ expect(delivered - acknowledged).toBeLessThanOrEqual(1024 * 1024);
141
+ advance(200);
142
+ scheduler.acknowledge(delivered);
143
+ acknowledged = delivered;
144
+ }
145
+ // Reach the cap with a backlogged sender, allowing for whole-frame sizing.
146
+ expect(maxFlight).toBeGreaterThan(1024 * 1024 - body(1).length);
147
+ // Keep the sender backlogged while ACK latency jumps to a second.
148
+ for (let round = 0; round < 12; round++) {
149
+ for (let i = 0; i < 30; i++) pending.push(scheduler.send(body(1)));
150
+ await tick();
151
+ const delivered = sent.reduce((sum, frame) => sum + frame.length, 0);
152
+ advance(1000);
153
+ scheduler.acknowledge(delivered);
154
+ acknowledged = delivered;
155
+ }
156
+ await tick();
157
+ expect(sent.reduce((sum, frame) => sum + frame.length, 0) - acknowledged).toBeLessThanOrEqual(64 * 1024);
158
+ expect(errors).toEqual([]);
159
+ scheduler.close();
160
+ await Promise.all(pending);
161
+ });
162
+ });
@@ -252,11 +252,12 @@ const parseHandshakeMessage = (raw) => {
252
252
  if (parsed.v !== RELAY_PROTOCOL_VERSION) return null;
253
253
  // Unknown/missing capability flag = false = legacy behavior.
254
254
  const batch = parsed.batch === true;
255
+ const flowControl = parsed.flowControl === true;
255
256
  if (parsed.t === 'ready') {
256
- return { t: 'ready', v: RELAY_PROTOCOL_VERSION, batch };
257
+ return { t: 'ready', v: RELAY_PROTOCOL_VERSION, batch, flowControl };
257
258
  }
258
259
  if (parsed.t === 'hello' && typeof parsed.nonce === 'string' && typeof parsed.clientPubJwk === 'object' && parsed.clientPubJwk !== null) {
259
- return { t: 'hello', v: RELAY_PROTOCOL_VERSION, clientPubJwk: parsed.clientPubJwk, nonce: parsed.nonce, batch };
260
+ return { t: 'hello', v: RELAY_PROTOCOL_VERSION, clientPubJwk: parsed.clientPubJwk, nonce: parsed.nonce, batch, flowControl };
260
261
  }
261
262
  return null;
262
263
  };
@@ -275,7 +276,7 @@ const failClosed = (reason) => ({
275
276
  * { type: 'ignore' } — drop the frame
276
277
  * { type: 'fail', closeCode, reason } — close the socket with closeCode
277
278
  * @param {CryptoKey} hostEncPrivateKey long-lived ECDH private key
278
- * @param {{ batch?: boolean }} [options] `batch` defaults true; set false to force legacy behavior
279
+ * @param {{ batch?: boolean, flowControl?: boolean }} [options] Capabilities default true.
279
280
  */
280
281
  export const createHostHandshake = (hostEncPrivateKey, options = {}) => {
281
282
  const localBatch = options.batch !== false;
@@ -321,15 +322,16 @@ export const createHostHandshake = (hostEncPrivateKey, options = {}) => {
321
322
  acceptedClientKeyFingerprint = fingerprint;
322
323
  // Batching runs only if both peers advertised it.
323
324
  negotiatedBatch = localBatch && message.batch === true;
324
- readyText = JSON.stringify(
325
- negotiatedBatch
326
- ? { t: 'ready', v: RELAY_PROTOCOL_VERSION, batch: true }
327
- : { t: 'ready', v: RELAY_PROTOCOL_VERSION },
328
- );
325
+ const flowControl = options.flowControl !== false && message.flowControl === true;
326
+ const ready = { t: 'ready', v: RELAY_PROTOCOL_VERSION };
327
+ if (negotiatedBatch) ready.batch = true;
328
+ if (flowControl) ready.flowControl = true;
329
+ readyText = JSON.stringify(ready);
329
330
  established = true;
330
331
  return {
331
332
  type: 'established',
332
333
  batch: negotiatedBatch,
334
+ flowControl,
333
335
  replyText: readyText,
334
336
  channel: {
335
337
  encryptor: createFrameEncryptor(keys.hostToClient),
@@ -0,0 +1,177 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import http from 'node:http';
3
+ import { WebSocketServer } from 'ws';
4
+ import { startRelayHost } from './host-client.js';
5
+ import { generateEcdhKeyPair, exportPublicKeyJwk } from './e2ee.js';
6
+ import { createRelayTunnelClient } from '../../../../ui/src/lib/relay/tunnel-client.ts';
7
+
8
+ const waitFor = async predicate => {
9
+ for (let attempt = 0; attempt < 1000; attempt++) {
10
+ if (predicate()) return;
11
+ await new Promise(resolve => setTimeout(resolve, 5));
12
+ }
13
+ throw new Error('test condition timed out');
14
+ };
15
+
16
+ async function exercise({ clientFlow = true, hostFlow = true, batch = true, cancel = false, websocket = false } = {}) {
17
+ const total = 512 * 1024;
18
+ const content = Buffer.alloc(total, 'a');
19
+ content.fill('b', total / 2);
20
+ const upstream = http.createServer((req, res) => {
21
+ // HTTP bearer ownership is unchanged by flow control.
22
+ if (req.headers.authorization !== 'Bearer fixture-token') { res.writeHead(401); res.end(); return; }
23
+ res.end(req.url === '/health' ? 'ok' : content);
24
+ });
25
+ const upstreamWs = new WebSocketServer({ noServer: true });
26
+ upstream.on('upgrade', (req, socket, head) => {
27
+ // Mirror the real URL-token and loopback-origin gates for tunneled WS.
28
+ const query = new URL(req.url, 'http://localhost');
29
+ if (query.searchParams.get('oc_url_token') !== 'fixture-url-token' || req.headers.origin !== `http://127.0.0.1:${upstream.address().port}`) {
30
+ socket.end('HTTP/1.1 403 Forbidden\r\n\r\n');
31
+ return;
32
+ }
33
+ upstreamWs.handleUpgrade(req, socket, head, ws => {
34
+ ws.send(content.subarray(0, total / 2));
35
+ ws.send(content.subarray(total / 2));
36
+ // Let Bun finish the local write turn, then close while the throttled
37
+ // tunnel still has most of the two messages queued.
38
+ setTimeout(() => ws.close(1000, 'complete'), 20);
39
+ });
40
+ });
41
+ await new Promise(resolve => upstream.listen(0, '127.0.0.1', resolve));
42
+ const relay = new WebSocketServer({ host: '127.0.0.1', port: 0 });
43
+ await new Promise(resolve => relay.on('listening', resolve));
44
+ let control;
45
+ let clientSocket;
46
+ let hostData;
47
+ const waiting = [];
48
+ const down = [];
49
+ // This fixture brokers an isolated room only; it does not replace production
50
+ // relay authentication. Neither WebSocket leg fails or drops a frame.
51
+ relay.on('connection', (socket, req) => {
52
+ const role = new URL(req.url, 'http://localhost').searchParams.get('role');
53
+ if (role === 'host-control') {
54
+ control = socket;
55
+ socket.send(JSON.stringify({ type: 'sync', connectionIds: clientSocket ? ['fixture'] : [] }));
56
+ } else if (role === 'client') {
57
+ clientSocket = socket;
58
+ control?.send(JSON.stringify({ type: 'connected', connectionId: 'fixture' }));
59
+ socket.on('message', (data, binary) => {
60
+ if (hostData) hostData.send(data, { binary });
61
+ else waiting.push({ data, binary });
62
+ });
63
+ } else {
64
+ hostData = socket;
65
+ for (const frame of waiting.splice(0)) socket.send(frame.data, { binary: frame.binary });
66
+ socket.on('message', (data, binary) => {
67
+ down.push({ data, binary });
68
+ });
69
+ }
70
+ });
71
+ // Drain at a byte budget, not a frame count: frame-size changes cannot make
72
+ // the test's effective bandwidth change. Preserve whole WS messages in order.
73
+ let credit = 0;
74
+ const drain = setInterval(() => {
75
+ credit = Math.min(128 * 1024, credit + 4096);
76
+ while (down.length && down[0].data.length <= credit) {
77
+ const frame = down.shift();
78
+ credit -= frame.data.length;
79
+ clientSocket.send(frame.data, { binary: frame.binary });
80
+ }
81
+ }, 5);
82
+ const keys = await generateEcdhKeyPair();
83
+ const relayUrl = `ws://127.0.0.1:${relay.address().port}/ws`;
84
+ const host = startRelayHost({
85
+ relayUrl, localPort: upstream.address().port, batch, flowControl: hostFlow,
86
+ identity: { serverId: 'fixture', hostEncPrivateKey: keys.privateKey, signRelayAuth: () => ({ ts: 0, sig: '', pk: '' }) },
87
+ });
88
+ const client = createRelayTunnelClient({ relayUrl, serverId: 'fixture', hostEncPubJwk: await exportPublicKeyJwk(keys.publicKey), batch, flowControl: clientFlow });
89
+ const states = [];
90
+ client.subscribeStatus(status => states.push(status.state));
91
+ const headers = { authorization: 'Bearer fixture-token' };
92
+ let received = 0;
93
+ let finished = false;
94
+ let wsMessages = 0;
95
+ let streamFailure;
96
+ const chunks = [];
97
+ const abort = new AbortController();
98
+ try {
99
+ await waitFor(() => client.getStatus().state === 'connected');
100
+ let stream;
101
+ if (websocket) {
102
+ const socket = client.openWebSocket('/api/terminal/ws?oc_url_token=fixture-url-token');
103
+ stream = new Promise((resolve, reject) => {
104
+ socket.onmessage = event => {
105
+ const bytes = new Uint8Array(event.data);
106
+ chunks.push(bytes);
107
+ received += bytes.length;
108
+ wsMessages++;
109
+ };
110
+ socket.onclose = event => {
111
+ finished = true;
112
+ if (event.code !== 1000) reject(new Error(event.reason));
113
+ else resolve();
114
+ };
115
+ });
116
+ } else {
117
+ const response = await client.fetch('/api/global/event', { headers, signal: abort.signal });
118
+ stream = (async () => {
119
+ try {
120
+ for await (const chunk of response.body) { chunks.push(chunk); received += chunk.length; }
121
+ } catch (error) {
122
+ if (!cancel || error.name !== 'AbortError') throw error;
123
+ } finally { finished = true; }
124
+ })();
125
+ }
126
+ stream = stream.catch(error => { streamFailure = error; });
127
+ await waitFor(() => received > 0 || streamFailure);
128
+ if (streamFailure) throw streamFailure;
129
+ if (cancel) abort.abort();
130
+ const response = await client.fetch('/health', { headers, signal: AbortSignal.timeout(5000) });
131
+ expect(await response.text()).toBe('ok');
132
+ const bytesAtProbe = received;
133
+ await stream;
134
+ expect(streamFailure).toBeUndefined();
135
+ expect(finished).toBe(true);
136
+ if (!cancel) expect(Buffer.concat(chunks).equals(content)).toBe(true);
137
+ if (websocket) expect(wsMessages).toBe(2);
138
+ expect(states).toEqual(['connected']);
139
+ return { bytesAtProbe, total };
140
+ } finally {
141
+ client.close();
142
+ host.stop();
143
+ clearInterval(drain);
144
+ for (const ws of relay.clients) ws.terminate();
145
+ relay.close();
146
+ for (const ws of upstreamWs.clients) ws.terminate();
147
+ upstreamWs.close();
148
+ upstream.closeAllConnections();
149
+ upstream.close();
150
+ }
151
+ }
152
+
153
+ describe('end-to-end downstream credit', () => {
154
+ test('small HTTP response overtakes bulk output without losing bytes or reconnecting', async () => {
155
+ const result = await exercise();
156
+ expect(result.bytesAtProbe).toBeLessThan(result.total / 2);
157
+ // Queue peaks depend on real ACK timing and include encryption overhead.
158
+ // downstream-scheduler.test.js checks credit bounds with a controlled clock.
159
+ });
160
+ test('works without batching', async () => {
161
+ const result = await exercise({ batch: false });
162
+ expect(result.bytesAtProbe).toBeLessThan(result.total / 2);
163
+ });
164
+ test('cancelling a blocked stream leaves unrelated requests usable', async () => {
165
+ const result = await exercise({ cancel: true });
166
+ expect(result.bytesAtProbe).toBeLessThan(result.total);
167
+ });
168
+ test('fragmented WS messages remain complete and precede the close', async () => {
169
+ await exercise({ websocket: true });
170
+ });
171
+ for (const legacy of [{ clientFlow: false }, { hostFlow: false }]) {
172
+ test(`legacy fallback ${JSON.stringify(legacy)} preserves delivery`, async () => {
173
+ const result = await exercise(legacy);
174
+ expect(result.bytesAtProbe).toBe(result.total);
175
+ });
176
+ }
177
+ });
@@ -4,10 +4,12 @@
4
4
  // dispatcher. Spec: .opencode/plans/private-relay/01-protocol-spec.md (Layer 1).
5
5
 
6
6
  import { WebSocket } from 'ws';
7
+ import { createRequire } from 'node:module';
7
8
 
8
9
  import { RELAY_PROTOCOL_VERSION, RelayCloseCode, createHostHandshake } from './e2ee.js';
9
- import { createOutboundFrameBatcher, decodeFrameBatch } from './tunnel-codec.js';
10
+ import { createOutboundFrameBatcher, decodeFrameBatch, decodeTunnelFrame, decodeDeliveryAck, encodeFrameBatch, TunnelFrameType } from './tunnel-codec.js';
10
11
  import { createTunnelHost } from './tunnel-host.js';
12
+ import { createDownstreamScheduler, DOWNSTREAM_CHUNK_BYTES } from './downstream-scheduler.js';
11
13
 
12
14
  const BACKOFF_BASE_MS = 1000;
13
15
  const BACKOFF_CAP_MS = 30000;
@@ -48,7 +50,9 @@ const resolveBatchWindowMs = (option) => {
48
50
  * logger?: Pick<Console, 'warn'>,
49
51
  * }} options
50
52
  */
51
- export const startRelayHost = ({ relayUrl, identity, localPort, getLocalPort, onStatus, logger = console, batchWindowMs, batch }) => {
53
+ export const startRelayHost = ({ relayUrl, identity, localPort, getLocalPort, onStatus, logger = console, batchWindowMs, batch, flowControl }) => {
54
+ const { version } = createRequire(import.meta.url)('../../../package.json');
55
+ const platform = process.env.AIWORKER_RUNTIME || 'web';
52
56
  const resolveLocalPort = typeof getLocalPort === 'function' ? getLocalPort : () => localPort;
53
57
  const localBatch = batch !== false;
54
58
  const resolvedBatchWindowMs = resolveBatchWindowMs(batchWindowMs);
@@ -81,6 +85,10 @@ export const startRelayHost = ({ relayUrl, identity, localPort, getLocalPort, on
81
85
  url.searchParams.set('v', String(RELAY_PROTOCOL_VERSION));
82
86
  url.searchParams.set('role', role);
83
87
  url.searchParams.set('serverId', identity.serverId);
88
+ // Self-reported diagnostics, not part of relay authentication.
89
+ url.searchParams.set('appId', 'aiworker');
90
+ url.searchParams.set('appVersion', version);
91
+ url.searchParams.set('platform', platform);
84
92
  if (connectionId) url.searchParams.set('connectionId', connectionId);
85
93
  const auth = identity.signRelayAuth(role, connectionId ?? null);
86
94
  url.searchParams.set('ts', String(auth.ts));
@@ -95,6 +103,7 @@ export const startRelayHost = ({ relayUrl, identity, localPort, getLocalPort, on
95
103
  dataSockets.delete(connectionId);
96
104
  if (entry.openTimer) clearTimeout(entry.openTimer);
97
105
  entry.batcher?.dispose();
106
+ entry.scheduler?.close();
98
107
  entry.tunnel?.close();
99
108
  try {
100
109
  if (entry.socket.readyState === WebSocket.OPEN || entry.socket.readyState === WebSocket.CONNECTING) {
@@ -118,14 +127,14 @@ export const startRelayHost = ({ relayUrl, identity, localPort, getLocalPort, on
118
127
  return;
119
128
  }
120
129
 
121
- const entry = { socket, tunnel: null, openTimer: null, batcher: null, lastActivityAt: Date.now() };
130
+ const entry = { socket, tunnel: null, openTimer: null, batcher: null, scheduler: null, lastActivityAt: Date.now() };
122
131
  dataSockets.set(connectionId, entry);
123
132
  entry.openTimer = setTimeout(() => {
124
133
  logger.warn('[Relay] host-data socket open timeout');
125
134
  teardownDataSocket(connectionId);
126
135
  }, DATA_SOCKET_OPEN_TIMEOUT_MS);
127
136
 
128
- const handshake = createHostHandshake(identity.hostEncPrivateKey, { batch: localBatch });
137
+ const handshake = createHostHandshake(identity.hostEncPrivateKey, { batch: localBatch, flowControl });
129
138
  let channel = null;
130
139
  let batchNegotiated = false;
131
140
  // Serialize async message handling so encrypted frame order (and the
@@ -140,11 +149,14 @@ export const startRelayHost = ({ relayUrl, identity, localPort, getLocalPort, on
140
149
  .then(async () => {
141
150
  if (dataSockets.get(connectionId) !== entry || socket.readyState !== WebSocket.OPEN || !channel) return;
142
151
  const encrypted = await channel.encryptor.encrypt(plaintext);
152
+ if (dataSockets.get(connectionId) !== entry || socket.readyState !== WebSocket.OPEN) return;
143
153
  socket.send(encrypted, { binary: true });
144
154
  })
145
155
  .catch((error) => {
146
156
  logger.warn(`[Relay] host-data send failed: ${error?.message ?? error}`);
157
+ failChannel(RelayCloseCode.ChannelFailure, 'send failed');
147
158
  });
159
+ return sendChain;
148
160
  };
149
161
 
150
162
  const failChannel = (closeCode, reason) => {
@@ -167,17 +179,25 @@ export const startRelayHost = ({ relayUrl, identity, localPort, getLocalPort, on
167
179
  } else if (action.type === 'established') {
168
180
  channel = action.channel;
169
181
  batchNegotiated = action.batch === true;
170
- entry.batcher = batchNegotiated
182
+ entry.scheduler = action.flowControl ? createDownstreamScheduler({
183
+ sendBatch: frames => sendEncryptedPlaintext(batchNegotiated ? encodeFrameBatch(frames) : frames[0]),
184
+ maxBatchFrames: batchNegotiated ? 32 : 1,
185
+ onError: () => failChannel(RelayCloseCode.ChannelFailure, 'downstream queue failed'),
186
+ }) : null;
187
+ entry.batcher = batchNegotiated && !entry.scheduler
171
188
  ? createOutboundFrameBatcher({ windowMs: resolvedBatchWindowMs, sendBatch: sendEncryptedPlaintext })
172
189
  : null;
173
190
  entry.tunnel = createTunnelHost({
174
191
  connectionId,
175
192
  getLocalPort: resolveLocalPort,
176
193
  getBufferedAmount: () => socket.bufferedAmount,
194
+ responseChunkBytes: entry.scheduler ? DOWNSTREAM_CHUNK_BYTES : undefined,
195
+ cancelPendingFrames: streamId => entry.scheduler?.cancel(streamId),
177
196
  sendFrame: (plaintextFrame) => {
178
197
  if (dataSockets.get(connectionId) !== entry || socket.readyState !== WebSocket.OPEN) return;
198
+ if (entry.scheduler) return entry.scheduler.send(plaintextFrame);
179
199
  if (entry.batcher) entry.batcher.enqueue(plaintextFrame);
180
- else sendEncryptedPlaintext(plaintextFrame);
200
+ else return sendEncryptedPlaintext(plaintextFrame);
181
201
  },
182
202
  });
183
203
  if (action.replyText) socket.send(action.replyText);
@@ -205,16 +225,32 @@ export const startRelayHost = ({ relayUrl, identity, localPort, getLocalPort, on
205
225
  // in order through the same per-frame handling as legacy.
206
226
  for (const frame of decodeFrameBatch(plaintext)) {
207
227
  if (dataSockets.get(connectionId) !== entry) return;
208
- await entry.tunnel.handleFrame(frame);
228
+ await dispatchFrame(frame);
209
229
  }
210
230
  } else {
211
- await entry.tunnel.handleFrame(plaintext);
231
+ await dispatchFrame(plaintext);
212
232
  }
213
233
  } catch (error) {
214
234
  logger.warn(`[Relay] tunnel frame handling failed: ${error?.message ?? error}`);
235
+ failChannel(RelayCloseCode.ChannelFailure, 'invalid tunnel frame');
215
236
  }
216
237
  };
217
238
 
239
+ const dispatchFrame = (plaintext) => {
240
+ const frame = decodeTunnelFrame(plaintext);
241
+ if (frame.frameType === TunnelFrameType.DeliveryAck) {
242
+ if (!entry.scheduler || frame.streamId !== 0 || frame.hasMoreFragments) {
243
+ throw new Error('unexpected delivery acknowledgement');
244
+ }
245
+ entry.scheduler.acknowledge(decodeDeliveryAck(frame.payload));
246
+ return;
247
+ }
248
+ // Never await outbound credit on the receive chain: ACKs use this chain too.
249
+ void entry.tunnel.handleFrame(plaintext).catch(() => {
250
+ failChannel(RelayCloseCode.ChannelFailure, 'invalid tunnel frame');
251
+ });
252
+ };
253
+
218
254
  socket.on('open', () => {
219
255
  if (entry.openTimer) {
220
256
  clearTimeout(entry.openTimer);