@floegence/flowersec-core 0.24.0 → 0.25.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/README.md +6 -0
- package/dist/client-connect/transportSecurity.js +1 -1
- package/dist/endpoint/index.js +185 -33
- package/dist/proxy/appWindow.js +93 -7
- package/dist/proxy/controllerWindow.js +219 -78
- package/dist/proxy/portStream.d.ts +2 -1
- package/dist/proxy/portStream.js +173 -74
- package/dist/proxy/server.d.ts +2 -0
- package/dist/proxy/server.js +346 -105
- package/dist/proxy/windowBridgeProtocol.d.ts +4 -3
- package/dist/proxy/windowBridgeProtocol.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -27,6 +27,12 @@ Start with the [TypeScript cookbook](https://github.com/floegence/flowersec/tree
|
|
|
27
27
|
|
|
28
28
|
High-level WebSocket connections require TLS by default. Use `AllowPlaintextForLoopback` only for literal local development targets.
|
|
29
29
|
|
|
30
|
+
## Node Proxy Server
|
|
31
|
+
|
|
32
|
+
The Node entrypoint exports `serveProxySession(...)`, `serveProxyStream(...)`, and `ProxyServerOptions`. Request bodies remain bounded per request by `maxBodyBytes`; `maxBufferedRequestBodyBytes` additionally caps the total buffered request-body bytes owned by one proxy session. WebSocket frames remain bounded by `maxWsFrameBytes`; `maxWsQueuedBytes` additionally caps upstream-to-Yamux queued bytes per connection, while the reverse direction waits for each Node `ws` send callback.
|
|
33
|
+
|
|
34
|
+
The defaults keep one session request-body budget equal to `maxBodyBytes` and one queued WebSocket frame plus its proxy header. Raise either limit only when the deployment has a matching memory budget.
|
|
35
|
+
|
|
30
36
|
## Runtime Boundaries
|
|
31
37
|
|
|
32
38
|
TypeScript owns browser and Service Worker integration. Shared tunnel, proxy gateway, and helper binaries remain Go-owned.
|
|
@@ -35,7 +35,7 @@ export async function enforceTransportSecurity(args) {
|
|
|
35
35
|
const policy = args.policy ?? RequireTLS;
|
|
36
36
|
try {
|
|
37
37
|
if (typeof policy === "function") {
|
|
38
|
-
allowed = await policy(input);
|
|
38
|
+
allowed = await policy(input) === true;
|
|
39
39
|
}
|
|
40
40
|
else {
|
|
41
41
|
allowed = evaluatePreset(policy, target);
|
package/dist/endpoint/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Role as ControlRole, assertChannelInitGrant } from "../gen/flowersec/controlplane/v1.gen.js";
|
|
2
2
|
import { Role as TunnelRole } from "../gen/flowersec/tunnel/v1.gen.js";
|
|
3
|
+
import { withAbortAndTimeout } from "../client-connect/common.js";
|
|
3
4
|
import { assertTunnelGrantContract, assertValidPSK, prepareChannelId } from "../client-connect/contract.js";
|
|
4
5
|
import { enforceTransportSecurity } from "../client-connect/transportSecurity.js";
|
|
5
6
|
import { SDK_DEFAULTS } from "../defaults.js";
|
|
@@ -12,7 +13,7 @@ import { isYamuxPingTimeoutError } from "../yamux/errors.js";
|
|
|
12
13
|
import { YamuxSession } from "../yamux/session.js";
|
|
13
14
|
import { RpcServer } from "../rpc/server.js";
|
|
14
15
|
import { base64urlDecode, base64urlEncode } from "../utils/base64url.js";
|
|
15
|
-
import { AbortError, FlowersecError } from "../utils/errors.js";
|
|
16
|
+
import { AbortError, FlowersecError, TimeoutError, throwIfAborted } from "../utils/errors.js";
|
|
16
17
|
import { WebSocketBinaryTransport } from "../ws-client/binaryTransport.js";
|
|
17
18
|
export class Session {
|
|
18
19
|
secure;
|
|
@@ -152,15 +153,46 @@ export class Session {
|
|
|
152
153
|
}
|
|
153
154
|
}
|
|
154
155
|
export async function acceptDirect(websocket, handshake, options = {}) {
|
|
156
|
+
const handshakeTimeoutMs = options.handshakeTimeoutMs ?? SDK_DEFAULTS.transport.handshakeTimeoutMs;
|
|
157
|
+
if (!Number.isFinite(handshakeTimeoutMs) || handshakeTimeoutMs < 0) {
|
|
158
|
+
throw new FlowersecError({
|
|
159
|
+
path: "direct",
|
|
160
|
+
stage: "validate",
|
|
161
|
+
code: "invalid_option",
|
|
162
|
+
message: "handshakeTimeoutMs must be a non-negative number",
|
|
163
|
+
});
|
|
164
|
+
}
|
|
155
165
|
await enforceIncomingDirectTransport(options);
|
|
166
|
+
const deadline = createHandshakeDeadline(handshakeTimeoutMs);
|
|
156
167
|
const transport = new WebSocketBinaryTransport(websocket, webSocketTransportOptions(options));
|
|
157
|
-
return await establishSession("direct", transport, handshake, options);
|
|
168
|
+
return await establishSession("direct", transport, handshake, options, undefined, deadline);
|
|
158
169
|
}
|
|
159
170
|
export async function acceptDirectResolved(websocket, resolver, options = {}) {
|
|
171
|
+
const handshakeTimeoutMs = options.handshakeTimeoutMs ?? SDK_DEFAULTS.transport.handshakeTimeoutMs;
|
|
172
|
+
if (!Number.isFinite(handshakeTimeoutMs) || handshakeTimeoutMs < 0) {
|
|
173
|
+
throw new FlowersecError({
|
|
174
|
+
path: "direct",
|
|
175
|
+
stage: "validate",
|
|
176
|
+
code: "invalid_option",
|
|
177
|
+
message: "handshakeTimeoutMs must be a non-negative number",
|
|
178
|
+
});
|
|
179
|
+
}
|
|
160
180
|
await enforceIncomingDirectTransport(options);
|
|
181
|
+
const deadline = createHandshakeDeadline(handshakeTimeoutMs);
|
|
161
182
|
const transport = new WebSocketBinaryTransport(websocket, webSocketTransportOptions(options));
|
|
162
|
-
|
|
183
|
+
let first;
|
|
184
|
+
try {
|
|
185
|
+
first = await runHandshakeStep(deadline, options.signal, () => transport.readBinary({
|
|
186
|
+
...(options.signal === undefined ? {} : { signal: options.signal }),
|
|
187
|
+
timeoutMs: remainingHandshakeTimeoutMs(deadline),
|
|
188
|
+
}));
|
|
189
|
+
}
|
|
190
|
+
catch (error) {
|
|
191
|
+
transport.close();
|
|
192
|
+
throw endpointHandshakeError("direct", error, "failed to read handshake init");
|
|
193
|
+
}
|
|
163
194
|
let init;
|
|
195
|
+
let channelId;
|
|
164
196
|
try {
|
|
165
197
|
const decoded = decodeHandshakeFrame(first, options.maxHandshakePayload ?? SDK_DEFAULTS.e2ee.maxHandshakePayloadBytes);
|
|
166
198
|
if (decoded.handshakeType !== HANDSHAKE_TYPE_INIT)
|
|
@@ -168,30 +200,44 @@ export async function acceptDirectResolved(websocket, resolver, options = {}) {
|
|
|
168
200
|
init = JSON.parse(new TextDecoder().decode(decoded.payloadJsonUtf8));
|
|
169
201
|
if (init.version !== PROTOCOL_VERSION || init.role !== 1 || (init.suite !== 1 && init.suite !== 2))
|
|
170
202
|
throw new Error("invalid handshake init");
|
|
203
|
+
channelId = prepareChannelId(init.channel_id, "direct");
|
|
204
|
+
if (channelId !== init.channel_id) {
|
|
205
|
+
throw new FlowersecError({
|
|
206
|
+
path: "direct",
|
|
207
|
+
stage: "validate",
|
|
208
|
+
code: "invalid_input",
|
|
209
|
+
message: "channel_id must not have leading or trailing whitespace",
|
|
210
|
+
});
|
|
211
|
+
}
|
|
171
212
|
}
|
|
172
213
|
catch (error) {
|
|
173
214
|
transport.close();
|
|
215
|
+
if (error instanceof FlowersecError)
|
|
216
|
+
throw error;
|
|
174
217
|
throw new FlowersecError({ path: "direct", stage: "handshake", code: "handshake_failed", message: "invalid handshake init", cause: error });
|
|
175
218
|
}
|
|
176
219
|
let credential;
|
|
177
220
|
try {
|
|
178
|
-
credential = await resolver({
|
|
179
|
-
channelId
|
|
221
|
+
credential = await runHandshakeStep(deadline, options.signal, () => resolver({
|
|
222
|
+
channelId,
|
|
180
223
|
version: init.version,
|
|
181
224
|
suite: init.suite,
|
|
182
225
|
clientFeatures: init.client_features >>> 0,
|
|
183
|
-
});
|
|
226
|
+
}));
|
|
184
227
|
}
|
|
185
228
|
catch (error) {
|
|
186
229
|
transport.close();
|
|
230
|
+
const interrupted = endpointHandshakeInterruption("direct", error);
|
|
231
|
+
if (interrupted != null)
|
|
232
|
+
throw interrupted;
|
|
187
233
|
throw new FlowersecError({ path: "direct", stage: "validate", code: "resolve_failed", message: "credential resolution failed", cause: error });
|
|
188
234
|
}
|
|
189
235
|
const replay = new PrefetchedTransport(transport, first);
|
|
190
236
|
return await establishSession("direct", replay, {
|
|
191
|
-
channelId
|
|
237
|
+
channelId,
|
|
192
238
|
suite: init.suite,
|
|
193
239
|
...credential,
|
|
194
|
-
}, options);
|
|
240
|
+
}, options, undefined, deadline);
|
|
195
241
|
}
|
|
196
242
|
export async function connectTunnel(grantInput, options) {
|
|
197
243
|
let grant;
|
|
@@ -202,6 +248,7 @@ export async function connectTunnel(grantInput, options) {
|
|
|
202
248
|
throw new FlowersecError({ path: "tunnel", stage: "validate", code: "invalid_input", message: "invalid ChannelInitGrant", cause: error });
|
|
203
249
|
}
|
|
204
250
|
assertTunnelGrantContract(grant, ControlRole.Role_server);
|
|
251
|
+
const channelId = prepareChannelId(grant.channel_id, "tunnel");
|
|
205
252
|
const tunnelUrl = grant.tunnel_url.trim();
|
|
206
253
|
if (tunnelUrl === "")
|
|
207
254
|
throw new FlowersecError({ path: "tunnel", stage: "validate", code: "missing_tunnel_url", message: "missing tunnel_url" });
|
|
@@ -212,35 +259,63 @@ export async function connectTunnel(grantInput, options) {
|
|
|
212
259
|
const origin = options.origin.trim();
|
|
213
260
|
if (origin === "")
|
|
214
261
|
throw new FlowersecError({ path: "tunnel", stage: "validate", code: "missing_origin", message: "missing origin" });
|
|
262
|
+
const connectTimeoutMs = options.connectTimeoutMs ?? SDK_DEFAULTS.transport.connectTimeoutMs;
|
|
263
|
+
if (!Number.isFinite(connectTimeoutMs) || connectTimeoutMs < 0) {
|
|
264
|
+
throw new FlowersecError({
|
|
265
|
+
path: "tunnel",
|
|
266
|
+
stage: "validate",
|
|
267
|
+
code: "invalid_option",
|
|
268
|
+
message: "connectTimeoutMs must be a non-negative number",
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
const handshakeTimeoutMs = options.handshakeTimeoutMs ?? SDK_DEFAULTS.transport.handshakeTimeoutMs;
|
|
272
|
+
if (!Number.isFinite(handshakeTimeoutMs) || handshakeTimeoutMs < 0) {
|
|
273
|
+
throw new FlowersecError({
|
|
274
|
+
path: "tunnel",
|
|
275
|
+
stage: "validate",
|
|
276
|
+
code: "invalid_option",
|
|
277
|
+
message: "handshakeTimeoutMs must be a non-negative number",
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
const psk = assertValidPSK(grant.e2ee_psk_b64u, "tunnel");
|
|
215
281
|
await enforceTransportSecurity({ rawUrl: tunnelUrl, path: "tunnel", ...(options.transportSecurityPolicy === undefined ? {} : { policy: options.transportSecurityPolicy }) });
|
|
216
282
|
const endpointInstanceId = normalizeEndpointInstanceId(options.endpointInstanceId);
|
|
217
|
-
|
|
218
|
-
const transport = new WebSocketBinaryTransport(websocket, webSocketTransportOptions(options));
|
|
283
|
+
let transport;
|
|
219
284
|
try {
|
|
220
|
-
|
|
285
|
+
throwIfAborted(options.signal, "connect aborted");
|
|
286
|
+
const websocket = options.wsFactory(tunnelUrl, origin);
|
|
287
|
+
transport = new WebSocketBinaryTransport(websocket, webSocketTransportOptions(options));
|
|
288
|
+
await waitForOpen(websocket, connectTimeoutMs, options.signal);
|
|
289
|
+
throwIfAborted(options.signal, "connect aborted");
|
|
221
290
|
const attach = {
|
|
222
291
|
v: 1,
|
|
223
|
-
channel_id:
|
|
292
|
+
channel_id: channelId,
|
|
224
293
|
role: TunnelRole.Role_server,
|
|
225
294
|
token: grant.token.trim(),
|
|
226
295
|
endpoint_instance_id: endpointInstanceId,
|
|
227
296
|
};
|
|
228
297
|
websocket.send(JSON.stringify(attach));
|
|
229
298
|
return await establishSession("tunnel", transport, {
|
|
230
|
-
channelId
|
|
299
|
+
channelId,
|
|
231
300
|
suite: grant.default_suite,
|
|
232
|
-
psk
|
|
301
|
+
psk,
|
|
233
302
|
initExpireAtUnixS: grant.channel_init_expire_at_unix_s,
|
|
234
303
|
}, options, endpointInstanceId);
|
|
235
304
|
}
|
|
236
305
|
catch (error) {
|
|
237
|
-
transport
|
|
306
|
+
transport?.close();
|
|
238
307
|
if (error instanceof FlowersecError)
|
|
239
308
|
throw error;
|
|
309
|
+
if (error instanceof TimeoutError) {
|
|
310
|
+
throw new FlowersecError({ path: "tunnel", stage: "connect", code: "timeout", message: "endpoint tunnel connect timed out", cause: error });
|
|
311
|
+
}
|
|
312
|
+
if (error instanceof AbortError) {
|
|
313
|
+
throw new FlowersecError({ path: "tunnel", stage: "connect", code: "canceled", message: "endpoint tunnel connect canceled", cause: error });
|
|
314
|
+
}
|
|
240
315
|
throw new FlowersecError({ path: "tunnel", stage: "connect", code: "dial_failed", message: "endpoint tunnel connect failed", cause: error });
|
|
241
316
|
}
|
|
242
317
|
}
|
|
243
|
-
async function establishSession(path, transport, handshake, options, endpointInstanceId) {
|
|
318
|
+
async function establishSession(path, transport, handshake, options, endpointInstanceId, deadline) {
|
|
244
319
|
let psk;
|
|
245
320
|
try {
|
|
246
321
|
psk = normalizePSK(handshake.psk, path);
|
|
@@ -250,7 +325,7 @@ async function establishSession(path, transport, handshake, options, endpointIns
|
|
|
250
325
|
throw error;
|
|
251
326
|
}
|
|
252
327
|
try {
|
|
253
|
-
const
|
|
328
|
+
const startHandshake = () => serverHandshake(transport, options.handshakeCache ?? new ServerHandshakeCache(), {
|
|
254
329
|
channelId: prepareChannelId(handshake.channelId, path),
|
|
255
330
|
suite: handshake.suite,
|
|
256
331
|
psk,
|
|
@@ -262,25 +337,52 @@ async function establishSession(path, transport, handshake, options, endpointIns
|
|
|
262
337
|
outboundRecordChunkBytes: options.outboundRecordChunkBytes ?? SDK_DEFAULTS.e2ee.outboundRecordChunkBytes,
|
|
263
338
|
maxBufferedBytes: options.maxBufferedBytes ?? SDK_DEFAULTS.e2ee.maxInboundBufferedBytes,
|
|
264
339
|
maxOutboundBufferedBytes: options.maxOutboundBufferedBytes ?? SDK_DEFAULTS.e2ee.maxOutboundBufferedBytes,
|
|
265
|
-
timeoutMs:
|
|
340
|
+
timeoutMs: deadline == null
|
|
341
|
+
? options.handshakeTimeoutMs ?? SDK_DEFAULTS.transport.handshakeTimeoutMs
|
|
342
|
+
: remainingHandshakeTimeoutMs(deadline),
|
|
266
343
|
...(options.signal === undefined ? {} : { signal: options.signal }),
|
|
267
344
|
});
|
|
345
|
+
const secure = deadline == null
|
|
346
|
+
? await startHandshake()
|
|
347
|
+
: await runHandshakeStep(deadline, options.signal, startHandshake);
|
|
268
348
|
try {
|
|
269
|
-
|
|
349
|
+
if (handshake.commitAuthenticated != null) {
|
|
350
|
+
if (deadline == null) {
|
|
351
|
+
await handshake.commitAuthenticated();
|
|
352
|
+
}
|
|
353
|
+
else {
|
|
354
|
+
await runHandshakeStep(deadline, options.signal, handshake.commitAuthenticated);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
270
357
|
}
|
|
271
358
|
catch (error) {
|
|
272
359
|
secure.close();
|
|
360
|
+
const interrupted = endpointHandshakeInterruption(path, error);
|
|
361
|
+
if (interrupted != null)
|
|
362
|
+
throw interrupted;
|
|
273
363
|
throw new FlowersecError({ path, stage: "handshake", code: "credential_commit_failed", message: "credential commit failed", cause: error });
|
|
274
364
|
}
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
365
|
+
try {
|
|
366
|
+
throwIfAborted(options.signal, "handshake canceled");
|
|
367
|
+
if (deadline != null)
|
|
368
|
+
remainingHandshakeTimeoutMs(deadline);
|
|
369
|
+
return Session.create(path, secure, {
|
|
370
|
+
...(options.yamuxLimits === undefined ? {} : { yamuxLimits: options.yamuxLimits }),
|
|
371
|
+
...(endpointInstanceId === undefined ? {} : { endpointInstanceId }),
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
catch (error) {
|
|
375
|
+
secure.close();
|
|
376
|
+
throw error;
|
|
377
|
+
}
|
|
279
378
|
}
|
|
280
379
|
catch (error) {
|
|
281
380
|
transport.close();
|
|
282
381
|
if (error instanceof FlowersecError)
|
|
283
382
|
throw error;
|
|
383
|
+
const interrupted = endpointHandshakeInterruption(path, error);
|
|
384
|
+
if (interrupted != null)
|
|
385
|
+
throw interrupted;
|
|
284
386
|
throw new FlowersecError({ path, stage: "handshake", code: "handshake_failed", message: "endpoint handshake failed", cause: error });
|
|
285
387
|
}
|
|
286
388
|
finally {
|
|
@@ -352,22 +454,25 @@ function unwrapServerGrant(input) {
|
|
|
352
454
|
function webSocketTransportOptions(options) {
|
|
353
455
|
return options.webSocketLimits === undefined ? {} : { webSocketLimits: options.webSocketLimits };
|
|
354
456
|
}
|
|
355
|
-
function readOptions(options) {
|
|
356
|
-
return {
|
|
357
|
-
timeoutMs: options.handshakeTimeoutMs ?? SDK_DEFAULTS.transport.handshakeTimeoutMs,
|
|
358
|
-
...(options.signal === undefined ? {} : { signal: options.signal }),
|
|
359
|
-
};
|
|
360
|
-
}
|
|
361
457
|
async function enforceIncomingDirectTransport(options) {
|
|
362
458
|
const rawUrl = options.secureTransport === false ? "ws://127.0.0.1/" : "wss://127.0.0.1/";
|
|
363
459
|
await enforceTransportSecurity({ rawUrl, path: "direct", ...(options.transportSecurityPolicy === undefined ? {} : { policy: options.transportSecurityPolicy }) });
|
|
364
460
|
}
|
|
365
461
|
function waitForOpen(websocket, timeoutMs, signal) {
|
|
366
|
-
if (websocket.readyState === 1)
|
|
367
|
-
return Promise.resolve();
|
|
368
462
|
if (!Number.isFinite(timeoutMs) || timeoutMs < 0)
|
|
369
463
|
return Promise.reject(new RangeError("connectTimeoutMs must be non-negative"));
|
|
464
|
+
try {
|
|
465
|
+
throwIfAborted(signal, "connect aborted");
|
|
466
|
+
}
|
|
467
|
+
catch (error) {
|
|
468
|
+
return Promise.reject(error);
|
|
469
|
+
}
|
|
470
|
+
if (websocket.readyState === 1)
|
|
471
|
+
return Promise.resolve();
|
|
472
|
+
if (websocket.readyState >= 2)
|
|
473
|
+
return Promise.reject(new Error("websocket closed before open"));
|
|
370
474
|
return new Promise((resolve, reject) => {
|
|
475
|
+
let settled = false;
|
|
371
476
|
let timer;
|
|
372
477
|
const cleanup = () => {
|
|
373
478
|
if (timer != null)
|
|
@@ -378,6 +483,9 @@ function waitForOpen(websocket, timeoutMs, signal) {
|
|
|
378
483
|
signal?.removeEventListener("abort", onAbort);
|
|
379
484
|
};
|
|
380
485
|
const finish = (error) => {
|
|
486
|
+
if (settled)
|
|
487
|
+
return;
|
|
488
|
+
settled = true;
|
|
381
489
|
cleanup();
|
|
382
490
|
if (error == null)
|
|
383
491
|
resolve();
|
|
@@ -392,14 +500,58 @@ function waitForOpen(websocket, timeoutMs, signal) {
|
|
|
392
500
|
websocket.addEventListener("error", onError);
|
|
393
501
|
websocket.addEventListener("close", onClose);
|
|
394
502
|
signal?.addEventListener("abort", onAbort, { once: true });
|
|
395
|
-
if (
|
|
396
|
-
|
|
503
|
+
if (signal?.aborted) {
|
|
504
|
+
onAbort();
|
|
505
|
+
}
|
|
506
|
+
else if (websocket.readyState === 1) {
|
|
507
|
+
onOpen();
|
|
508
|
+
}
|
|
509
|
+
else if (websocket.readyState >= 2) {
|
|
510
|
+
onClose();
|
|
511
|
+
}
|
|
512
|
+
if (!settled && timeoutMs > 0)
|
|
513
|
+
timer = setTimeout(() => finish(new TimeoutError("websocket open timeout")), timeoutMs);
|
|
397
514
|
});
|
|
398
515
|
}
|
|
399
516
|
function cleanupWaiter(waiter) {
|
|
400
517
|
if (waiter.onAbort != null)
|
|
401
518
|
waiter.signal?.removeEventListener("abort", waiter.onAbort);
|
|
402
519
|
}
|
|
520
|
+
function createHandshakeDeadline(timeoutMs) {
|
|
521
|
+
return timeoutMs > 0 ? { expiresAtMs: Date.now() + timeoutMs } : {};
|
|
522
|
+
}
|
|
523
|
+
function remainingHandshakeTimeoutMs(deadline) {
|
|
524
|
+
if (deadline.expiresAtMs === undefined)
|
|
525
|
+
return 0;
|
|
526
|
+
const remaining = deadline.expiresAtMs - Date.now();
|
|
527
|
+
if (remaining <= 0)
|
|
528
|
+
throw new TimeoutError("handshake timeout");
|
|
529
|
+
return remaining;
|
|
530
|
+
}
|
|
531
|
+
async function runHandshakeStep(deadline, signal, operation) {
|
|
532
|
+
throwIfAborted(signal, "handshake canceled");
|
|
533
|
+
const timeoutMs = remainingHandshakeTimeoutMs(deadline);
|
|
534
|
+
const result = await withAbortAndTimeout(Promise.resolve().then(operation), {
|
|
535
|
+
timeoutMs,
|
|
536
|
+
...(signal === undefined ? {} : { signal }),
|
|
537
|
+
});
|
|
538
|
+
throwIfAborted(signal, "handshake canceled");
|
|
539
|
+
remainingHandshakeTimeoutMs(deadline);
|
|
540
|
+
return result;
|
|
541
|
+
}
|
|
542
|
+
function endpointHandshakeInterruption(path, error) {
|
|
543
|
+
if (error instanceof TimeoutError) {
|
|
544
|
+
return new FlowersecError({ path, stage: "handshake", code: "timeout", message: "endpoint handshake timed out", cause: error });
|
|
545
|
+
}
|
|
546
|
+
if (error instanceof AbortError) {
|
|
547
|
+
return new FlowersecError({ path, stage: "handshake", code: "canceled", message: "endpoint handshake canceled", cause: error });
|
|
548
|
+
}
|
|
549
|
+
return undefined;
|
|
550
|
+
}
|
|
551
|
+
function endpointHandshakeError(path, error, message) {
|
|
552
|
+
return endpointHandshakeInterruption(path, error)
|
|
553
|
+
?? new FlowersecError({ path, stage: "handshake", code: "handshake_failed", message, cause: error });
|
|
554
|
+
}
|
|
403
555
|
function asError(error) {
|
|
404
556
|
return error instanceof Error ? error : new Error(String(error));
|
|
405
557
|
}
|
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,
|
|
3
|
+
import { PROXY_WINDOW_FETCH_FORWARD_MSG_TYPE, PROXY_WINDOW_FETCH_MSG_TYPE, PROXY_WINDOW_STREAM_RESET_MSG_TYPE, PROXY_WINDOW_WS_ERROR_MSG_TYPE, PROXY_WINDOW_WS_BIDIRECTIONAL_ACK_CAPABILITY, PROXY_WINDOW_WS_OPEN_ACK_MSG_TYPE, PROXY_WINDOW_WS_OPEN_MSG_TYPE, } from "./windowBridgeProtocol.js";
|
|
4
4
|
function resolveTargetWindow(raw) {
|
|
5
5
|
const target = raw ?? globalThis.window;
|
|
6
6
|
if (target == null)
|
|
@@ -59,6 +59,8 @@ export function registerProxyAppWindow(opts) {
|
|
|
59
59
|
const targetWindow = resolveTargetWindow(opts.targetWindow);
|
|
60
60
|
const controllerWindow = resolveControllerWindow(targetWindow, opts.controllerWindow);
|
|
61
61
|
const capabilityNonce = normalizeCapabilityNonce(opts.capabilityNonce);
|
|
62
|
+
const activeWebSocketBridges = new Set();
|
|
63
|
+
let disposed = false;
|
|
62
64
|
const sw = targetWindow.navigator?.serviceWorker;
|
|
63
65
|
const onServiceWorkerMessage = (ev) => {
|
|
64
66
|
const data = ev.data;
|
|
@@ -90,15 +92,32 @@ export function registerProxyAppWindow(opts) {
|
|
|
90
92
|
: { maxWsBufferedAmountBytes: opts.maxWsBufferedAmountBytes }),
|
|
91
93
|
},
|
|
92
94
|
openWebSocketStream: async (path, wsOpts = {}) => {
|
|
95
|
+
if (disposed)
|
|
96
|
+
throw new Error("proxy app Window bridge is disposed");
|
|
93
97
|
const channel = new MessageChannel();
|
|
94
98
|
const port = channel.port1;
|
|
95
99
|
port.start?.();
|
|
96
100
|
return await new Promise((resolve, reject) => {
|
|
97
101
|
let settled = false;
|
|
102
|
+
let terminal = false;
|
|
103
|
+
let stream = null;
|
|
104
|
+
const cleanup = () => {
|
|
105
|
+
activeWebSocketBridges.delete(bridge);
|
|
106
|
+
if (wsOpts.signal != null)
|
|
107
|
+
wsOpts.signal.removeEventListener("abort", onAbort);
|
|
108
|
+
};
|
|
109
|
+
const finishTerminal = () => {
|
|
110
|
+
if (terminal)
|
|
111
|
+
return false;
|
|
112
|
+
terminal = true;
|
|
113
|
+
cleanup();
|
|
114
|
+
return true;
|
|
115
|
+
};
|
|
98
116
|
const finishReject = (error) => {
|
|
99
117
|
if (settled)
|
|
100
118
|
return;
|
|
101
119
|
settled = true;
|
|
120
|
+
finishTerminal();
|
|
102
121
|
try {
|
|
103
122
|
port.close();
|
|
104
123
|
}
|
|
@@ -107,16 +126,76 @@ export function registerProxyAppWindow(opts) {
|
|
|
107
126
|
}
|
|
108
127
|
reject(error instanceof Error ? error : new Error(String(error)));
|
|
109
128
|
};
|
|
129
|
+
const disposeBridge = (error) => {
|
|
130
|
+
if (!finishTerminal())
|
|
131
|
+
return;
|
|
132
|
+
if (stream != null) {
|
|
133
|
+
try {
|
|
134
|
+
void Promise.resolve(stream.reset(error)).catch(() => {
|
|
135
|
+
// The bridge is already terminal.
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
// The bridge is already terminal.
|
|
140
|
+
}
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
try {
|
|
144
|
+
port.postMessage({
|
|
145
|
+
type: PROXY_WINDOW_STREAM_RESET_MSG_TYPE,
|
|
146
|
+
message: error.message,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
// Best-effort.
|
|
151
|
+
}
|
|
152
|
+
if (!settled) {
|
|
153
|
+
settled = true;
|
|
154
|
+
reject(error);
|
|
155
|
+
}
|
|
156
|
+
try {
|
|
157
|
+
port.close();
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
// Best-effort.
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
const bridge = { dispose: disposeBridge };
|
|
164
|
+
const onAbort = () => {
|
|
165
|
+
const reason = wsOpts.signal?.reason;
|
|
166
|
+
disposeBridge(reason instanceof Error ? reason : new Error(String(reason ?? "aborted")));
|
|
167
|
+
};
|
|
168
|
+
activeWebSocketBridges.add(bridge);
|
|
110
169
|
const finishResolve = (ack) => {
|
|
111
|
-
if (settled)
|
|
170
|
+
if (settled || terminal)
|
|
112
171
|
return;
|
|
113
|
-
settled = true;
|
|
114
172
|
const capabilities = Array.isArray(ack.capabilities)
|
|
115
173
|
? ack.capabilities.filter((value) => typeof value === "string")
|
|
116
174
|
: [];
|
|
117
|
-
|
|
175
|
+
if (!capabilities.includes(PROXY_WINDOW_WS_BIDIRECTIONAL_ACK_CAPABILITY)) {
|
|
176
|
+
try {
|
|
177
|
+
port.postMessage({
|
|
178
|
+
type: PROXY_WINDOW_STREAM_RESET_MSG_TYPE,
|
|
179
|
+
message: "proxy Window bridge capability mismatch",
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
// The capability error remains authoritative.
|
|
184
|
+
}
|
|
185
|
+
finishReject(new Error("proxy Window bridge does not support bidirectional stream acknowledgements"));
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
if (disposed) {
|
|
189
|
+
disposeBridge(new Error("proxy app Window bridge is disposed"));
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
settled = true;
|
|
193
|
+
stream = createMessagePortBackedStream(port, {
|
|
194
|
+
maxBufferedBytes: opts.maxWsBufferedAmountBytes ?? 4 * (1 << 20),
|
|
195
|
+
onTerminal: finishTerminal,
|
|
196
|
+
});
|
|
118
197
|
resolve({
|
|
119
|
-
stream
|
|
198
|
+
stream,
|
|
120
199
|
protocol: String(ack.protocol ?? ""),
|
|
121
200
|
});
|
|
122
201
|
};
|
|
@@ -135,15 +214,16 @@ export function registerProxyAppWindow(opts) {
|
|
|
135
214
|
};
|
|
136
215
|
if (wsOpts.signal != null) {
|
|
137
216
|
if (wsOpts.signal.aborted) {
|
|
138
|
-
|
|
217
|
+
onAbort();
|
|
139
218
|
return;
|
|
140
219
|
}
|
|
141
|
-
wsOpts.signal.addEventListener("abort",
|
|
220
|
+
wsOpts.signal.addEventListener("abort", onAbort, { once: true });
|
|
142
221
|
}
|
|
143
222
|
try {
|
|
144
223
|
controllerWindow.postMessage({
|
|
145
224
|
type: PROXY_WINDOW_WS_OPEN_MSG_TYPE,
|
|
146
225
|
path,
|
|
226
|
+
capabilities: [PROXY_WINDOW_WS_BIDIRECTIONAL_ACK_CAPABILITY],
|
|
147
227
|
...(wsOpts.protocols === undefined ? {} : { protocols: wsOpts.protocols }),
|
|
148
228
|
...(capabilityNonce === "" ? {} : { capabilityNonce }),
|
|
149
229
|
}, controllerOrigin, [channel.port2]);
|
|
@@ -157,7 +237,13 @@ export function registerProxyAppWindow(opts) {
|
|
|
157
237
|
return {
|
|
158
238
|
runtime,
|
|
159
239
|
dispose: () => {
|
|
240
|
+
if (disposed)
|
|
241
|
+
return;
|
|
242
|
+
disposed = true;
|
|
160
243
|
sw?.removeEventListener("message", onServiceWorkerMessage);
|
|
244
|
+
const error = new Error("proxy app Window bridge is disposed");
|
|
245
|
+
for (const bridge of [...activeWebSocketBridges])
|
|
246
|
+
bridge.dispose(error);
|
|
161
247
|
},
|
|
162
248
|
};
|
|
163
249
|
}
|