@floegence/flowersec-core 0.20.0 → 0.20.2
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/dist/client-connect/connectCore.d.ts +2 -0
- package/dist/client-connect/connectCore.js +45 -5
- package/dist/client-connect/termination.d.ts +6 -0
- package/dist/client-connect/termination.js +7 -0
- package/dist/e2ee/handshake.d.ts +4 -0
- package/dist/e2ee/handshake.js +14 -1
- package/dist/e2ee/secureChannel.d.ts +6 -1
- package/dist/e2ee/secureChannel.js +37 -12
- package/dist/proxy/appWindow.d.ts +1 -0
- package/dist/proxy/appWindow.js +17 -5
- package/dist/proxy/controllerWindow.js +87 -14
- package/dist/proxy/headerPolicy.js +10 -1
- package/dist/proxy/integration.d.ts +4 -0
- package/dist/proxy/integration.js +12 -0
- package/dist/proxy/portStream.d.ts +3 -1
- package/dist/proxy/portStream.js +40 -3
- package/dist/proxy/runtime.d.ts +8 -0
- package/dist/proxy/runtime.js +170 -4
- package/dist/proxy/runtimeScope.d.ts +8 -0
- package/dist/proxy/windowBridgeProtocol.d.ts +8 -0
- package/dist/proxy/windowBridgeProtocol.js +2 -0
- package/dist/proxy/wsPatch.d.ts +1 -0
- package/dist/proxy/wsPatch.js +56 -12
- package/dist/reconnect/index.js +9 -0
- package/dist/rpc/client.d.ts +2 -0
- package/dist/rpc/client.js +11 -0
- package/dist/yamux/session.d.ts +8 -1
- package/dist/yamux/session.js +77 -26
- package/package.json +1 -1
|
@@ -25,6 +25,8 @@ export type ConnectOptionsBase = Readonly<{
|
|
|
25
25
|
maxRecordBytes?: number;
|
|
26
26
|
/** Maximum buffered plaintext bytes in the secure channel (0 uses default). */
|
|
27
27
|
maxBufferedBytes?: number;
|
|
28
|
+
/** Maximum queued outbound plaintext bytes in the secure channel (default 4 MiB; 0 uses default). */
|
|
29
|
+
maxOutboundBufferedBytes?: number;
|
|
28
30
|
/** Preferred plaintext bytes per outbound encrypted record (default 64 KiB). */
|
|
29
31
|
outboundRecordChunkBytes?: number;
|
|
30
32
|
/** WebSocket inbound and outbound queue limits. */
|
|
@@ -14,6 +14,7 @@ import { isTunnelAttachCloseReason } from "./tunnelAttachCloseReason.js";
|
|
|
14
14
|
import { enforceTransportSecurity } from "./transportSecurity.js";
|
|
15
15
|
import { maxPlaintextBytes } from "../e2ee/record.js";
|
|
16
16
|
import { isYamuxResourceExhaustedError } from "../yamux/errors.js";
|
|
17
|
+
import { registerClientTermination } from "./termination.js";
|
|
17
18
|
export async function connectCore(args) {
|
|
18
19
|
const observer = normalizeObserver(args.opts.observer, { path: args.path });
|
|
19
20
|
const signal = args.opts.signal;
|
|
@@ -75,6 +76,10 @@ export async function connectCore(args) {
|
|
|
75
76
|
if (!Number.isSafeInteger(maxBufferedBytes) || maxBufferedBytes < 0) {
|
|
76
77
|
invalidOption("maxBufferedBytes must be a non-negative integer");
|
|
77
78
|
}
|
|
79
|
+
const maxOutboundBufferedBytes = args.opts.maxOutboundBufferedBytes ?? 0;
|
|
80
|
+
if (!Number.isSafeInteger(maxOutboundBufferedBytes) || maxOutboundBufferedBytes < 0) {
|
|
81
|
+
invalidOption("maxOutboundBufferedBytes must be a non-negative integer");
|
|
82
|
+
}
|
|
78
83
|
const effectiveMaxRecordBytes = maxRecordBytes > 0 ? maxRecordBytes : (1 << 20);
|
|
79
84
|
const outboundRecordChunkBytes = args.opts.outboundRecordChunkBytes ?? 64 * 1024;
|
|
80
85
|
if (!Number.isSafeInteger(outboundRecordChunkBytes) || outboundRecordChunkBytes <= 0 || outboundRecordChunkBytes > maxPlaintextBytes(effectiveMaxRecordBytes)) {
|
|
@@ -182,6 +187,7 @@ export async function connectCore(args) {
|
|
|
182
187
|
maxRecordBytes: effectiveMaxRecordBytes,
|
|
183
188
|
outboundRecordChunkBytes,
|
|
184
189
|
...(maxBufferedBytes > 0 ? { maxBufferedBytes } : {}),
|
|
190
|
+
...(maxOutboundBufferedBytes > 0 ? { maxOutboundBufferedBytes } : {}),
|
|
185
191
|
timeoutMs: handshakeTimeoutMs,
|
|
186
192
|
...(signal !== undefined ? { signal } : {}),
|
|
187
193
|
}), {
|
|
@@ -220,9 +226,28 @@ export async function connectCore(args) {
|
|
|
220
226
|
write: (b) => secure.write(b),
|
|
221
227
|
close: () => secure.close(),
|
|
222
228
|
};
|
|
229
|
+
let resolveTermination;
|
|
230
|
+
const termination = new Promise((resolve) => {
|
|
231
|
+
resolveTermination = resolve;
|
|
232
|
+
});
|
|
233
|
+
let terminationReported = false;
|
|
234
|
+
let closeAll = () => {
|
|
235
|
+
try {
|
|
236
|
+
secure.close();
|
|
237
|
+
}
|
|
238
|
+
catch { /* ignore */ }
|
|
239
|
+
};
|
|
240
|
+
const reportTermination = (error) => {
|
|
241
|
+
if (terminationReported)
|
|
242
|
+
return;
|
|
243
|
+
terminationReported = true;
|
|
244
|
+
closeAll();
|
|
245
|
+
resolveTermination({ error });
|
|
246
|
+
};
|
|
223
247
|
const mux = new YamuxSession(conn, {
|
|
224
248
|
client: true,
|
|
225
249
|
...(args.opts.yamuxLimits === undefined ? {} : { limits: args.opts.yamuxLimits }),
|
|
250
|
+
onTerminal: reportTermination,
|
|
226
251
|
onDiagnostic: (event) => emitObserverDiagnostic(args.opts.observer, {
|
|
227
252
|
path: args.path,
|
|
228
253
|
stage: "yamux",
|
|
@@ -234,9 +259,19 @@ export async function connectCore(args) {
|
|
|
234
259
|
limit: event.limit,
|
|
235
260
|
}),
|
|
236
261
|
});
|
|
262
|
+
closeAll = () => {
|
|
263
|
+
try {
|
|
264
|
+
mux.close();
|
|
265
|
+
}
|
|
266
|
+
catch { /* ignore */ }
|
|
267
|
+
try {
|
|
268
|
+
secure.close();
|
|
269
|
+
}
|
|
270
|
+
catch { /* ignore */ }
|
|
271
|
+
};
|
|
237
272
|
let rpcStream;
|
|
238
273
|
try {
|
|
239
|
-
rpcStream = await mux.openStream();
|
|
274
|
+
rpcStream = await mux.openStream(signal === undefined ? {} : { signal });
|
|
240
275
|
}
|
|
241
276
|
catch (e) {
|
|
242
277
|
mux.close();
|
|
@@ -266,7 +301,7 @@ export async function connectCore(args) {
|
|
|
266
301
|
cause: e,
|
|
267
302
|
});
|
|
268
303
|
}
|
|
269
|
-
const rpc = new RpcClient(readExactly, write, { observer });
|
|
304
|
+
const rpc = new RpcClient(readExactly, write, { observer, onTerminal: reportTermination });
|
|
270
305
|
const ping = async () => {
|
|
271
306
|
try {
|
|
272
307
|
await secure.sendPing();
|
|
@@ -306,7 +341,7 @@ export async function connectCore(args) {
|
|
|
306
341
|
clearInterval(livenessTimer);
|
|
307
342
|
livenessTimer = undefined;
|
|
308
343
|
};
|
|
309
|
-
|
|
344
|
+
closeAll = () => {
|
|
310
345
|
stopLiveness();
|
|
311
346
|
try {
|
|
312
347
|
rpc.close();
|
|
@@ -342,7 +377,7 @@ export async function connectCore(args) {
|
|
|
342
377
|
}, liveness.intervalMs);
|
|
343
378
|
livenessTimer?.unref?.();
|
|
344
379
|
}
|
|
345
|
-
|
|
380
|
+
const client = {
|
|
346
381
|
path: args.path,
|
|
347
382
|
...(args.attach != null ? { endpointInstanceId: args.attach.endpointInstanceId } : {}),
|
|
348
383
|
secure,
|
|
@@ -374,9 +409,12 @@ export async function connectCore(args) {
|
|
|
374
409
|
let abortListener;
|
|
375
410
|
let s;
|
|
376
411
|
try {
|
|
377
|
-
s = await mux.openStream();
|
|
412
|
+
s = await mux.openStream(signal === undefined ? {} : { signal });
|
|
378
413
|
}
|
|
379
414
|
catch (e) {
|
|
415
|
+
if (signal?.aborted) {
|
|
416
|
+
throw new FlowersecError({ path: args.path, stage: "yamux", code: "canceled", message: "open stream aborted", cause: signal.reason });
|
|
417
|
+
}
|
|
380
418
|
const exhausted = isYamuxResourceExhaustedError(e);
|
|
381
419
|
throw new FlowersecError({ path: args.path, stage: "yamux", code: exhausted ? "resource_exhausted" : "open_stream_failed", message: exhausted ? "yamux stream limit reached" : "open stream failed", cause: e });
|
|
382
420
|
}
|
|
@@ -456,6 +494,8 @@ export async function connectCore(args) {
|
|
|
456
494
|
},
|
|
457
495
|
close: closeAll,
|
|
458
496
|
};
|
|
497
|
+
registerClientTermination(client, termination);
|
|
498
|
+
return client;
|
|
459
499
|
}
|
|
460
500
|
catch (e) {
|
|
461
501
|
try {
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { Client } from "../client.js";
|
|
2
|
+
export type ClientTermination = Readonly<{
|
|
3
|
+
error: Error;
|
|
4
|
+
}>;
|
|
5
|
+
export declare function registerClientTermination(client: Client, termination: Promise<ClientTermination>): void;
|
|
6
|
+
export declare function getClientTermination(client: Client): Promise<ClientTermination> | undefined;
|
package/dist/e2ee/handshake.d.ts
CHANGED
|
@@ -18,6 +18,8 @@ export type HandshakeClientOptions = Readonly<{
|
|
|
18
18
|
outboundRecordChunkBytes?: number;
|
|
19
19
|
/** Maximum buffered plaintext bytes for the secure channel. */
|
|
20
20
|
maxBufferedBytes?: number;
|
|
21
|
+
/** Maximum queued outbound plaintext bytes for the secure channel. */
|
|
22
|
+
maxOutboundBufferedBytes?: number;
|
|
21
23
|
/** Optional AbortSignal to cancel the handshake. */
|
|
22
24
|
signal?: AbortSignal;
|
|
23
25
|
/** Optional total handshake timeout in milliseconds (>= 0; 0 disables). */
|
|
@@ -44,6 +46,8 @@ export type HandshakeServerOptions = Readonly<{
|
|
|
44
46
|
outboundRecordChunkBytes?: number;
|
|
45
47
|
/** Maximum buffered plaintext bytes for the secure channel. */
|
|
46
48
|
maxBufferedBytes?: number;
|
|
49
|
+
/** Maximum queued outbound plaintext bytes for the secure channel. */
|
|
50
|
+
maxOutboundBufferedBytes?: number;
|
|
47
51
|
/** Optional AbortSignal to cancel the handshake. */
|
|
48
52
|
signal?: AbortSignal;
|
|
49
53
|
/** Optional total handshake timeout in milliseconds (>= 0; 0 disables). */
|
package/dist/e2ee/handshake.js
CHANGED
|
@@ -161,6 +161,7 @@ export async function clientHandshake(transport, opts) {
|
|
|
161
161
|
maxRecordBytes: opts.maxRecordBytes,
|
|
162
162
|
...(opts.outboundRecordChunkBytes !== undefined ? { outboundRecordChunkBytes: opts.outboundRecordChunkBytes } : {}),
|
|
163
163
|
...(opts.maxBufferedBytes !== undefined ? { maxBufferedBytes: opts.maxBufferedBytes } : {}),
|
|
164
|
+
...(opts.maxOutboundBufferedBytes !== undefined ? { maxOutboundBufferedBytes: opts.maxOutboundBufferedBytes } : {}),
|
|
164
165
|
sendKey: keys.c2sKey,
|
|
165
166
|
recvKey: keys.s2cKey,
|
|
166
167
|
sendNoncePrefix: keys.c2sNoncePrefix,
|
|
@@ -190,6 +191,7 @@ export class ServerHandshakeCache {
|
|
|
190
191
|
}
|
|
191
192
|
this.ttlMs = ttlMs;
|
|
192
193
|
this.maxEntries = maxEntries;
|
|
194
|
+
serverHandshakeCacheStores.set(this, this.m);
|
|
193
195
|
}
|
|
194
196
|
cleanup(nowMs) {
|
|
195
197
|
if (this.ttlMs <= 0)
|
|
@@ -231,6 +233,15 @@ export class ServerHandshakeCache {
|
|
|
231
233
|
this.m.delete(initKey);
|
|
232
234
|
}
|
|
233
235
|
}
|
|
236
|
+
const serverHandshakeCacheStores = new WeakMap();
|
|
237
|
+
function takeServerHandshakeState(cache, expected) {
|
|
238
|
+
const store = serverHandshakeCacheStores.get(cache);
|
|
239
|
+
const current = store?.get(expected.initKey);
|
|
240
|
+
if (current !== expected)
|
|
241
|
+
return undefined;
|
|
242
|
+
store.delete(expected.initKey);
|
|
243
|
+
return current;
|
|
244
|
+
}
|
|
234
245
|
// serverHandshake performs the server side of the E2EE handshake.
|
|
235
246
|
export async function serverHandshake(transport, cache, opts) {
|
|
236
247
|
if (opts.initExpireAtUnixS <= 0)
|
|
@@ -281,6 +292,8 @@ export async function serverHandshake(transport, cache, opts) {
|
|
|
281
292
|
}
|
|
282
293
|
if (decoded.handshakeType !== HANDSHAKE_TYPE_ACK)
|
|
283
294
|
throw new Error("unexpected handshake type");
|
|
295
|
+
if (takeServerHandshakeState(cache, entry) == null)
|
|
296
|
+
throw new Error("handshake state unavailable");
|
|
284
297
|
ack = JSON.parse(td.decode(decoded.payloadJsonUtf8));
|
|
285
298
|
break;
|
|
286
299
|
}
|
|
@@ -309,7 +322,6 @@ export async function serverHandshake(transport, cache, opts) {
|
|
|
309
322
|
throw new E2EEHandshakeError("auth_tag_mismatch", "auth tag mismatch");
|
|
310
323
|
const shared = suiteSharedSecret(suite, entry.serverPriv, clientPub);
|
|
311
324
|
const keys = deriveSessionKeys(opts.psk, shared, th);
|
|
312
|
-
cache.delete(init);
|
|
313
325
|
// Server-finished confirmation: send an encrypted ping record (seq=1) immediately after the handshake.
|
|
314
326
|
const pingFrame = encryptRecord(keys.s2cKey, keys.s2cNoncePrefix, RECORD_FLAG_PING, 1n, new Uint8Array(), opts.maxRecordBytes);
|
|
315
327
|
await transport.writeBinary(pingFrame, ioWriteOpts(opts.signal));
|
|
@@ -319,6 +331,7 @@ export async function serverHandshake(transport, cache, opts) {
|
|
|
319
331
|
maxRecordBytes: opts.maxRecordBytes,
|
|
320
332
|
...(opts.outboundRecordChunkBytes !== undefined ? { outboundRecordChunkBytes: opts.outboundRecordChunkBytes } : {}),
|
|
321
333
|
...(opts.maxBufferedBytes !== undefined ? { maxBufferedBytes: opts.maxBufferedBytes } : {}),
|
|
334
|
+
...(opts.maxOutboundBufferedBytes !== undefined ? { maxOutboundBufferedBytes: opts.maxOutboundBufferedBytes } : {}),
|
|
322
335
|
sendKey: keys.s2cKey,
|
|
323
336
|
recvKey: keys.c2sKey,
|
|
324
337
|
sendNoncePrefix: keys.s2cNoncePrefix,
|
|
@@ -21,8 +21,10 @@ export type SecureChannelOptions = Readonly<{
|
|
|
21
21
|
maxRecordBytes: number;
|
|
22
22
|
/** Preferred plaintext bytes per outbound record. */
|
|
23
23
|
outboundRecordChunkBytes?: number;
|
|
24
|
-
/** Maximum queued plaintext bytes before
|
|
24
|
+
/** Maximum queued inbound plaintext bytes before the channel is closed. */
|
|
25
25
|
maxBufferedBytes?: number;
|
|
26
|
+
/** Maximum queued outbound plaintext bytes (default 4 MiB; 0 uses default). */
|
|
27
|
+
maxOutboundBufferedBytes?: number;
|
|
26
28
|
}>;
|
|
27
29
|
type Direction = 1 | 2;
|
|
28
30
|
export declare class SecureChannel {
|
|
@@ -30,6 +32,7 @@ export declare class SecureChannel {
|
|
|
30
32
|
private readonly maxRecordBytes;
|
|
31
33
|
private readonly outboundRecordChunkBytes;
|
|
32
34
|
private readonly maxBufferedBytes;
|
|
35
|
+
private readonly maxOutboundBufferedBytes;
|
|
33
36
|
private sendKey;
|
|
34
37
|
private recvKey;
|
|
35
38
|
private sendNoncePrefix;
|
|
@@ -44,6 +47,7 @@ export declare class SecureChannel {
|
|
|
44
47
|
private sendQueueHead;
|
|
45
48
|
private sendWaiters;
|
|
46
49
|
private sendWaitersHead;
|
|
50
|
+
private sendQueueBytes;
|
|
47
51
|
private sendClosed;
|
|
48
52
|
private sendErr;
|
|
49
53
|
private readonly recvQueue;
|
|
@@ -57,6 +61,7 @@ export declare class SecureChannel {
|
|
|
57
61
|
maxRecordBytes: number;
|
|
58
62
|
outboundRecordChunkBytes?: number;
|
|
59
63
|
maxBufferedBytes?: number;
|
|
64
|
+
maxOutboundBufferedBytes?: number;
|
|
60
65
|
sendKey: Uint8Array;
|
|
61
66
|
recvKey: Uint8Array;
|
|
62
67
|
sendNoncePrefix: Uint8Array;
|
|
@@ -17,6 +17,7 @@ export class SecureChannel {
|
|
|
17
17
|
outboundRecordChunkBytes;
|
|
18
18
|
// Upper bound for buffered plaintext in memory.
|
|
19
19
|
maxBufferedBytes;
|
|
20
|
+
maxOutboundBufferedBytes;
|
|
20
21
|
// Active encryption keys and nonce prefixes for the current epoch.
|
|
21
22
|
sendKey;
|
|
22
23
|
recvKey;
|
|
@@ -37,6 +38,7 @@ export class SecureChannel {
|
|
|
37
38
|
sendQueueHead = 0;
|
|
38
39
|
sendWaiters = [];
|
|
39
40
|
sendWaitersHead = 0;
|
|
41
|
+
sendQueueBytes = 0;
|
|
40
42
|
sendClosed = false;
|
|
41
43
|
sendErr = null;
|
|
42
44
|
// Receive queue and waiters for plaintext delivery.
|
|
@@ -55,6 +57,11 @@ export class SecureChannel {
|
|
|
55
57
|
throw new RangeError("outboundRecordChunkBytes must be a positive integer within the record plaintext limit");
|
|
56
58
|
}
|
|
57
59
|
this.maxBufferedBytes = Math.max(0, args.maxBufferedBytes ?? 4 * (1 << 20));
|
|
60
|
+
const maxOutboundBufferedBytes = args.maxOutboundBufferedBytes ?? 4 * (1 << 20);
|
|
61
|
+
if (!Number.isSafeInteger(maxOutboundBufferedBytes) || maxOutboundBufferedBytes < 0) {
|
|
62
|
+
throw new RangeError("maxOutboundBufferedBytes must be a non-negative safe integer");
|
|
63
|
+
}
|
|
64
|
+
this.maxOutboundBufferedBytes = maxOutboundBufferedBytes === 0 ? 4 * (1 << 20) : maxOutboundBufferedBytes;
|
|
58
65
|
this.sendKey = args.sendKey;
|
|
59
66
|
this.recvKey = args.recvKey;
|
|
60
67
|
this.sendNoncePrefix = args.sendNoncePrefix;
|
|
@@ -72,7 +79,7 @@ export class SecureChannel {
|
|
|
72
79
|
async write(plaintext) {
|
|
73
80
|
if (plaintext.length === 0)
|
|
74
81
|
return;
|
|
75
|
-
await this.enqueueSend("app", plaintext
|
|
82
|
+
await this.enqueueSend("app", plaintext);
|
|
76
83
|
}
|
|
77
84
|
// read resolves with the next plaintext chunk or throws on errors/close.
|
|
78
85
|
async read() {
|
|
@@ -121,6 +128,10 @@ export class SecureChannel {
|
|
|
121
128
|
return Promise.reject(this.sendErr);
|
|
122
129
|
if (this.closed || this.sendClosed)
|
|
123
130
|
return Promise.reject(new Error("closed"));
|
|
131
|
+
const bufferedBytes = payload?.byteLength ?? 0;
|
|
132
|
+
if (this.sendQueueBytes + bufferedBytes > this.maxOutboundBufferedBytes) {
|
|
133
|
+
return Promise.reject(new Error("secure channel outbound buffer exceeded"));
|
|
134
|
+
}
|
|
124
135
|
return new Promise((resolve, reject) => {
|
|
125
136
|
if (this.sendErr != null) {
|
|
126
137
|
reject(this.sendErr);
|
|
@@ -130,7 +141,15 @@ export class SecureChannel {
|
|
|
130
141
|
reject(new Error("closed"));
|
|
131
142
|
return;
|
|
132
143
|
}
|
|
133
|
-
|
|
144
|
+
if (this.sendQueueBytes + bufferedBytes > this.maxOutboundBufferedBytes) {
|
|
145
|
+
reject(new Error("secure channel outbound buffer exceeded"));
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
const retainedPayload = payload?.slice();
|
|
149
|
+
const req = retainedPayload === undefined
|
|
150
|
+
? { kind, bufferedBytes, resolve, reject }
|
|
151
|
+
: { kind, payload: retainedPayload, bufferedBytes, resolve, reject };
|
|
152
|
+
this.sendQueueBytes += bufferedBytes;
|
|
134
153
|
this.sendQueue.push(req);
|
|
135
154
|
const w = this.shiftSendWaiter();
|
|
136
155
|
if (w != null)
|
|
@@ -182,8 +201,11 @@ export class SecureChannel {
|
|
|
182
201
|
const start = this.sendQueueHead;
|
|
183
202
|
this.sendQueue = [];
|
|
184
203
|
this.sendQueueHead = 0;
|
|
185
|
-
for (let i = start; i < queued.length; i++)
|
|
186
|
-
queued[i]
|
|
204
|
+
for (let i = start; i < queued.length; i++) {
|
|
205
|
+
const req = queued[i];
|
|
206
|
+
this.sendQueueBytes = Math.max(0, this.sendQueueBytes - req.bufferedBytes);
|
|
207
|
+
req.reject(err);
|
|
208
|
+
}
|
|
187
209
|
}
|
|
188
210
|
failSend(err) {
|
|
189
211
|
if (this.sendErr != null)
|
|
@@ -207,15 +229,15 @@ export class SecureChannel {
|
|
|
207
229
|
const req = await this.nextSend();
|
|
208
230
|
if (req == null)
|
|
209
231
|
return;
|
|
210
|
-
if (this.sendErr != null) {
|
|
211
|
-
req.reject(this.sendErr);
|
|
212
|
-
continue;
|
|
213
|
-
}
|
|
214
|
-
if (this.closed || this.sendClosed) {
|
|
215
|
-
req.reject(new Error("closed"));
|
|
216
|
-
continue;
|
|
217
|
-
}
|
|
218
232
|
try {
|
|
233
|
+
if (this.sendErr != null) {
|
|
234
|
+
req.reject(this.sendErr);
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
if (this.closed || this.sendClosed) {
|
|
238
|
+
req.reject(new Error("closed"));
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
219
241
|
let frame;
|
|
220
242
|
if (req.kind === "app") {
|
|
221
243
|
const payload = req.payload ?? new Uint8Array();
|
|
@@ -247,6 +269,9 @@ export class SecureChannel {
|
|
|
247
269
|
this.close();
|
|
248
270
|
return;
|
|
249
271
|
}
|
|
272
|
+
finally {
|
|
273
|
+
this.sendQueueBytes = Math.max(0, this.sendQueueBytes - req.bufferedBytes);
|
|
274
|
+
}
|
|
250
275
|
}
|
|
251
276
|
}
|
|
252
277
|
async readLoop() {
|
package/dist/proxy/appWindow.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createServiceWorkerControllerGuard, } from "./controllerGuard.js";
|
|
2
2
|
import { createMessagePortBackedStream } from "./portStream.js";
|
|
3
|
-
import { PROXY_WINDOW_FETCH_FORWARD_MSG_TYPE, PROXY_WINDOW_FETCH_MSG_TYPE, PROXY_WINDOW_WS_ERROR_MSG_TYPE, PROXY_WINDOW_WS_OPEN_ACK_MSG_TYPE, PROXY_WINDOW_WS_OPEN_MSG_TYPE, } from "./windowBridgeProtocol.js";
|
|
3
|
+
import { PROXY_WINDOW_FETCH_FORWARD_MSG_TYPE, PROXY_WINDOW_FETCH_MSG_TYPE, PROXY_WINDOW_WS_ERROR_MSG_TYPE, PROXY_WINDOW_WS_OPEN_ACK_MSG_TYPE, PROXY_WINDOW_WS_OPEN_MSG_TYPE, PROXY_WINDOW_WS_WRITE_ACK_CAPABILITY, } from "./windowBridgeProtocol.js";
|
|
4
4
|
function resolveTargetWindow(raw) {
|
|
5
5
|
const target = raw ?? globalThis.window;
|
|
6
6
|
if (target == null)
|
|
@@ -83,7 +83,12 @@ export function registerProxyAppWindow(opts) {
|
|
|
83
83
|
};
|
|
84
84
|
sw?.addEventListener("message", onServiceWorkerMessage);
|
|
85
85
|
const runtime = {
|
|
86
|
-
limits:
|
|
86
|
+
limits: {
|
|
87
|
+
...(opts.maxWsFrameBytes === undefined ? {} : { maxWsFrameBytes: opts.maxWsFrameBytes }),
|
|
88
|
+
...(opts.maxWsBufferedAmountBytes === undefined
|
|
89
|
+
? {}
|
|
90
|
+
: { maxWsBufferedAmountBytes: opts.maxWsBufferedAmountBytes }),
|
|
91
|
+
},
|
|
87
92
|
openWebSocketStream: async (path, wsOpts = {}) => {
|
|
88
93
|
const channel = new MessageChannel();
|
|
89
94
|
const port = channel.port1;
|
|
@@ -102,11 +107,18 @@ export function registerProxyAppWindow(opts) {
|
|
|
102
107
|
}
|
|
103
108
|
reject(error instanceof Error ? error : new Error(String(error)));
|
|
104
109
|
};
|
|
105
|
-
const finishResolve = (
|
|
110
|
+
const finishResolve = (ack) => {
|
|
106
111
|
if (settled)
|
|
107
112
|
return;
|
|
108
113
|
settled = true;
|
|
109
|
-
|
|
114
|
+
const capabilities = Array.isArray(ack.capabilities)
|
|
115
|
+
? ack.capabilities.filter((value) => typeof value === "string")
|
|
116
|
+
: [];
|
|
117
|
+
const writeAcknowledgements = capabilities.includes(PROXY_WINDOW_WS_WRITE_ACK_CAPABILITY);
|
|
118
|
+
resolve({
|
|
119
|
+
stream: createMessagePortBackedStream(port, { writeAcknowledgements }),
|
|
120
|
+
protocol: String(ack.protocol ?? ""),
|
|
121
|
+
});
|
|
110
122
|
};
|
|
111
123
|
port.onmessage = (ev) => {
|
|
112
124
|
const data = ev.data;
|
|
@@ -114,7 +126,7 @@ export function registerProxyAppWindow(opts) {
|
|
|
114
126
|
return;
|
|
115
127
|
const type = typeof data.type === "string" ? data.type : "";
|
|
116
128
|
if (type === PROXY_WINDOW_WS_OPEN_ACK_MSG_TYPE) {
|
|
117
|
-
finishResolve(
|
|
129
|
+
finishResolve(data);
|
|
118
130
|
return;
|
|
119
131
|
}
|
|
120
132
|
if (type === PROXY_WINDOW_WS_ERROR_MSG_TYPE) {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { PROXY_WINDOW_FETCH_MSG_TYPE, PROXY_WINDOW_STREAM_CHUNK_MSG_TYPE, PROXY_WINDOW_STREAM_CLOSE_MSG_TYPE, PROXY_WINDOW_STREAM_END_MSG_TYPE, PROXY_WINDOW_STREAM_RESET_MSG_TYPE, PROXY_WINDOW_WS_ERROR_MSG_TYPE, PROXY_WINDOW_WS_OPEN_ACK_MSG_TYPE, PROXY_WINDOW_WS_OPEN_MSG_TYPE, } from "./windowBridgeProtocol.js";
|
|
1
|
+
import { PROXY_WINDOW_FETCH_MSG_TYPE, PROXY_WINDOW_STREAM_CHUNK_MSG_TYPE, PROXY_WINDOW_STREAM_CLOSE_MSG_TYPE, PROXY_WINDOW_STREAM_END_MSG_TYPE, PROXY_WINDOW_STREAM_RESET_MSG_TYPE, PROXY_WINDOW_STREAM_WRITE_ACK_MSG_TYPE, PROXY_WINDOW_WS_ERROR_MSG_TYPE, PROXY_WINDOW_WS_OPEN_ACK_MSG_TYPE, PROXY_WINDOW_WS_OPEN_MSG_TYPE, PROXY_WINDOW_WS_WRITE_ACK_CAPABILITY, } from "./windowBridgeProtocol.js";
|
|
2
2
|
function normalizeOrigins(origins) {
|
|
3
3
|
const out = [];
|
|
4
4
|
const seen = new Set();
|
|
@@ -41,7 +41,12 @@ function hasExpectedCapability(data, capabilityNonce) {
|
|
|
41
41
|
}
|
|
42
42
|
function bridgeWebSocket(runtime, msg, port) {
|
|
43
43
|
const ac = new AbortController();
|
|
44
|
-
let
|
|
44
|
+
let terminal = false;
|
|
45
|
+
let acceptingWrites = true;
|
|
46
|
+
let stream = null;
|
|
47
|
+
let pendingWriteBytes = 0;
|
|
48
|
+
let writeChain = Promise.resolve();
|
|
49
|
+
const maxBufferedBytes = runtime.limits.maxWsBufferedAmountBytes ?? 4 * (1 << 20);
|
|
45
50
|
const closePort = () => {
|
|
46
51
|
try {
|
|
47
52
|
port.close();
|
|
@@ -50,13 +55,43 @@ function bridgeWebSocket(runtime, msg, port) {
|
|
|
50
55
|
// Best-effort.
|
|
51
56
|
}
|
|
52
57
|
};
|
|
58
|
+
const failBridge = (error) => {
|
|
59
|
+
if (terminal)
|
|
60
|
+
return;
|
|
61
|
+
terminal = true;
|
|
62
|
+
acceptingWrites = false;
|
|
63
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
64
|
+
try {
|
|
65
|
+
stream?.reset(err);
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
// Best-effort.
|
|
69
|
+
}
|
|
70
|
+
try {
|
|
71
|
+
ac.abort(err.message);
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
// Best-effort.
|
|
75
|
+
}
|
|
76
|
+
try {
|
|
77
|
+
port.postMessage({
|
|
78
|
+
type: PROXY_WINDOW_STREAM_RESET_MSG_TYPE,
|
|
79
|
+
message: err.message,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
// Best-effort.
|
|
84
|
+
}
|
|
85
|
+
closePort();
|
|
86
|
+
};
|
|
53
87
|
void (async () => {
|
|
54
88
|
try {
|
|
55
89
|
const wsOpts = {
|
|
56
90
|
signal: ac.signal,
|
|
57
91
|
...(msg.protocols === undefined ? {} : { protocols: msg.protocols }),
|
|
58
92
|
};
|
|
59
|
-
const
|
|
93
|
+
const opened = await runtime.openWebSocketStream(msg.path, wsOpts);
|
|
94
|
+
stream = opened.stream;
|
|
60
95
|
port.onmessage = (ev) => {
|
|
61
96
|
const data = ev.data;
|
|
62
97
|
if (data == null || typeof data !== "object")
|
|
@@ -64,21 +99,50 @@ function bridgeWebSocket(runtime, msg, port) {
|
|
|
64
99
|
const type = typeof data.type === "string" ? data.type : "";
|
|
65
100
|
switch (type) {
|
|
66
101
|
case PROXY_WINDOW_STREAM_CHUNK_MSG_TYPE: {
|
|
102
|
+
if (!acceptingWrites || stream == null)
|
|
103
|
+
return;
|
|
67
104
|
const raw = data.data;
|
|
68
105
|
if (!(raw instanceof ArrayBuffer))
|
|
69
106
|
return;
|
|
70
|
-
|
|
107
|
+
if (pendingWriteBytes + raw.byteLength > maxBufferedBytes) {
|
|
108
|
+
failBridge(new Error("proxy WebSocket outbound buffer exceeded"));
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
const rawWriteId = data.writeId;
|
|
112
|
+
const writeId = Number.isSafeInteger(rawWriteId) && Number(rawWriteId) > 0
|
|
113
|
+
? Number(rawWriteId)
|
|
114
|
+
: undefined;
|
|
115
|
+
const chunk = new Uint8Array(raw);
|
|
116
|
+
pendingWriteBytes += chunk.byteLength;
|
|
117
|
+
writeChain = writeChain
|
|
118
|
+
.then(async () => {
|
|
119
|
+
if (terminal || stream == null)
|
|
120
|
+
throw new Error("stream is closed");
|
|
121
|
+
await stream.write(chunk);
|
|
122
|
+
if (terminal || writeId === undefined)
|
|
123
|
+
return;
|
|
124
|
+
port.postMessage({
|
|
125
|
+
type: PROXY_WINDOW_STREAM_WRITE_ACK_MSG_TYPE,
|
|
126
|
+
writeId,
|
|
127
|
+
});
|
|
128
|
+
})
|
|
129
|
+
.catch((error) => failBridge(error))
|
|
130
|
+
.finally(() => {
|
|
131
|
+
pendingWriteBytes = Math.max(0, pendingWriteBytes - chunk.byteLength);
|
|
132
|
+
});
|
|
71
133
|
return;
|
|
72
134
|
}
|
|
73
135
|
case PROXY_WINDOW_STREAM_CLOSE_MSG_TYPE:
|
|
74
|
-
|
|
136
|
+
if (!acceptingWrites || stream == null)
|
|
137
|
+
return;
|
|
138
|
+
acceptingWrites = false;
|
|
139
|
+
writeChain = writeChain
|
|
140
|
+
.then(() => stream?.close())
|
|
141
|
+
.catch((error) => failBridge(error));
|
|
75
142
|
return;
|
|
76
143
|
case PROXY_WINDOW_STREAM_RESET_MSG_TYPE: {
|
|
77
144
|
const message = String(data.message ?? "stream reset");
|
|
78
|
-
|
|
79
|
-
streamClosed = true;
|
|
80
|
-
ac.abort(message);
|
|
81
|
-
closePort();
|
|
145
|
+
failBridge(new Error(message));
|
|
82
146
|
return;
|
|
83
147
|
}
|
|
84
148
|
default:
|
|
@@ -86,11 +150,16 @@ function bridgeWebSocket(runtime, msg, port) {
|
|
|
86
150
|
}
|
|
87
151
|
};
|
|
88
152
|
port.start?.();
|
|
89
|
-
port.postMessage({
|
|
90
|
-
|
|
153
|
+
port.postMessage({
|
|
154
|
+
type: PROXY_WINDOW_WS_OPEN_ACK_MSG_TYPE,
|
|
155
|
+
protocol: opened.protocol,
|
|
156
|
+
capabilities: [PROXY_WINDOW_WS_WRITE_ACK_CAPABILITY],
|
|
157
|
+
});
|
|
158
|
+
while (!terminal) {
|
|
91
159
|
const chunk = await stream.read();
|
|
92
160
|
if (chunk == null) {
|
|
93
|
-
|
|
161
|
+
terminal = true;
|
|
162
|
+
acceptingWrites = false;
|
|
94
163
|
port.postMessage({ type: PROXY_WINDOW_STREAM_END_MSG_TYPE });
|
|
95
164
|
closePort();
|
|
96
165
|
return;
|
|
@@ -101,8 +170,12 @@ function bridgeWebSocket(runtime, msg, port) {
|
|
|
101
170
|
}
|
|
102
171
|
catch (error) {
|
|
103
172
|
const message = error instanceof Error ? error.message : String(error);
|
|
104
|
-
|
|
105
|
-
|
|
173
|
+
if (stream == null) {
|
|
174
|
+
port.postMessage({ type: PROXY_WINDOW_WS_ERROR_MSG_TYPE, message });
|
|
175
|
+
closePort();
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
failBridge(error);
|
|
106
179
|
}
|
|
107
180
|
})();
|
|
108
181
|
}
|
|
@@ -16,15 +16,24 @@ const DEFAULT_RESPONSE_HEADER_ALLOWLIST = new Set([
|
|
|
16
16
|
"content-disposition",
|
|
17
17
|
"content-encoding",
|
|
18
18
|
"content-language",
|
|
19
|
+
"content-security-policy",
|
|
20
|
+
"content-security-policy-report-only",
|
|
19
21
|
"content-type",
|
|
22
|
+
"cross-origin-embedder-policy",
|
|
23
|
+
"cross-origin-opener-policy",
|
|
24
|
+
"cross-origin-resource-policy",
|
|
20
25
|
"etag",
|
|
21
26
|
"expires",
|
|
22
27
|
"last-modified",
|
|
23
28
|
"location",
|
|
29
|
+
"permissions-policy",
|
|
24
30
|
"pragma",
|
|
31
|
+
"referrer-policy",
|
|
25
32
|
"vary",
|
|
26
33
|
"www-authenticate",
|
|
27
|
-
"set-cookie"
|
|
34
|
+
"set-cookie",
|
|
35
|
+
"x-content-type-options",
|
|
36
|
+
"x-frame-options"
|
|
28
37
|
]);
|
|
29
38
|
const DEFAULT_WS_HEADER_ALLOWLIST = new Set(["sec-websocket-protocol", "cookie"]);
|
|
30
39
|
export function normalizeHeaderName(name) {
|
|
@@ -33,6 +33,10 @@ export type RegisterProxyIntegrationOptions = Readonly<{
|
|
|
33
33
|
maxChunkBytes?: number;
|
|
34
34
|
maxBodyBytes?: number;
|
|
35
35
|
maxWsFrameBytes?: number;
|
|
36
|
+
maxWsBufferedAmountBytes?: number;
|
|
37
|
+
maxConcurrentHttpStreams?: number;
|
|
38
|
+
maxQueuedHttpRequests?: number;
|
|
39
|
+
maxQueuedHttpBodyBytes?: number;
|
|
36
40
|
timeoutMs?: number;
|
|
37
41
|
pathPolicy?: ProxyRuntimePathPolicy;
|
|
38
42
|
externalOrigin?: string;
|
|
@@ -180,7 +180,19 @@ function buildRuntimeOptions(preset, runtime) {
|
|
|
180
180
|
maxChunkBytes: runtime?.maxChunkBytes ?? preset.limits.max_chunk_bytes,
|
|
181
181
|
maxBodyBytes: runtime?.maxBodyBytes ?? preset.limits.max_body_bytes,
|
|
182
182
|
maxWsFrameBytes: runtime?.maxWsFrameBytes ?? preset.limits.max_ws_frame_bytes,
|
|
183
|
+
...(runtime?.maxWsBufferedAmountBytes === undefined
|
|
184
|
+
? {}
|
|
185
|
+
: { maxWsBufferedAmountBytes: runtime.maxWsBufferedAmountBytes }),
|
|
183
186
|
timeoutMs: runtime?.timeoutMs ?? preset.limits.timeout_ms ?? 0,
|
|
187
|
+
...(runtime?.maxConcurrentHttpStreams === undefined
|
|
188
|
+
? {}
|
|
189
|
+
: { maxConcurrentHttpStreams: runtime.maxConcurrentHttpStreams }),
|
|
190
|
+
...(runtime?.maxQueuedHttpRequests === undefined
|
|
191
|
+
? {}
|
|
192
|
+
: { maxQueuedHttpRequests: runtime.maxQueuedHttpRequests }),
|
|
193
|
+
...(runtime?.maxQueuedHttpBodyBytes === undefined
|
|
194
|
+
? {}
|
|
195
|
+
: { maxQueuedHttpBodyBytes: runtime.maxQueuedHttpBodyBytes }),
|
|
184
196
|
...(runtime?.pathPolicy === undefined ? {} : { pathPolicy: runtime.pathPolicy }),
|
|
185
197
|
...(runtime?.externalOrigin === undefined ? {} : { externalOrigin: runtime.externalOrigin }),
|
|
186
198
|
...(runtime?.runtimeRegistrationToken === undefined ? {} : { runtimeRegistrationToken: runtime.runtimeRegistrationToken }),
|
|
@@ -1,2 +1,4 @@
|
|
|
1
1
|
import type { YamuxStream } from "../yamux/stream.js";
|
|
2
|
-
export declare function createMessagePortBackedStream(port: MessagePort
|
|
2
|
+
export declare function createMessagePortBackedStream(port: MessagePort, opts?: Readonly<{
|
|
3
|
+
writeAcknowledgements?: boolean;
|
|
4
|
+
}>): YamuxStream;
|