@floegence/flowersec-core 0.23.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 +20 -62
- package/dist/client-connect/connectCore.d.ts +3 -3
- package/dist/client-connect/connectCore.js +7 -5
- package/dist/client-connect/transportSecurity.js +1 -1
- package/dist/controlplane/issuer.d.ts +5 -2
- package/dist/controlplane/issuer.js +76 -16
- package/dist/defaults.d.ts +2 -0
- package/dist/defaults.js +2 -0
- package/dist/e2ee/handshake.d.ts +2 -2
- package/dist/e2ee/secureChannel.d.ts +2 -2
- package/dist/e2ee/secureChannel.js +22 -10
- package/dist/endpoint/index.js +203 -38
- 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/dist/reconnect/index.js +62 -25
- package/dist/yamux/byteReader.d.ts +2 -0
- package/dist/yamux/byteReader.js +28 -24
- package/dist/yamux/session.js +1 -1
- package/package.json +4 -1
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";
|
|
@@ -8,10 +9,11 @@ import { decodeHandshakeFrame } from "../e2ee/framing.js";
|
|
|
8
9
|
import { HANDSHAKE_TYPE_INIT, PROTOCOL_VERSION } from "../e2ee/constants.js";
|
|
9
10
|
import { readStreamHello, writeStreamHello } from "../streamhello/streamHello.js";
|
|
10
11
|
import { ByteReader } from "../yamux/byteReader.js";
|
|
12
|
+
import { isYamuxPingTimeoutError } from "../yamux/errors.js";
|
|
11
13
|
import { YamuxSession } from "../yamux/session.js";
|
|
12
14
|
import { RpcServer } from "../rpc/server.js";
|
|
13
15
|
import { base64urlDecode, base64urlEncode } from "../utils/base64url.js";
|
|
14
|
-
import { AbortError, FlowersecError } from "../utils/errors.js";
|
|
16
|
+
import { AbortError, FlowersecError, TimeoutError, throwIfAborted } from "../utils/errors.js";
|
|
15
17
|
import { WebSocketBinaryTransport } from "../ws-client/binaryTransport.js";
|
|
16
18
|
export class Session {
|
|
17
19
|
secure;
|
|
@@ -43,9 +45,10 @@ export class Session {
|
|
|
43
45
|
return session;
|
|
44
46
|
}
|
|
45
47
|
async openStream(kind, options = {}) {
|
|
48
|
+
const streamKind = normalizeStreamKind(kind, this.path);
|
|
46
49
|
const stream = await this.mux.openStream(options);
|
|
47
50
|
try {
|
|
48
|
-
await writeStreamHello((bytes) => stream.write(bytes),
|
|
51
|
+
await writeStreamHello((bytes) => stream.write(bytes), streamKind);
|
|
49
52
|
return stream;
|
|
50
53
|
}
|
|
51
54
|
catch (error) {
|
|
@@ -82,8 +85,19 @@ export class Session {
|
|
|
82
85
|
return;
|
|
83
86
|
}
|
|
84
87
|
}
|
|
85
|
-
probeLiveness(timeoutMs = SDK_DEFAULTS.transport.handshakeTimeoutMs) {
|
|
86
|
-
|
|
88
|
+
async probeLiveness(timeoutMs = SDK_DEFAULTS.transport.handshakeTimeoutMs) {
|
|
89
|
+
try {
|
|
90
|
+
return await this.mux.probeLiveness(timeoutMs);
|
|
91
|
+
}
|
|
92
|
+
catch (error) {
|
|
93
|
+
throw new FlowersecError({
|
|
94
|
+
path: this.path,
|
|
95
|
+
stage: "yamux",
|
|
96
|
+
code: isYamuxPingTimeoutError(error) ? "timeout" : "ping_failed",
|
|
97
|
+
message: "endpoint liveness probe failed",
|
|
98
|
+
cause: error,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
87
101
|
}
|
|
88
102
|
async rekey() {
|
|
89
103
|
try {
|
|
@@ -139,15 +153,46 @@ export class Session {
|
|
|
139
153
|
}
|
|
140
154
|
}
|
|
141
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
|
+
}
|
|
142
165
|
await enforceIncomingDirectTransport(options);
|
|
166
|
+
const deadline = createHandshakeDeadline(handshakeTimeoutMs);
|
|
143
167
|
const transport = new WebSocketBinaryTransport(websocket, webSocketTransportOptions(options));
|
|
144
|
-
return await establishSession("direct", transport, handshake, options);
|
|
168
|
+
return await establishSession("direct", transport, handshake, options, undefined, deadline);
|
|
145
169
|
}
|
|
146
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
|
+
}
|
|
147
180
|
await enforceIncomingDirectTransport(options);
|
|
181
|
+
const deadline = createHandshakeDeadline(handshakeTimeoutMs);
|
|
148
182
|
const transport = new WebSocketBinaryTransport(websocket, webSocketTransportOptions(options));
|
|
149
|
-
|
|
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
|
+
}
|
|
150
194
|
let init;
|
|
195
|
+
let channelId;
|
|
151
196
|
try {
|
|
152
197
|
const decoded = decodeHandshakeFrame(first, options.maxHandshakePayload ?? SDK_DEFAULTS.e2ee.maxHandshakePayloadBytes);
|
|
153
198
|
if (decoded.handshakeType !== HANDSHAKE_TYPE_INIT)
|
|
@@ -155,30 +200,44 @@ export async function acceptDirectResolved(websocket, resolver, options = {}) {
|
|
|
155
200
|
init = JSON.parse(new TextDecoder().decode(decoded.payloadJsonUtf8));
|
|
156
201
|
if (init.version !== PROTOCOL_VERSION || init.role !== 1 || (init.suite !== 1 && init.suite !== 2))
|
|
157
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
|
+
}
|
|
158
212
|
}
|
|
159
213
|
catch (error) {
|
|
160
214
|
transport.close();
|
|
215
|
+
if (error instanceof FlowersecError)
|
|
216
|
+
throw error;
|
|
161
217
|
throw new FlowersecError({ path: "direct", stage: "handshake", code: "handshake_failed", message: "invalid handshake init", cause: error });
|
|
162
218
|
}
|
|
163
219
|
let credential;
|
|
164
220
|
try {
|
|
165
|
-
credential = await resolver({
|
|
166
|
-
channelId
|
|
221
|
+
credential = await runHandshakeStep(deadline, options.signal, () => resolver({
|
|
222
|
+
channelId,
|
|
167
223
|
version: init.version,
|
|
168
224
|
suite: init.suite,
|
|
169
225
|
clientFeatures: init.client_features >>> 0,
|
|
170
|
-
});
|
|
226
|
+
}));
|
|
171
227
|
}
|
|
172
228
|
catch (error) {
|
|
173
229
|
transport.close();
|
|
230
|
+
const interrupted = endpointHandshakeInterruption("direct", error);
|
|
231
|
+
if (interrupted != null)
|
|
232
|
+
throw interrupted;
|
|
174
233
|
throw new FlowersecError({ path: "direct", stage: "validate", code: "resolve_failed", message: "credential resolution failed", cause: error });
|
|
175
234
|
}
|
|
176
235
|
const replay = new PrefetchedTransport(transport, first);
|
|
177
236
|
return await establishSession("direct", replay, {
|
|
178
|
-
channelId
|
|
237
|
+
channelId,
|
|
179
238
|
suite: init.suite,
|
|
180
239
|
...credential,
|
|
181
|
-
}, options);
|
|
240
|
+
}, options, undefined, deadline);
|
|
182
241
|
}
|
|
183
242
|
export async function connectTunnel(grantInput, options) {
|
|
184
243
|
let grant;
|
|
@@ -189,6 +248,7 @@ export async function connectTunnel(grantInput, options) {
|
|
|
189
248
|
throw new FlowersecError({ path: "tunnel", stage: "validate", code: "invalid_input", message: "invalid ChannelInitGrant", cause: error });
|
|
190
249
|
}
|
|
191
250
|
assertTunnelGrantContract(grant, ControlRole.Role_server);
|
|
251
|
+
const channelId = prepareChannelId(grant.channel_id, "tunnel");
|
|
192
252
|
const tunnelUrl = grant.tunnel_url.trim();
|
|
193
253
|
if (tunnelUrl === "")
|
|
194
254
|
throw new FlowersecError({ path: "tunnel", stage: "validate", code: "missing_tunnel_url", message: "missing tunnel_url" });
|
|
@@ -199,35 +259,63 @@ export async function connectTunnel(grantInput, options) {
|
|
|
199
259
|
const origin = options.origin.trim();
|
|
200
260
|
if (origin === "")
|
|
201
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");
|
|
202
281
|
await enforceTransportSecurity({ rawUrl: tunnelUrl, path: "tunnel", ...(options.transportSecurityPolicy === undefined ? {} : { policy: options.transportSecurityPolicy }) });
|
|
203
282
|
const endpointInstanceId = normalizeEndpointInstanceId(options.endpointInstanceId);
|
|
204
|
-
|
|
205
|
-
const transport = new WebSocketBinaryTransport(websocket, webSocketTransportOptions(options));
|
|
283
|
+
let transport;
|
|
206
284
|
try {
|
|
207
|
-
|
|
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");
|
|
208
290
|
const attach = {
|
|
209
291
|
v: 1,
|
|
210
|
-
channel_id:
|
|
292
|
+
channel_id: channelId,
|
|
211
293
|
role: TunnelRole.Role_server,
|
|
212
294
|
token: grant.token.trim(),
|
|
213
295
|
endpoint_instance_id: endpointInstanceId,
|
|
214
296
|
};
|
|
215
297
|
websocket.send(JSON.stringify(attach));
|
|
216
298
|
return await establishSession("tunnel", transport, {
|
|
217
|
-
channelId
|
|
299
|
+
channelId,
|
|
218
300
|
suite: grant.default_suite,
|
|
219
|
-
psk
|
|
301
|
+
psk,
|
|
220
302
|
initExpireAtUnixS: grant.channel_init_expire_at_unix_s,
|
|
221
303
|
}, options, endpointInstanceId);
|
|
222
304
|
}
|
|
223
305
|
catch (error) {
|
|
224
|
-
transport
|
|
306
|
+
transport?.close();
|
|
225
307
|
if (error instanceof FlowersecError)
|
|
226
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
|
+
}
|
|
227
315
|
throw new FlowersecError({ path: "tunnel", stage: "connect", code: "dial_failed", message: "endpoint tunnel connect failed", cause: error });
|
|
228
316
|
}
|
|
229
317
|
}
|
|
230
|
-
async function establishSession(path, transport, handshake, options, endpointInstanceId) {
|
|
318
|
+
async function establishSession(path, transport, handshake, options, endpointInstanceId, deadline) {
|
|
231
319
|
let psk;
|
|
232
320
|
try {
|
|
233
321
|
psk = normalizePSK(handshake.psk, path);
|
|
@@ -237,7 +325,7 @@ async function establishSession(path, transport, handshake, options, endpointIns
|
|
|
237
325
|
throw error;
|
|
238
326
|
}
|
|
239
327
|
try {
|
|
240
|
-
const
|
|
328
|
+
const startHandshake = () => serverHandshake(transport, options.handshakeCache ?? new ServerHandshakeCache(), {
|
|
241
329
|
channelId: prepareChannelId(handshake.channelId, path),
|
|
242
330
|
suite: handshake.suite,
|
|
243
331
|
psk,
|
|
@@ -247,27 +335,54 @@ async function establishSession(path, transport, handshake, options, endpointIns
|
|
|
247
335
|
maxHandshakePayload: options.maxHandshakePayload ?? SDK_DEFAULTS.e2ee.maxHandshakePayloadBytes,
|
|
248
336
|
maxRecordBytes: options.maxRecordBytes ?? SDK_DEFAULTS.e2ee.maxRecordBytes,
|
|
249
337
|
outboundRecordChunkBytes: options.outboundRecordChunkBytes ?? SDK_DEFAULTS.e2ee.outboundRecordChunkBytes,
|
|
250
|
-
maxBufferedBytes: options.maxBufferedBytes ?? SDK_DEFAULTS.e2ee.
|
|
338
|
+
maxBufferedBytes: options.maxBufferedBytes ?? SDK_DEFAULTS.e2ee.maxInboundBufferedBytes,
|
|
251
339
|
maxOutboundBufferedBytes: options.maxOutboundBufferedBytes ?? SDK_DEFAULTS.e2ee.maxOutboundBufferedBytes,
|
|
252
|
-
timeoutMs:
|
|
340
|
+
timeoutMs: deadline == null
|
|
341
|
+
? options.handshakeTimeoutMs ?? SDK_DEFAULTS.transport.handshakeTimeoutMs
|
|
342
|
+
: remainingHandshakeTimeoutMs(deadline),
|
|
253
343
|
...(options.signal === undefined ? {} : { signal: options.signal }),
|
|
254
344
|
});
|
|
345
|
+
const secure = deadline == null
|
|
346
|
+
? await startHandshake()
|
|
347
|
+
: await runHandshakeStep(deadline, options.signal, startHandshake);
|
|
255
348
|
try {
|
|
256
|
-
|
|
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
|
+
}
|
|
257
357
|
}
|
|
258
358
|
catch (error) {
|
|
259
359
|
secure.close();
|
|
360
|
+
const interrupted = endpointHandshakeInterruption(path, error);
|
|
361
|
+
if (interrupted != null)
|
|
362
|
+
throw interrupted;
|
|
260
363
|
throw new FlowersecError({ path, stage: "handshake", code: "credential_commit_failed", message: "credential commit failed", cause: error });
|
|
261
364
|
}
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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
|
+
}
|
|
266
378
|
}
|
|
267
379
|
catch (error) {
|
|
268
380
|
transport.close();
|
|
269
381
|
if (error instanceof FlowersecError)
|
|
270
382
|
throw error;
|
|
383
|
+
const interrupted = endpointHandshakeInterruption(path, error);
|
|
384
|
+
if (interrupted != null)
|
|
385
|
+
throw interrupted;
|
|
271
386
|
throw new FlowersecError({ path, stage: "handshake", code: "handshake_failed", message: "endpoint handshake failed", cause: error });
|
|
272
387
|
}
|
|
273
388
|
finally {
|
|
@@ -327,7 +442,7 @@ function randomEndpointInstanceId() {
|
|
|
327
442
|
function normalizeStreamKind(kind, path) {
|
|
328
443
|
const value = kind.trim();
|
|
329
444
|
if (value === "")
|
|
330
|
-
throw new FlowersecError({ path, stage: "
|
|
445
|
+
throw new FlowersecError({ path, stage: "rpc", code: "missing_stream_kind", message: "missing stream kind" });
|
|
331
446
|
return value;
|
|
332
447
|
}
|
|
333
448
|
function unwrapServerGrant(input) {
|
|
@@ -339,22 +454,25 @@ function unwrapServerGrant(input) {
|
|
|
339
454
|
function webSocketTransportOptions(options) {
|
|
340
455
|
return options.webSocketLimits === undefined ? {} : { webSocketLimits: options.webSocketLimits };
|
|
341
456
|
}
|
|
342
|
-
function readOptions(options) {
|
|
343
|
-
return {
|
|
344
|
-
timeoutMs: options.handshakeTimeoutMs ?? SDK_DEFAULTS.transport.handshakeTimeoutMs,
|
|
345
|
-
...(options.signal === undefined ? {} : { signal: options.signal }),
|
|
346
|
-
};
|
|
347
|
-
}
|
|
348
457
|
async function enforceIncomingDirectTransport(options) {
|
|
349
458
|
const rawUrl = options.secureTransport === false ? "ws://127.0.0.1/" : "wss://127.0.0.1/";
|
|
350
459
|
await enforceTransportSecurity({ rawUrl, path: "direct", ...(options.transportSecurityPolicy === undefined ? {} : { policy: options.transportSecurityPolicy }) });
|
|
351
460
|
}
|
|
352
461
|
function waitForOpen(websocket, timeoutMs, signal) {
|
|
353
|
-
if (websocket.readyState === 1)
|
|
354
|
-
return Promise.resolve();
|
|
355
462
|
if (!Number.isFinite(timeoutMs) || timeoutMs < 0)
|
|
356
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"));
|
|
357
474
|
return new Promise((resolve, reject) => {
|
|
475
|
+
let settled = false;
|
|
358
476
|
let timer;
|
|
359
477
|
const cleanup = () => {
|
|
360
478
|
if (timer != null)
|
|
@@ -365,6 +483,9 @@ function waitForOpen(websocket, timeoutMs, signal) {
|
|
|
365
483
|
signal?.removeEventListener("abort", onAbort);
|
|
366
484
|
};
|
|
367
485
|
const finish = (error) => {
|
|
486
|
+
if (settled)
|
|
487
|
+
return;
|
|
488
|
+
settled = true;
|
|
368
489
|
cleanup();
|
|
369
490
|
if (error == null)
|
|
370
491
|
resolve();
|
|
@@ -379,14 +500,58 @@ function waitForOpen(websocket, timeoutMs, signal) {
|
|
|
379
500
|
websocket.addEventListener("error", onError);
|
|
380
501
|
websocket.addEventListener("close", onClose);
|
|
381
502
|
signal?.addEventListener("abort", onAbort, { once: true });
|
|
382
|
-
if (
|
|
383
|
-
|
|
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);
|
|
384
514
|
});
|
|
385
515
|
}
|
|
386
516
|
function cleanupWaiter(waiter) {
|
|
387
517
|
if (waiter.onAbort != null)
|
|
388
518
|
waiter.signal?.removeEventListener("abort", waiter.onAbort);
|
|
389
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
|
+
}
|
|
390
555
|
function asError(error) {
|
|
391
556
|
return error instanceof Error ? error : new Error(String(error));
|
|
392
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
|
}
|