staff-mcp 1.2.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +29 -0
- package/README.md +178 -49
- package/README_zh.md +176 -47
- package/dist/src/data/HttpDataSession.d.ts +22 -0
- package/dist/src/data/HttpDataSession.js +925 -0
- package/dist/src/data/HttpDataSession.js.map +1 -0
- package/dist/src/data/node.d.ts +4 -0
- package/dist/src/data/node.js +93 -0
- package/dist/src/data/node.js.map +1 -0
- package/dist/src/data/types.d.ts +54 -0
- package/dist/src/data/types.js +2 -0
- package/dist/src/data/types.js.map +1 -0
- package/dist/src/data-stdio-docker.d.ts +10 -0
- package/dist/src/data-stdio-docker.js +140 -0
- package/dist/src/data-stdio-docker.js.map +1 -0
- package/dist/src/docker-proxy.d.ts +6 -1
- package/dist/src/docker-proxy.js +324 -147
- package/dist/src/docker-proxy.js.map +1 -1
- package/dist/src/extensions/apps.d.ts +12 -0
- package/dist/src/extensions/apps.js +85 -0
- package/dist/src/extensions/apps.js.map +1 -0
- package/dist/src/extensions/config.d.ts +27 -0
- package/dist/src/extensions/config.js +91 -0
- package/dist/src/extensions/config.js.map +1 -0
- package/dist/src/extensions/protocol.d.ts +35 -0
- package/dist/src/extensions/protocol.js +47 -0
- package/dist/src/extensions/protocol.js.map +1 -0
- package/dist/src/extensions/runtime.d.ts +16 -0
- package/dist/src/extensions/runtime.js +266 -0
- package/dist/src/extensions/runtime.js.map +1 -0
- package/dist/src/file-manager/extension.d.ts +21 -0
- package/dist/src/file-manager/extension.js +514 -0
- package/dist/src/file-manager/extension.js.map +1 -0
- package/dist/src/file-manager/file-core.d.ts +128 -0
- package/dist/src/file-manager/file-core.js +750 -0
- package/dist/src/file-manager/file-core.js.map +1 -0
- package/dist/src/file-manager/transfers.d.ts +423 -0
- package/dist/src/file-manager/transfers.js +540 -0
- package/dist/src/file-manager/transfers.js.map +1 -0
- package/dist/src/file-manager/ui/index.html +81 -0
- package/dist/src/index.js +289 -128
- package/dist/src/index.js.map +1 -1
- package/dist/src/lsp/manager.d.ts +2 -0
- package/dist/src/lsp/manager.js +22 -10
- package/dist/src/lsp/manager.js.map +1 -1
- package/dist/src/reverse/SseTransport.d.ts +34 -0
- package/dist/src/reverse/SseTransport.js +215 -0
- package/dist/src/reverse/SseTransport.js.map +1 -0
- package/dist/src/reverse/http.d.ts +7 -0
- package/dist/src/reverse/http.js +85 -0
- package/dist/src/reverse/http.js.map +1 -0
- package/dist/src/server.d.ts +4 -2
- package/dist/src/server.js +80 -18
- package/dist/src/server.js.map +1 -1
- package/dist/src/terminal/extension.d.ts +4 -0
- package/dist/src/terminal/extension.js +108 -0
- package/dist/src/terminal/extension.js.map +1 -0
- package/dist/src/terminal/pty.d.ts +14 -0
- package/dist/src/terminal/pty.js +121 -0
- package/dist/src/terminal/pty.js.map +1 -0
- package/dist/src/terminal/session.d.ts +136 -0
- package/dist/src/terminal/session.js +422 -0
- package/dist/src/terminal/session.js.map +1 -0
- package/dist/src/terminal/ui/index.html +183 -0
- package/dist/src/tools/file-tools.d.ts +2 -1
- package/dist/src/tools/file-tools.js +19 -7
- package/dist/src/tools/file-tools.js.map +1 -1
- package/dist/src/tools/mcp-client-tools.js +244 -104
- package/dist/src/tools/mcp-client-tools.js.map +1 -1
- package/dist/src/tools/shell-tools.js +1 -1
- package/dist/src/tools/shell-tools.js.map +1 -1
- package/dist/src/tools/skills.js +26 -1
- package/dist/src/tools/skills.js.map +1 -1
- package/dist/src/tools/system-tools.d.ts +9 -3
- package/dist/src/tools/system-tools.js +31 -48
- package/dist/src/tools/system-tools.js.map +1 -1
- package/dist/src/transports/http.d.ts +16 -2
- package/dist/src/transports/http.js +261 -59
- package/dist/src/transports/http.js.map +1 -1
- package/dist/src/transports/reverse.d.ts +23 -19
- package/dist/src/transports/reverse.js +130 -74
- package/dist/src/transports/reverse.js.map +1 -1
- package/dist/src/transports/stdio.d.ts +6 -2
- package/dist/src/transports/stdio.js +131 -19
- package/dist/src/transports/stdio.js.map +1 -1
- package/dist/src/utils/proxy.d.ts +3 -3
- package/dist/src/utils/proxy.js +84 -62
- package/dist/src/utils/proxy.js.map +1 -1
- package/dist/src/utils/server-activity.d.ts +6 -0
- package/dist/src/utils/server-activity.js +46 -0
- package/dist/src/utils/server-activity.js.map +1 -0
- package/dist/src/utils/server-lifecycle.d.ts +5 -0
- package/dist/src/utils/server-lifecycle.js +51 -0
- package/dist/src/utils/server-lifecycle.js.map +1 -0
- package/docs/file-manager.md +179 -0
- package/docs/terminal.md +66 -0
- package/package.json +40 -19
- package/dist/src/transports/fte-lifecycle.d.ts +0 -13
- package/dist/src/transports/fte-lifecycle.js +0 -76
- package/dist/src/transports/fte-lifecycle.js.map +0 -1
- package/dist/tests/docker-proxy.test.d.ts +0 -1
- package/dist/tests/docker-proxy.test.js +0 -395
- package/dist/tests/docker-proxy.test.js.map +0 -1
- package/dist/tests/file-write-queue.test.d.ts +0 -1
- package/dist/tests/file-write-queue.test.js +0 -105
- package/dist/tests/file-write-queue.test.js.map +0 -1
- package/dist/tests/fte-lifecycle.test.d.ts +0 -1
- package/dist/tests/fte-lifecycle.test.js +0 -118
- package/dist/tests/fte-lifecycle.test.js.map +0 -1
- package/dist/tests/lsp-client.test.d.ts +0 -1
- package/dist/tests/lsp-client.test.js +0 -52
- package/dist/tests/lsp-client.test.js.map +0 -1
- package/dist/tests/package-info.test.d.ts +0 -1
- package/dist/tests/package-info.test.js +0 -54
- package/dist/tests/package-info.test.js.map +0 -1
- package/dist/tests/proxy-no-proxy.test.d.ts +0 -1
- package/dist/tests/proxy-no-proxy.test.js +0 -156
- package/dist/tests/proxy-no-proxy.test.js.map +0 -1
- package/dist/tests/proxy.test.d.ts +0 -1
- package/dist/tests/proxy.test.js +0 -89
- package/dist/tests/proxy.test.js.map +0 -1
- package/dist/tests/reverse-reconnect.test.d.ts +0 -1
- package/dist/tests/reverse-reconnect.test.js +0 -89
- package/dist/tests/reverse-reconnect.test.js.map +0 -1
- package/dist/tests/security.test.d.ts +0 -1
- package/dist/tests/security.test.js +0 -21
- package/dist/tests/security.test.js.map +0 -1
- package/dist/tests/shell-tools.test.d.ts +0 -1
- package/dist/tests/shell-tools.test.js +0 -113
- package/dist/tests/shell-tools.test.js.map +0 -1
- package/dist/tests/test_files/test.d.ts +0 -1
- package/dist/tests/test_files/test.js +0 -4
- package/dist/tests/test_files/test.js.map +0 -1
|
@@ -0,0 +1,925 @@
|
|
|
1
|
+
import { randomBytes, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
import { setImmediate as yieldImmediate } from 'node:timers/promises';
|
|
3
|
+
export const DATA_OPEN_METHOD = 'cc.qqxing/data/v1/open';
|
|
4
|
+
const CHUNK = 64 * 1024;
|
|
5
|
+
const WINDOW = 256 * 1024;
|
|
6
|
+
const HEADERS = { 'Cache-Control': 'no-store' };
|
|
7
|
+
function deferred() {
|
|
8
|
+
let resolve;
|
|
9
|
+
let reject;
|
|
10
|
+
const promise = new Promise((yes, no) => {
|
|
11
|
+
resolve = yes;
|
|
12
|
+
reject = no;
|
|
13
|
+
});
|
|
14
|
+
void promise.catch(() => { });
|
|
15
|
+
return { promise, resolve, reject };
|
|
16
|
+
}
|
|
17
|
+
function check(value, message) {
|
|
18
|
+
if (!value)
|
|
19
|
+
throw new Error(message);
|
|
20
|
+
}
|
|
21
|
+
function errorOf(error) {
|
|
22
|
+
return error instanceof Error ? error : new Error(String(error));
|
|
23
|
+
}
|
|
24
|
+
function validPurpose(value) {
|
|
25
|
+
return typeof value === 'string' && value.length > 0 && value.length <= 256;
|
|
26
|
+
}
|
|
27
|
+
function validOpen(value, supportsPurpose) {
|
|
28
|
+
const open = value;
|
|
29
|
+
check(open &&
|
|
30
|
+
validPurpose(open.purpose) &&
|
|
31
|
+
typeof open.resourceId === 'string' &&
|
|
32
|
+
open.resourceId.length > 0 &&
|
|
33
|
+
open.resourceId.length <= 256 &&
|
|
34
|
+
['host-to-remote', 'remote-to-host', 'duplex'].includes(open.direction), 'Invalid data channel metadata');
|
|
35
|
+
check(supportsPurpose(open.purpose), 'Unsupported data channel purpose');
|
|
36
|
+
return {
|
|
37
|
+
purpose: open.purpose,
|
|
38
|
+
resourceId: open.resourceId,
|
|
39
|
+
direction: open.direction,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
function sameOpen(left, right) {
|
|
43
|
+
return (left.purpose === right.purpose &&
|
|
44
|
+
left.resourceId === right.resourceId &&
|
|
45
|
+
left.direction === right.direction);
|
|
46
|
+
}
|
|
47
|
+
function endpointUrl(value) {
|
|
48
|
+
const url = new URL(value);
|
|
49
|
+
check(['http:', 'https:'].includes(url.protocol) &&
|
|
50
|
+
!url.username &&
|
|
51
|
+
!url.password &&
|
|
52
|
+
!url.search &&
|
|
53
|
+
!url.hash &&
|
|
54
|
+
url.pathname !== '/' &&
|
|
55
|
+
!url.pathname.endsWith('/'), 'Invalid data endpoint');
|
|
56
|
+
check(url.href === value, 'Data endpoint must be canonical');
|
|
57
|
+
return url;
|
|
58
|
+
}
|
|
59
|
+
/** One logical channel; HTTP legs never invoke a second business accept. */
|
|
60
|
+
class HttpChannel {
|
|
61
|
+
link;
|
|
62
|
+
options;
|
|
63
|
+
serving;
|
|
64
|
+
release;
|
|
65
|
+
completion = deferred();
|
|
66
|
+
receiverReady = deferred();
|
|
67
|
+
changed = deferred();
|
|
68
|
+
connected = deferred();
|
|
69
|
+
closed = this.completion.promise;
|
|
70
|
+
abort = new AbortController();
|
|
71
|
+
outgoing;
|
|
72
|
+
acknowledgments;
|
|
73
|
+
output;
|
|
74
|
+
ackOutput;
|
|
75
|
+
receiver;
|
|
76
|
+
writing;
|
|
77
|
+
reading;
|
|
78
|
+
legs = new Set();
|
|
79
|
+
sent = 0;
|
|
80
|
+
consumed = 0;
|
|
81
|
+
received = 0;
|
|
82
|
+
ackSequence = 0;
|
|
83
|
+
ackPending = false;
|
|
84
|
+
ackFinished = false;
|
|
85
|
+
ackWanted;
|
|
86
|
+
ackSending;
|
|
87
|
+
receiveSequence = 0;
|
|
88
|
+
ending = false;
|
|
89
|
+
outputEnded = false;
|
|
90
|
+
inputEnded = false;
|
|
91
|
+
writingNow = false;
|
|
92
|
+
writeFinalized = false;
|
|
93
|
+
ackDemand = deferred();
|
|
94
|
+
consuming = false;
|
|
95
|
+
disposed = false;
|
|
96
|
+
activated = false;
|
|
97
|
+
transportReady = false;
|
|
98
|
+
failure;
|
|
99
|
+
lastActivity = performance.now();
|
|
100
|
+
ticketDeadline;
|
|
101
|
+
openTimer;
|
|
102
|
+
idleTimer;
|
|
103
|
+
upload;
|
|
104
|
+
acknowledgmentsRead;
|
|
105
|
+
bytesSinceYield = 0;
|
|
106
|
+
constructor(link, options, serving, release) {
|
|
107
|
+
this.link = link;
|
|
108
|
+
this.options = options;
|
|
109
|
+
this.serving = serving;
|
|
110
|
+
this.release = release;
|
|
111
|
+
this.ticketDeadline = performance.now() + link.expiresInMs;
|
|
112
|
+
const host = options.role === 'host';
|
|
113
|
+
this.writing =
|
|
114
|
+
link.open.direction === 'duplex' ||
|
|
115
|
+
link.open.direction ===
|
|
116
|
+
(host ? 'host-to-remote' : 'remote-to-host');
|
|
117
|
+
this.reading = link.open.direction === 'duplex' || !this.writing;
|
|
118
|
+
this.outputEnded = !this.writing;
|
|
119
|
+
this.writeFinalized = !this.writing;
|
|
120
|
+
this.inputEnded = !this.reading;
|
|
121
|
+
this.outgoing = new ReadableStream({
|
|
122
|
+
start: (controller) => {
|
|
123
|
+
this.output = controller;
|
|
124
|
+
},
|
|
125
|
+
cancel: () => this.close('HTTP data reader cancelled'),
|
|
126
|
+
}, { highWaterMark: 0 });
|
|
127
|
+
this.acknowledgments = new ReadableStream({
|
|
128
|
+
start: (controller) => {
|
|
129
|
+
this.ackOutput = controller;
|
|
130
|
+
},
|
|
131
|
+
pull: () => {
|
|
132
|
+
this.ackDemand.resolve();
|
|
133
|
+
},
|
|
134
|
+
cancel: () => {
|
|
135
|
+
if (!this.ackFinished)
|
|
136
|
+
this.close('HTTP acknowledgment reader cancelled');
|
|
137
|
+
},
|
|
138
|
+
}, { highWaterMark: 0 });
|
|
139
|
+
this.openTimer = setTimeout(() => this.close('HTTP data legs timed out'), options.openTimeoutMs ?? 15_000);
|
|
140
|
+
const idle = options.idleTimeoutMs ?? 60_000;
|
|
141
|
+
this.idleTimer = setInterval(() => {
|
|
142
|
+
if (this.pendingConsumption() &&
|
|
143
|
+
performance.now() - this.lastActivity >= idle)
|
|
144
|
+
this.close('HTTP data consumption timed out');
|
|
145
|
+
}, Math.min(idle, 1_000));
|
|
146
|
+
this.openTimer.unref?.();
|
|
147
|
+
this.idleTimer.unref?.();
|
|
148
|
+
}
|
|
149
|
+
setReceiver(receiver) {
|
|
150
|
+
if (this.disposed) {
|
|
151
|
+
receiver.close(this.failure);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
this.receiver = receiver;
|
|
155
|
+
this.receiverReady.resolve(receiver);
|
|
156
|
+
}
|
|
157
|
+
assertAlive() {
|
|
158
|
+
if (this.disposed)
|
|
159
|
+
throw this.failure ?? new Error('Data channel already closed');
|
|
160
|
+
}
|
|
161
|
+
pendingConsumption() {
|
|
162
|
+
return (this.sent > this.consumed ||
|
|
163
|
+
(this.ending && !this.writeFinalized) ||
|
|
164
|
+
this.consuming ||
|
|
165
|
+
this.ackPending ||
|
|
166
|
+
this.ackSending !== undefined);
|
|
167
|
+
}
|
|
168
|
+
startConsumption() {
|
|
169
|
+
// A healthy idle PTY is governed by its business lease, not an extra
|
|
170
|
+
// data-plane lease. Start a fresh progress deadline when work appears.
|
|
171
|
+
if (!this.pendingConsumption())
|
|
172
|
+
this.lastActivity = performance.now();
|
|
173
|
+
}
|
|
174
|
+
pulse() {
|
|
175
|
+
this.lastActivity = performance.now();
|
|
176
|
+
this.changed.resolve();
|
|
177
|
+
this.changed = deferred();
|
|
178
|
+
}
|
|
179
|
+
async wait(promise) {
|
|
180
|
+
this.assertAlive();
|
|
181
|
+
// A cancellable waiter, not Promise.race(closed): the latter retains one
|
|
182
|
+
// closed.then callback per chunk until a potentially hours-long channel ends.
|
|
183
|
+
return new Promise((resolve, reject) => {
|
|
184
|
+
const signal = this.abort.signal;
|
|
185
|
+
const cancel = () => reject(this.failure ?? new Error('Data channel closed'));
|
|
186
|
+
signal.addEventListener('abort', cancel, { once: true });
|
|
187
|
+
promise.then((value) => {
|
|
188
|
+
signal.removeEventListener('abort', cancel);
|
|
189
|
+
resolve(value);
|
|
190
|
+
}, (error) => {
|
|
191
|
+
signal.removeEventListener('abort', cancel);
|
|
192
|
+
reject(error);
|
|
193
|
+
});
|
|
194
|
+
if (signal.aborted)
|
|
195
|
+
cancel();
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
attachSignal(signal) {
|
|
199
|
+
if (!signal)
|
|
200
|
+
return;
|
|
201
|
+
const cancel = () => this.close('Data channel aborted');
|
|
202
|
+
if (signal.aborted)
|
|
203
|
+
cancel();
|
|
204
|
+
else {
|
|
205
|
+
signal.addEventListener('abort', cancel, { once: true });
|
|
206
|
+
void this.closed
|
|
207
|
+
.finally(() => signal.removeEventListener('abort', cancel))
|
|
208
|
+
.catch(() => { });
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
claim(leg) {
|
|
212
|
+
this.assertAlive();
|
|
213
|
+
check(!this.legs.has(leg), 'Data ticket already used');
|
|
214
|
+
check(leg === 'ready' || (leg === 'get' ? this.writing : this.reading), 'Wrong data direction');
|
|
215
|
+
this.legs.add(leg);
|
|
216
|
+
// A real metadata response proves ticket admission without requiring a
|
|
217
|
+
// first data/ACK chunk (Next does not flush streaming GET headers early).
|
|
218
|
+
// PUT may be lazy until its first byte/EOF; reserve it with ready, then
|
|
219
|
+
// enforce its own single-use claim and the consumption idle deadline.
|
|
220
|
+
const ready = this.legs.has('ready') &&
|
|
221
|
+
(!this.writing || this.legs.has('get')) &&
|
|
222
|
+
(!this.reading || this.legs.has('ack'));
|
|
223
|
+
if (ready) {
|
|
224
|
+
this.transportReady = true;
|
|
225
|
+
if (this.activated)
|
|
226
|
+
clearTimeout(this.openTimer);
|
|
227
|
+
this.connected.resolve();
|
|
228
|
+
}
|
|
229
|
+
this.pulse();
|
|
230
|
+
}
|
|
231
|
+
ticketExpired() {
|
|
232
|
+
// The single-use ready claim redeems the ticket; it cannot renew it.
|
|
233
|
+
// A reserved lazy PUT can attach later while the logical channel lives.
|
|
234
|
+
return (!this.legs.has('ready') && performance.now() >= this.ticketDeadline);
|
|
235
|
+
}
|
|
236
|
+
async waitConnected() {
|
|
237
|
+
await this.wait(this.connected.promise);
|
|
238
|
+
}
|
|
239
|
+
activate() {
|
|
240
|
+
this.assertAlive();
|
|
241
|
+
this.activated = true;
|
|
242
|
+
if (this.transportReady)
|
|
243
|
+
clearTimeout(this.openTimer);
|
|
244
|
+
this.finish();
|
|
245
|
+
}
|
|
246
|
+
clientConnected() {
|
|
247
|
+
this.transportReady = true;
|
|
248
|
+
if (this.activated)
|
|
249
|
+
clearTimeout(this.openTimer);
|
|
250
|
+
this.connected.resolve();
|
|
251
|
+
this.pulse();
|
|
252
|
+
}
|
|
253
|
+
async fairness(bytes) {
|
|
254
|
+
this.bytesSinceYield += bytes;
|
|
255
|
+
if (this.bytesSinceYield >= WINDOW) {
|
|
256
|
+
this.bytesSinceYield %= WINDOW;
|
|
257
|
+
await this.wait(yieldImmediate());
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
async write(bytes) {
|
|
261
|
+
this.assertAlive();
|
|
262
|
+
check(this.writing && !this.ending, 'Data channel is not writable');
|
|
263
|
+
// No unbounded write queue retaining arbitrary caller buffers.
|
|
264
|
+
check(!this.writingNow, 'Concurrent data writes are not supported');
|
|
265
|
+
this.writingNow = true;
|
|
266
|
+
try {
|
|
267
|
+
await this.wait(this.connected.promise);
|
|
268
|
+
for (let offset = 0; offset < bytes.byteLength; offset += CHUNK) {
|
|
269
|
+
this.assertAlive();
|
|
270
|
+
const length = Math.min(CHUNK, bytes.byteLength - offset);
|
|
271
|
+
while (this.sent - this.consumed + length > WINDOW) {
|
|
272
|
+
await this.wait(this.changed.promise);
|
|
273
|
+
}
|
|
274
|
+
// Buffer.slice aliases memory, so always make an owned Uint8Array copy.
|
|
275
|
+
const chunk = new Uint8Array(length);
|
|
276
|
+
chunk.set(bytes.subarray(offset, offset + length));
|
|
277
|
+
this.startConsumption();
|
|
278
|
+
this.sent += chunk.byteLength;
|
|
279
|
+
check(Number.isSafeInteger(this.sent), 'Data offset overflow');
|
|
280
|
+
this.output.enqueue(chunk);
|
|
281
|
+
// Copy/queue admission only; credit advances exclusively on receiver consumption ACK.
|
|
282
|
+
await this.fairness(chunk.byteLength);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
catch (error) {
|
|
286
|
+
this.fail(errorOf(error));
|
|
287
|
+
throw error;
|
|
288
|
+
}
|
|
289
|
+
finally {
|
|
290
|
+
this.writingNow = false;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
async end() {
|
|
294
|
+
if (this.outputEnded)
|
|
295
|
+
return;
|
|
296
|
+
this.assertAlive();
|
|
297
|
+
check(this.writing && !this.ending && !this.writingNow, 'Invalid data end');
|
|
298
|
+
this.startConsumption();
|
|
299
|
+
this.ending = true;
|
|
300
|
+
try {
|
|
301
|
+
await this.wait(this.connected.promise);
|
|
302
|
+
// EOF is emitted immediately, never await a final socket drain before end.
|
|
303
|
+
this.output.close();
|
|
304
|
+
while (!this.outputEnded)
|
|
305
|
+
await this.wait(this.changed.promise);
|
|
306
|
+
if (this.upload)
|
|
307
|
+
await this.upload;
|
|
308
|
+
if (this.acknowledgmentsRead)
|
|
309
|
+
await this.acknowledgmentsRead;
|
|
310
|
+
this.writeFinalized = true;
|
|
311
|
+
this.finish();
|
|
312
|
+
}
|
|
313
|
+
catch (error) {
|
|
314
|
+
this.fail(errorOf(error));
|
|
315
|
+
throw error;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
beginAck() {
|
|
319
|
+
this.assertAlive();
|
|
320
|
+
check(!this.ackPending, 'Concurrent consumption acknowledgment');
|
|
321
|
+
this.startConsumption();
|
|
322
|
+
this.ackPending = true;
|
|
323
|
+
}
|
|
324
|
+
endAck() {
|
|
325
|
+
this.ackPending = false;
|
|
326
|
+
}
|
|
327
|
+
applyAck(raw) {
|
|
328
|
+
this.assertAlive();
|
|
329
|
+
const ack = raw;
|
|
330
|
+
check(ack &&
|
|
331
|
+
Number.isSafeInteger(ack.sequence) &&
|
|
332
|
+
ack.sequence === this.ackSequence + 1 &&
|
|
333
|
+
Number.isSafeInteger(ack.offset) &&
|
|
334
|
+
ack.offset >= this.consumed &&
|
|
335
|
+
ack.offset <= this.sent &&
|
|
336
|
+
typeof ack.ended === 'boolean' &&
|
|
337
|
+
!this.outputEnded &&
|
|
338
|
+
(ack.ended
|
|
339
|
+
? this.ending && ack.offset === this.sent
|
|
340
|
+
: ack.offset > this.consumed), 'Invalid or replayed consumption acknowledgment');
|
|
341
|
+
this.ackSequence = ack.sequence;
|
|
342
|
+
this.consumed = ack.offset;
|
|
343
|
+
if (ack.ended)
|
|
344
|
+
this.outputEnded = true;
|
|
345
|
+
this.pulse();
|
|
346
|
+
// end() owns final completion, after its HTTP PUT success (if any).
|
|
347
|
+
}
|
|
348
|
+
queueAck(ended) {
|
|
349
|
+
// One in-flight HTTP operation plus one latest cumulative offset, never
|
|
350
|
+
// a per-chunk request queue. Receiver consumption does NOT await an RTT.
|
|
351
|
+
this.ackWanted = { offset: this.received, ended };
|
|
352
|
+
this.startAckPump();
|
|
353
|
+
}
|
|
354
|
+
startAckPump() {
|
|
355
|
+
if (this.ackSending || !this.ackWanted)
|
|
356
|
+
return;
|
|
357
|
+
this.startConsumption();
|
|
358
|
+
const sending = this.pumpAcks();
|
|
359
|
+
this.ackSending = sending;
|
|
360
|
+
void sending.then(() => {
|
|
361
|
+
this.ackSending = undefined;
|
|
362
|
+
if (!this.disposed)
|
|
363
|
+
this.startAckPump();
|
|
364
|
+
}, (error) => this.fail(errorOf(error)));
|
|
365
|
+
}
|
|
366
|
+
async pumpAcks() {
|
|
367
|
+
while (this.ackWanted) {
|
|
368
|
+
const latest = this.ackWanted;
|
|
369
|
+
this.ackWanted = undefined;
|
|
370
|
+
const ack = { sequence: ++this.receiveSequence, ...latest };
|
|
371
|
+
if (this.serving) {
|
|
372
|
+
await this.wait(this.ackDemand.promise);
|
|
373
|
+
this.ackDemand = deferred();
|
|
374
|
+
if (ack.ended)
|
|
375
|
+
this.ackFinished = true;
|
|
376
|
+
this.ackOutput.enqueue(new TextEncoder().encode(JSON.stringify(ack) + '\n'));
|
|
377
|
+
if (ack.ended)
|
|
378
|
+
this.ackOutput.close();
|
|
379
|
+
}
|
|
380
|
+
else {
|
|
381
|
+
const response = await this.request('ack', 'POST', JSON.stringify(ack));
|
|
382
|
+
await response.body?.cancel();
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
async finalAck() {
|
|
387
|
+
this.queueAck(true);
|
|
388
|
+
while (this.ackSending)
|
|
389
|
+
await this.wait(this.ackSending);
|
|
390
|
+
}
|
|
391
|
+
async receive(stream) {
|
|
392
|
+
const reader = stream?.getReader();
|
|
393
|
+
check(reader, 'Missing raw HTTP body');
|
|
394
|
+
try {
|
|
395
|
+
const receiver = await this.wait(this.receiverReady.promise);
|
|
396
|
+
while (true) {
|
|
397
|
+
const result = await this.wait(reader.read());
|
|
398
|
+
if (result.done)
|
|
399
|
+
break;
|
|
400
|
+
for (let offset = 0; offset < result.value.byteLength; offset += CHUNK) {
|
|
401
|
+
const bytes = result.value.subarray(offset, offset + CHUNK);
|
|
402
|
+
this.startConsumption();
|
|
403
|
+
this.consuming = true;
|
|
404
|
+
await this.wait(Promise.resolve().then(() => {
|
|
405
|
+
this.assertAlive();
|
|
406
|
+
return receiver.data(bytes);
|
|
407
|
+
}));
|
|
408
|
+
this.assertAlive();
|
|
409
|
+
this.consuming = false;
|
|
410
|
+
this.received += bytes.byteLength;
|
|
411
|
+
check(Number.isSafeInteger(this.received), 'Data offset overflow');
|
|
412
|
+
this.queueAck(false);
|
|
413
|
+
this.pulse();
|
|
414
|
+
await this.fairness(bytes.byteLength);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
this.startConsumption();
|
|
418
|
+
this.consuming = true;
|
|
419
|
+
await this.wait(Promise.resolve().then(() => {
|
|
420
|
+
this.assertAlive();
|
|
421
|
+
return receiver.end();
|
|
422
|
+
}));
|
|
423
|
+
await this.finalAck();
|
|
424
|
+
this.consuming = false;
|
|
425
|
+
this.inputEnded = true;
|
|
426
|
+
this.pulse();
|
|
427
|
+
this.finish();
|
|
428
|
+
}
|
|
429
|
+
catch (error) {
|
|
430
|
+
this.fail(errorOf(error));
|
|
431
|
+
throw error;
|
|
432
|
+
}
|
|
433
|
+
finally {
|
|
434
|
+
this.consuming = false;
|
|
435
|
+
// reader.cancel releases a blocked network read on failure.
|
|
436
|
+
if (this.failure)
|
|
437
|
+
void reader.cancel(this.failure).catch(() => { });
|
|
438
|
+
reader.releaseLock();
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
async request(leg, method, body) {
|
|
442
|
+
this.assertAlive();
|
|
443
|
+
const init = {
|
|
444
|
+
method,
|
|
445
|
+
redirect: 'error',
|
|
446
|
+
cache: 'no-store',
|
|
447
|
+
signal: this.abort.signal,
|
|
448
|
+
headers: {
|
|
449
|
+
Authorization: `Bearer ${this.link.token}`,
|
|
450
|
+
'X-Data-Binding': this.link.binding,
|
|
451
|
+
'Content-Type': typeof body === 'string'
|
|
452
|
+
? 'application/json'
|
|
453
|
+
: 'application/octet-stream',
|
|
454
|
+
'Accept-Encoding': 'identity',
|
|
455
|
+
},
|
|
456
|
+
};
|
|
457
|
+
if (body !== undefined) {
|
|
458
|
+
init.body = body;
|
|
459
|
+
if (typeof body !== 'string')
|
|
460
|
+
init.duplex = 'half';
|
|
461
|
+
}
|
|
462
|
+
const response = await this.wait(this.options.fetch(`${this.link.endpoint}/${this.link.channel}/${leg}`, init));
|
|
463
|
+
const encoding = response.headers.get('content-encoding');
|
|
464
|
+
if (!response.ok || (encoding && encoding !== 'identity')) {
|
|
465
|
+
await response.body?.cancel();
|
|
466
|
+
throw new Error('HTTP data request failed');
|
|
467
|
+
}
|
|
468
|
+
return response;
|
|
469
|
+
}
|
|
470
|
+
async readAcks(stream) {
|
|
471
|
+
const reader = stream?.getReader();
|
|
472
|
+
check(reader, 'Missing HTTP consumption acknowledgments');
|
|
473
|
+
let pending = '';
|
|
474
|
+
const decoder = new TextDecoder('utf-8', { fatal: true });
|
|
475
|
+
try {
|
|
476
|
+
while (true) {
|
|
477
|
+
const next = await this.wait(reader.read());
|
|
478
|
+
if (next.done) {
|
|
479
|
+
pending += decoder.decode();
|
|
480
|
+
break;
|
|
481
|
+
}
|
|
482
|
+
check(!this.outputEnded || next.value.byteLength === 0, 'Data after final acknowledgment');
|
|
483
|
+
pending += decoder.decode(next.value, { stream: true });
|
|
484
|
+
let index;
|
|
485
|
+
while ((index = pending.indexOf('\n')) >= 0) {
|
|
486
|
+
check(index <= 256, 'HTTP acknowledgment record limit');
|
|
487
|
+
this.applyAck(JSON.parse(pending.slice(0, index)));
|
|
488
|
+
pending = pending.slice(index + 1);
|
|
489
|
+
if (this.outputEnded) {
|
|
490
|
+
check(pending.length === 0, 'Data after final acknowledgment');
|
|
491
|
+
// Read the HTTP EOF instead of cancelling a valid final ACK leg.
|
|
492
|
+
// Cancelling here can abort the peer's still-open duplex channel.
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
check(pending.length <= 256, 'HTTP acknowledgment record limit');
|
|
496
|
+
}
|
|
497
|
+
check(pending.length === 0 && this.outputEnded, 'Truncated HTTP acknowledgments');
|
|
498
|
+
}
|
|
499
|
+
finally {
|
|
500
|
+
if (this.failure)
|
|
501
|
+
void reader.cancel(this.failure).catch(() => { });
|
|
502
|
+
reader.releaseLock();
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
async connect() {
|
|
506
|
+
try {
|
|
507
|
+
if (this.writing) {
|
|
508
|
+
this.acknowledgmentsRead = this.request('ack', 'GET').then((response) => this.readAcks(response.body));
|
|
509
|
+
void this.acknowledgmentsRead.catch((error) => this.fail(errorOf(error)));
|
|
510
|
+
this.upload = this.request('put', 'PUT', this.outgoing).then(async (response) => {
|
|
511
|
+
await response.body?.cancel();
|
|
512
|
+
check(this.ending, 'Premature HTTP upload completion');
|
|
513
|
+
});
|
|
514
|
+
void this.upload.catch((error) => this.fail(errorOf(error)));
|
|
515
|
+
}
|
|
516
|
+
if (this.reading) {
|
|
517
|
+
void this.request('get', 'GET')
|
|
518
|
+
.then((response) => this.receive(response.body))
|
|
519
|
+
.catch((error) => this.fail(errorOf(error)));
|
|
520
|
+
}
|
|
521
|
+
// No payload/prelude or invented consumption credit. Unlike raw GET
|
|
522
|
+
// headers, this empty authenticated response is independent of bytes
|
|
523
|
+
// and EOF; it also rejects stale/replayed links before business accept.
|
|
524
|
+
const ready = await this.request('ready', 'POST');
|
|
525
|
+
await ready.body?.cancel();
|
|
526
|
+
this.clientConnected();
|
|
527
|
+
}
|
|
528
|
+
catch (error) {
|
|
529
|
+
this.fail(errorOf(error));
|
|
530
|
+
throw error;
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
finish() {
|
|
534
|
+
if (!this.activated ||
|
|
535
|
+
!this.inputEnded ||
|
|
536
|
+
!this.outputEnded ||
|
|
537
|
+
!this.writeFinalized ||
|
|
538
|
+
this.disposed)
|
|
539
|
+
return;
|
|
540
|
+
this.cleanup();
|
|
541
|
+
this.completion.resolve();
|
|
542
|
+
try {
|
|
543
|
+
this.receiver?.close();
|
|
544
|
+
}
|
|
545
|
+
catch {
|
|
546
|
+
/* Completion cannot be undone by cleanup. */
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
cleanup() {
|
|
550
|
+
this.disposed = true;
|
|
551
|
+
this.ackWanted = undefined;
|
|
552
|
+
clearTimeout(this.openTimer);
|
|
553
|
+
clearInterval(this.idleTimer);
|
|
554
|
+
this.release();
|
|
555
|
+
}
|
|
556
|
+
fail(error) {
|
|
557
|
+
if (this.disposed)
|
|
558
|
+
return;
|
|
559
|
+
this.failure = error;
|
|
560
|
+
this.cleanup();
|
|
561
|
+
this.abort.abort(error);
|
|
562
|
+
this.receiverReady.reject(error);
|
|
563
|
+
this.connected.reject(error);
|
|
564
|
+
this.changed.reject(error);
|
|
565
|
+
this.ackDemand.reject(error);
|
|
566
|
+
try {
|
|
567
|
+
this.output.error(error);
|
|
568
|
+
}
|
|
569
|
+
catch {
|
|
570
|
+
/* Already ended. */
|
|
571
|
+
}
|
|
572
|
+
try {
|
|
573
|
+
this.ackOutput.error(error);
|
|
574
|
+
}
|
|
575
|
+
catch {
|
|
576
|
+
/* Already ended. */
|
|
577
|
+
}
|
|
578
|
+
this.completion.reject(error);
|
|
579
|
+
try {
|
|
580
|
+
this.receiver?.close(error);
|
|
581
|
+
}
|
|
582
|
+
catch {
|
|
583
|
+
/* Preserve original failure. */
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
close(code = 'Data channel cancelled') {
|
|
587
|
+
if (this.disposed)
|
|
588
|
+
return;
|
|
589
|
+
// Best effort only; no retry or replay. Local cancellation is immediate.
|
|
590
|
+
if (!this.serving) {
|
|
591
|
+
const signal = AbortSignal.timeout(1_000);
|
|
592
|
+
void this.options
|
|
593
|
+
.fetch(`${this.link.endpoint}/${this.link.channel}/close`, {
|
|
594
|
+
method: 'DELETE',
|
|
595
|
+
redirect: 'error',
|
|
596
|
+
signal,
|
|
597
|
+
headers: {
|
|
598
|
+
Authorization: `Bearer ${this.link.token}`,
|
|
599
|
+
'X-Data-Binding': this.link.binding,
|
|
600
|
+
},
|
|
601
|
+
})
|
|
602
|
+
.then((response) => response.body?.cancel())
|
|
603
|
+
.catch(() => { });
|
|
604
|
+
}
|
|
605
|
+
this.fail(new Error(code));
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
/** Connection-scoped HTTP binary data plane; no SDK, business classes or listeners. */
|
|
609
|
+
export class HttpDataSession {
|
|
610
|
+
options;
|
|
611
|
+
channels = new Map();
|
|
612
|
+
stopped = false;
|
|
613
|
+
pendingOpens = new Set();
|
|
614
|
+
usedLinks = new Map();
|
|
615
|
+
endpoint;
|
|
616
|
+
maximum;
|
|
617
|
+
constructor(options) {
|
|
618
|
+
this.options = options;
|
|
619
|
+
check(typeof options.fetch === 'function' &&
|
|
620
|
+
typeof options.validateEndpoint === 'function', 'HTTP client and endpoint policy are required');
|
|
621
|
+
check(typeof options.binding === 'string' &&
|
|
622
|
+
/^[A-Za-z0-9_-]{16,256}$/.test(options.binding), 'Invalid connection data binding');
|
|
623
|
+
check(typeof options.supportsPurpose === 'function', 'Purpose policy is required');
|
|
624
|
+
this.maximum = options.maxChannels ?? 4;
|
|
625
|
+
check(Number.isInteger(this.maximum) &&
|
|
626
|
+
this.maximum > 0 &&
|
|
627
|
+
this.maximum <= 32, 'Invalid data channel limit');
|
|
628
|
+
for (const timeout of [
|
|
629
|
+
options.openTimeoutMs ?? 15_000,
|
|
630
|
+
options.idleTimeoutMs ?? 60_000,
|
|
631
|
+
]) {
|
|
632
|
+
check(Number.isSafeInteger(timeout) &&
|
|
633
|
+
timeout > 0 &&
|
|
634
|
+
timeout <= 300_000, 'Invalid data timeout');
|
|
635
|
+
}
|
|
636
|
+
if (options.endpoint)
|
|
637
|
+
this.endpoint = endpointUrl(options.endpoint);
|
|
638
|
+
}
|
|
639
|
+
hasActivity() {
|
|
640
|
+
return this.pendingOpens.size > 0 || this.channels.size > 0;
|
|
641
|
+
}
|
|
642
|
+
supports(purpose, direction) {
|
|
643
|
+
return (!this.stopped &&
|
|
644
|
+
validPurpose(purpose) &&
|
|
645
|
+
this.options.supportsPurpose(purpose) &&
|
|
646
|
+
['host-to-remote', 'remote-to-host', 'duplex'].includes(direction));
|
|
647
|
+
}
|
|
648
|
+
admit() {
|
|
649
|
+
check(!this.stopped, 'Data session closed');
|
|
650
|
+
check(this.channels.size + this.pendingOpens.size < this.maximum, 'Data channel limit exceeded');
|
|
651
|
+
}
|
|
652
|
+
validateLink(raw, open) {
|
|
653
|
+
const link = raw;
|
|
654
|
+
check(link &&
|
|
655
|
+
link.version === 1 &&
|
|
656
|
+
typeof link.channel === 'string' &&
|
|
657
|
+
/^[a-f0-9]{48}$/.test(link.channel) &&
|
|
658
|
+
typeof link.token === 'string' &&
|
|
659
|
+
/^[a-f0-9]{64}$/.test(link.token) &&
|
|
660
|
+
link.binding === this.options.binding &&
|
|
661
|
+
Number.isSafeInteger(link.expiresInMs) &&
|
|
662
|
+
link.expiresInMs > 0 &&
|
|
663
|
+
link.expiresInMs <= (this.options.openTimeoutMs ?? 15_000) &&
|
|
664
|
+
sameOpen(validOpen(link.open, this.options.supportsPurpose), open), 'Invalid data link');
|
|
665
|
+
this.options.validateEndpoint(endpointUrl(link.endpoint));
|
|
666
|
+
return { ...link, open };
|
|
667
|
+
}
|
|
668
|
+
create(open, supplied) {
|
|
669
|
+
open = validOpen(open, this.options.supportsPurpose);
|
|
670
|
+
this.admit();
|
|
671
|
+
check(supplied || this.endpoint, 'No local HTTP data endpoint');
|
|
672
|
+
const link = supplied ?? {
|
|
673
|
+
version: 1,
|
|
674
|
+
endpoint: this.endpoint.href,
|
|
675
|
+
channel: randomBytes(24).toString('hex'),
|
|
676
|
+
token: randomBytes(32).toString('hex'),
|
|
677
|
+
binding: this.options.binding,
|
|
678
|
+
open,
|
|
679
|
+
expiresInMs: this.options.openTimeoutMs ?? 15_000,
|
|
680
|
+
};
|
|
681
|
+
check(!this.channels.has(link.channel), 'Data channel already accepted');
|
|
682
|
+
if (supplied) {
|
|
683
|
+
const now = performance.now();
|
|
684
|
+
for (const [id, expires] of this.usedLinks) {
|
|
685
|
+
if (expires <= now)
|
|
686
|
+
this.usedLinks.delete(id);
|
|
687
|
+
}
|
|
688
|
+
check(!this.usedLinks.has(link.channel), 'Data link already used');
|
|
689
|
+
check(this.usedLinks.size < 256, 'Pending data link replay limit');
|
|
690
|
+
this.usedLinks.set(link.channel, now + link.expiresInMs);
|
|
691
|
+
}
|
|
692
|
+
const channel = new HttpChannel(link, this.options, !supplied, () => this.channels.delete(link.channel));
|
|
693
|
+
this.channels.set(link.channel, channel);
|
|
694
|
+
return channel;
|
|
695
|
+
}
|
|
696
|
+
async openChannel(open, receiver, link, signal) {
|
|
697
|
+
check(this.options.role === 'host', 'Only the host opens data channels');
|
|
698
|
+
open = validOpen(open, this.options.supportsPurpose);
|
|
699
|
+
let channel;
|
|
700
|
+
try {
|
|
701
|
+
if (link) {
|
|
702
|
+
channel = this.create(open, this.validateLink(link, open));
|
|
703
|
+
channel.setReceiver(receiver);
|
|
704
|
+
channel.attachSignal(signal);
|
|
705
|
+
await channel.connect();
|
|
706
|
+
}
|
|
707
|
+
else if (this.endpoint) {
|
|
708
|
+
check(this.options.requestOpen, 'Missing data/open control client');
|
|
709
|
+
channel = this.create(open);
|
|
710
|
+
channel.setReceiver(receiver);
|
|
711
|
+
channel.attachSignal(signal);
|
|
712
|
+
await channel.wait(this.options.requestOpen({
|
|
713
|
+
open,
|
|
714
|
+
binding: this.options.binding,
|
|
715
|
+
link: channel.link,
|
|
716
|
+
}, channel.abort.signal));
|
|
717
|
+
}
|
|
718
|
+
else {
|
|
719
|
+
check(this.options.requestOpen, 'Missing data/open control client');
|
|
720
|
+
// Reserve a bounded slot BEFORE awaiting RPC, including stalled metadata calls.
|
|
721
|
+
this.admit();
|
|
722
|
+
const pending = new AbortController();
|
|
723
|
+
const deadline = setTimeout(() => pending.abort(), this.options.openTimeoutMs ?? 15_000);
|
|
724
|
+
deadline.unref?.();
|
|
725
|
+
const combined = AbortSignal.any([
|
|
726
|
+
pending.signal,
|
|
727
|
+
...(signal ? [signal] : []),
|
|
728
|
+
]);
|
|
729
|
+
this.pendingOpens.add(pending);
|
|
730
|
+
let result;
|
|
731
|
+
try {
|
|
732
|
+
result = await new Promise((resolve, reject) => {
|
|
733
|
+
const abort = () => reject(new Error('Data/open cancelled or timed out'));
|
|
734
|
+
combined.addEventListener('abort', abort, {
|
|
735
|
+
once: true,
|
|
736
|
+
});
|
|
737
|
+
if (combined.aborted)
|
|
738
|
+
abort();
|
|
739
|
+
else
|
|
740
|
+
void this.options.requestOpen({ open, binding: this.options.binding }, combined).then(resolve, reject);
|
|
741
|
+
});
|
|
742
|
+
}
|
|
743
|
+
finally {
|
|
744
|
+
clearTimeout(deadline);
|
|
745
|
+
pending.abort();
|
|
746
|
+
this.pendingOpens.delete(pending);
|
|
747
|
+
}
|
|
748
|
+
check(result.link, 'Data/open did not return a link');
|
|
749
|
+
channel = this.create(open, this.validateLink(result.link, open));
|
|
750
|
+
channel.setReceiver(receiver);
|
|
751
|
+
channel.attachSignal(signal);
|
|
752
|
+
await channel.connect();
|
|
753
|
+
}
|
|
754
|
+
await channel.waitConnected();
|
|
755
|
+
channel.activate();
|
|
756
|
+
return channel;
|
|
757
|
+
}
|
|
758
|
+
catch (error) {
|
|
759
|
+
if (channel)
|
|
760
|
+
channel.fail(errorOf(error));
|
|
761
|
+
else
|
|
762
|
+
receiver.close(errorOf(error));
|
|
763
|
+
throw error;
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
async acceptOpen(raw) {
|
|
767
|
+
check(this.options.role === 'remote' && this.options.accept, 'Data accept is unavailable');
|
|
768
|
+
const params = raw;
|
|
769
|
+
check(params && params.binding === this.options.binding, 'Data/open connection binding mismatch');
|
|
770
|
+
const open = validOpen(params.open, this.options.supportsPurpose);
|
|
771
|
+
const link = params.link
|
|
772
|
+
? this.validateLink(params.link, open)
|
|
773
|
+
: undefined;
|
|
774
|
+
const channel = this.create(open, link);
|
|
775
|
+
try {
|
|
776
|
+
// A reverse descriptor must first claim live, single-use HTTP legs.
|
|
777
|
+
// Replaying a completed/expired descriptor must not re-enter business accept.
|
|
778
|
+
if (link)
|
|
779
|
+
await channel.connect();
|
|
780
|
+
const accepting = Promise.resolve().then(() => this.options.accept(open, channel));
|
|
781
|
+
const installed = accepting.then((receiver) => channel.setReceiver(receiver));
|
|
782
|
+
await channel.wait(installed);
|
|
783
|
+
channel.activate();
|
|
784
|
+
return link ? {} : { link: channel.link };
|
|
785
|
+
}
|
|
786
|
+
catch (error) {
|
|
787
|
+
channel.fail(errorOf(error));
|
|
788
|
+
throw error;
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
async handleRequest(request) {
|
|
792
|
+
if (!this.endpoint)
|
|
793
|
+
return undefined;
|
|
794
|
+
const url = new URL(request.url);
|
|
795
|
+
const prefix = this.endpoint.pathname + '/';
|
|
796
|
+
if (!url.pathname.startsWith(prefix))
|
|
797
|
+
return undefined;
|
|
798
|
+
if (request.headers.has('origin') || url.search || url.hash)
|
|
799
|
+
return new Response(null, { status: 403, headers: HEADERS });
|
|
800
|
+
const match = /^([a-f0-9]{48})\/(get|put|ack|ready|close)$/.exec(url.pathname.slice(prefix.length));
|
|
801
|
+
const channel = match && this.channels.get(match[1]);
|
|
802
|
+
const token = Buffer.from(request.headers.get('authorization') ?? '');
|
|
803
|
+
const expected = Buffer.from(channel ? `Bearer ${channel.link.token}` : '');
|
|
804
|
+
if (!channel ||
|
|
805
|
+
!channel.serving ||
|
|
806
|
+
token.length !== expected.length ||
|
|
807
|
+
!timingSafeEqual(token, expected) ||
|
|
808
|
+
request.headers.get('x-data-binding') !== this.options.binding) {
|
|
809
|
+
return new Response(null, { status: 401, headers: HEADERS });
|
|
810
|
+
}
|
|
811
|
+
const leg = match[2];
|
|
812
|
+
if ((leg === 'ready' && request.method !== 'POST') ||
|
|
813
|
+
(leg === 'get' && request.method !== 'GET') ||
|
|
814
|
+
(leg === 'put' && request.method !== 'PUT') ||
|
|
815
|
+
(leg === 'ack' && !['GET', 'POST'].includes(request.method)) ||
|
|
816
|
+
(leg === 'close' && request.method !== 'DELETE')) {
|
|
817
|
+
return new Response(null, { status: 405, headers: HEADERS });
|
|
818
|
+
}
|
|
819
|
+
// Expiry gates initial leg admission, not a healthy long-running transfer.
|
|
820
|
+
if (leg !== 'close' &&
|
|
821
|
+
!(leg === 'ack' && request.method === 'POST') &&
|
|
822
|
+
channel.ticketExpired()) {
|
|
823
|
+
channel.close('Data ticket expired');
|
|
824
|
+
return new Response(null, { status: 410, headers: HEADERS });
|
|
825
|
+
}
|
|
826
|
+
const cancel = () => channel.close('HTTP data request aborted');
|
|
827
|
+
request.signal.addEventListener('abort', cancel, { once: true });
|
|
828
|
+
if (!(leg === 'close' ||
|
|
829
|
+
leg === 'ready' ||
|
|
830
|
+
(leg === 'ack' && request.method === 'POST'))) {
|
|
831
|
+
void channel.closed
|
|
832
|
+
.finally(() => request.signal.removeEventListener('abort', cancel))
|
|
833
|
+
.catch(() => { });
|
|
834
|
+
}
|
|
835
|
+
if (request.signal.aborted)
|
|
836
|
+
cancel();
|
|
837
|
+
const transient = leg === 'close' ||
|
|
838
|
+
leg === 'ready' ||
|
|
839
|
+
(leg === 'ack' && request.method === 'POST');
|
|
840
|
+
try {
|
|
841
|
+
if (leg === 'close') {
|
|
842
|
+
channel.close();
|
|
843
|
+
return new Response(null, { status: 204, headers: HEADERS });
|
|
844
|
+
}
|
|
845
|
+
if (leg === 'ready') {
|
|
846
|
+
check(!request.headers.has('transfer-encoding') &&
|
|
847
|
+
(!request.headers.has('content-length') ||
|
|
848
|
+
request.headers.get('content-length') === '0'), 'Data ready must have no body');
|
|
849
|
+
channel.claim(leg);
|
|
850
|
+
await channel.waitConnected();
|
|
851
|
+
return new Response(null, { status: 204, headers: HEADERS });
|
|
852
|
+
}
|
|
853
|
+
if (leg === 'ack' && request.method === 'POST') {
|
|
854
|
+
channel.beginAck();
|
|
855
|
+
const text = await readSmallBody(request, channel);
|
|
856
|
+
channel.applyAck(JSON.parse(text));
|
|
857
|
+
return new Response(null, { status: 204, headers: HEADERS });
|
|
858
|
+
}
|
|
859
|
+
channel.claim(leg);
|
|
860
|
+
if (leg === 'get')
|
|
861
|
+
return new Response(channel.outgoing, {
|
|
862
|
+
headers: {
|
|
863
|
+
...HEADERS,
|
|
864
|
+
'Content-Type': 'application/octet-stream',
|
|
865
|
+
},
|
|
866
|
+
});
|
|
867
|
+
if (leg === 'ack')
|
|
868
|
+
return new Response(channel.acknowledgments, {
|
|
869
|
+
headers: {
|
|
870
|
+
...HEADERS,
|
|
871
|
+
'Content-Type': 'application/x-ndjson',
|
|
872
|
+
},
|
|
873
|
+
});
|
|
874
|
+
check(!request.headers.get('content-encoding') ||
|
|
875
|
+
request.headers.get('content-encoding') === 'identity', 'Encoded data body is forbidden');
|
|
876
|
+
await channel.receive(request.body);
|
|
877
|
+
return new Response(null, { status: 204, headers: HEADERS });
|
|
878
|
+
}
|
|
879
|
+
catch (error) {
|
|
880
|
+
channel.fail(errorOf(error));
|
|
881
|
+
return new Response(null, { status: 409, headers: HEADERS });
|
|
882
|
+
}
|
|
883
|
+
finally {
|
|
884
|
+
if (transient) {
|
|
885
|
+
if (leg === 'ack')
|
|
886
|
+
channel.endAck();
|
|
887
|
+
request.signal.removeEventListener('abort', cancel);
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
close(reason = 'Data connection closed') {
|
|
892
|
+
if (this.stopped)
|
|
893
|
+
return;
|
|
894
|
+
this.stopped = true;
|
|
895
|
+
for (const pending of this.pendingOpens)
|
|
896
|
+
pending.abort();
|
|
897
|
+
this.pendingOpens.clear();
|
|
898
|
+
for (const channel of this.channels.values())
|
|
899
|
+
channel.close(reason);
|
|
900
|
+
this.channels.clear();
|
|
901
|
+
this.usedLinks.clear();
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
async function readSmallBody(request, channel) {
|
|
905
|
+
const reader = request.body?.getReader();
|
|
906
|
+
check(reader, 'Missing acknowledgment');
|
|
907
|
+
const chunks = [];
|
|
908
|
+
let size = 0;
|
|
909
|
+
try {
|
|
910
|
+
while (true) {
|
|
911
|
+
const next = await channel.wait(reader.read());
|
|
912
|
+
if (next.done)
|
|
913
|
+
break;
|
|
914
|
+
size += next.value.byteLength;
|
|
915
|
+
check(size <= 256, 'Acknowledgment metadata limit');
|
|
916
|
+
chunks.push(next.value);
|
|
917
|
+
}
|
|
918
|
+
return Buffer.concat(chunks).toString('utf8');
|
|
919
|
+
}
|
|
920
|
+
finally {
|
|
921
|
+
void reader.cancel().catch(() => { });
|
|
922
|
+
reader.releaseLock();
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
//# sourceMappingURL=HttpDataSession.js.map
|