@floegence/flowersec-core 0.19.11 → 0.20.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -7
- package/dist/browser/reconnectConfig.d.ts +8 -36
- package/dist/browser/reconnectConfig.js +13 -70
- package/dist/client-connect/connectCore.d.ts +14 -5
- package/dist/client-connect/connectCore.js +130 -38
- package/dist/client-connect/transportSecurity.js +13 -15
- package/dist/client.d.ts +2 -0
- package/dist/e2ee/handshake.d.ts +4 -0
- package/dist/e2ee/handshake.js +2 -0
- package/dist/e2ee/secureChannel.d.ts +4 -0
- package/dist/e2ee/secureChannel.js +18 -9
- package/dist/facade.d.ts +3 -0
- package/dist/node/reconnectConfig.d.ts +8 -34
- package/dist/node/reconnectConfig.js +13 -71
- package/dist/node/wsFactory.js +3 -0
- package/dist/observability/observer.d.ts +5 -2
- package/dist/observability/observer.js +3 -0
- package/dist/proxy/integration.d.ts +2 -0
- package/dist/proxy/integration.js +6 -0
- package/dist/proxy/runtime.d.ts +4 -0
- package/dist/proxy/runtime.js +145 -3
- package/dist/proxy/runtimeScope.d.ts +4 -0
- package/dist/reconnect/artifactControlplane.d.ts +9 -6
- package/dist/reconnect/artifactControlplane.js +28 -27
- package/dist/reconnect/index.d.ts +2 -0
- package/dist/reconnect/index.js +7 -0
- package/dist/rpc/server.d.ts +29 -4
- package/dist/rpc/server.js +142 -34
- package/dist/tunnel-client/connect.js +6 -6
- package/dist/utils/errors.d.ts +1 -1
- package/dist/ws-client/binaryTransport.d.ts +20 -5
- package/dist/ws-client/binaryTransport.js +110 -8
- package/dist/yamux/byteReader.d.ts +1 -0
- package/dist/yamux/byteReader.js +26 -0
- package/dist/yamux/errors.d.ts +7 -0
- package/dist/yamux/errors.js +15 -0
- package/dist/yamux/session.d.ts +31 -1
- package/dist/yamux/session.js +191 -12
- package/dist/yamux/stream.d.ts +10 -3
- package/dist/yamux/stream.js +61 -19
- package/package.json +1 -1
package/dist/rpc/server.d.ts
CHANGED
|
@@ -3,13 +3,38 @@ export type RpcHandler = (payload: unknown) => Promise<{
|
|
|
3
3
|
payload: unknown;
|
|
4
4
|
error?: RpcError;
|
|
5
5
|
}>;
|
|
6
|
+
export type RpcServerOptions = Readonly<{
|
|
7
|
+
maxConcurrentRequests?: number;
|
|
8
|
+
maxQueuedRequests?: number;
|
|
9
|
+
maxQueuedNotifications?: number;
|
|
10
|
+
}>;
|
|
11
|
+
export type RpcServerTransport = Readonly<{
|
|
12
|
+
readExactly(n: number): Promise<Uint8Array>;
|
|
13
|
+
write(bytes: Uint8Array): Promise<void>;
|
|
14
|
+
close(error: unknown): void;
|
|
15
|
+
}>;
|
|
6
16
|
export declare class RpcServer {
|
|
7
|
-
private readonly
|
|
8
|
-
private readonly write;
|
|
17
|
+
private readonly transport;
|
|
9
18
|
private readonly handlers;
|
|
10
19
|
private closed;
|
|
11
|
-
|
|
20
|
+
private readonly options;
|
|
21
|
+
private readonly requests;
|
|
22
|
+
private readonly notifications;
|
|
23
|
+
private requestWaiters;
|
|
24
|
+
private notificationWaiters;
|
|
25
|
+
private writeChain;
|
|
26
|
+
private terminalError;
|
|
27
|
+
private readonly terminalSignal;
|
|
28
|
+
private signalTerminal;
|
|
29
|
+
private transportClosed;
|
|
30
|
+
constructor(transport: RpcServerTransport, options?: RpcServerOptions);
|
|
12
31
|
register(typeId: number, h: RpcHandler): void;
|
|
13
32
|
serve(signal?: AbortSignal): Promise<void>;
|
|
14
|
-
close(): void;
|
|
33
|
+
close(error?: unknown): void;
|
|
34
|
+
private fail;
|
|
35
|
+
private requestWorker;
|
|
36
|
+
private notificationWorker;
|
|
37
|
+
private nextWork;
|
|
38
|
+
private wakeOne;
|
|
39
|
+
private writeResponse;
|
|
15
40
|
}
|
package/dist/rpc/server.js
CHANGED
|
@@ -1,16 +1,35 @@
|
|
|
1
1
|
import { DEFAULT_MAX_JSON_FRAME_BYTES, readJsonFrame, writeJsonFrame } from "../framing/jsonframe.js";
|
|
2
2
|
import { assertRpcEnvelope } from "./validate.js";
|
|
3
|
+
const DEFAULT_RPC_SERVER_OPTIONS = Object.freeze({
|
|
4
|
+
maxConcurrentRequests: 32,
|
|
5
|
+
maxQueuedRequests: 128,
|
|
6
|
+
maxQueuedNotifications: 128,
|
|
7
|
+
});
|
|
3
8
|
// RpcServer dispatches request envelopes to registered handlers.
|
|
4
9
|
export class RpcServer {
|
|
5
|
-
|
|
6
|
-
write;
|
|
10
|
+
transport;
|
|
7
11
|
// Registered handlers keyed by type ID.
|
|
8
12
|
handlers = new Map();
|
|
9
13
|
// Closed flag to stop the serve loop.
|
|
10
14
|
closed = false;
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
15
|
+
options;
|
|
16
|
+
requests = [];
|
|
17
|
+
notifications = [];
|
|
18
|
+
requestWaiters = [];
|
|
19
|
+
notificationWaiters = [];
|
|
20
|
+
writeChain = Promise.resolve();
|
|
21
|
+
terminalError;
|
|
22
|
+
terminalSignal;
|
|
23
|
+
signalTerminal;
|
|
24
|
+
transportClosed = false;
|
|
25
|
+
constructor(transport, options = {}) {
|
|
26
|
+
this.transport = transport;
|
|
27
|
+
this.terminalSignal = new Promise((resolve) => { this.signalTerminal = resolve; });
|
|
28
|
+
this.options = {
|
|
29
|
+
maxConcurrentRequests: positiveInteger(options.maxConcurrentRequests ?? DEFAULT_RPC_SERVER_OPTIONS.maxConcurrentRequests, "maxConcurrentRequests"),
|
|
30
|
+
maxQueuedRequests: nonNegativeInteger(options.maxQueuedRequests ?? DEFAULT_RPC_SERVER_OPTIONS.maxQueuedRequests, "maxQueuedRequests"),
|
|
31
|
+
maxQueuedNotifications: nonNegativeInteger(options.maxQueuedNotifications ?? DEFAULT_RPC_SERVER_OPTIONS.maxQueuedNotifications, "maxQueuedNotifications"),
|
|
32
|
+
};
|
|
14
33
|
}
|
|
15
34
|
// register binds a handler to a type ID.
|
|
16
35
|
register(typeId, h) {
|
|
@@ -18,50 +37,139 @@ export class RpcServer {
|
|
|
18
37
|
}
|
|
19
38
|
// serve handles request/response frames until closed or aborted.
|
|
20
39
|
async serve(signal) {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
|
|
40
|
+
const supervise = (worker) => worker.catch((err) => {
|
|
41
|
+
this.fail(err);
|
|
42
|
+
});
|
|
43
|
+
const workers = Array.from({ length: this.options.maxConcurrentRequests }, () => supervise(this.requestWorker()));
|
|
44
|
+
workers.push(supervise(this.notificationWorker()));
|
|
45
|
+
try {
|
|
46
|
+
while (!this.closed) {
|
|
47
|
+
if (signal?.aborted)
|
|
48
|
+
throw signal.reason ?? new Error("aborted");
|
|
49
|
+
const next = await Promise.race([
|
|
50
|
+
readJsonFrame(this.transport.readExactly, DEFAULT_MAX_JSON_FRAME_BYTES),
|
|
51
|
+
this.terminalSignal.then((error) => { throw error; }),
|
|
52
|
+
]);
|
|
53
|
+
const v = assertRpcEnvelope(next);
|
|
54
|
+
if (v.response_to !== 0)
|
|
55
|
+
continue;
|
|
56
|
+
if (v.request_id === 0) {
|
|
57
|
+
if (this.notifications.length >= this.options.maxQueuedNotifications) {
|
|
58
|
+
throw new Error("rpc notification queue exhausted");
|
|
35
59
|
}
|
|
60
|
+
this.notifications.push({ envelope: v });
|
|
61
|
+
this.wakeOne(this.notificationWaiters);
|
|
62
|
+
continue;
|
|
36
63
|
}
|
|
37
|
-
|
|
64
|
+
if (this.requests.length >= this.options.maxQueuedRequests) {
|
|
65
|
+
await this.writeResponse(v, { payload: null, error: { code: 429, message: "server overloaded" } });
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
this.requests.push({ envelope: v });
|
|
69
|
+
this.wakeOne(this.requestWaiters);
|
|
38
70
|
}
|
|
71
|
+
}
|
|
72
|
+
catch (err) {
|
|
73
|
+
this.terminalError = err;
|
|
74
|
+
this.close(err);
|
|
75
|
+
throw err;
|
|
76
|
+
}
|
|
77
|
+
finally {
|
|
78
|
+
this.close(this.terminalError ?? new Error("rpc server closed"));
|
|
79
|
+
void Promise.allSettled(workers);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
// close stops the serve loop and closes the underlying RPC stream.
|
|
83
|
+
close(error = new Error("rpc server closed")) {
|
|
84
|
+
if (!this.closed) {
|
|
85
|
+
this.closed = true;
|
|
86
|
+
this.signalTerminal(error);
|
|
87
|
+
for (const wake of this.requestWaiters.splice(0))
|
|
88
|
+
wake();
|
|
89
|
+
for (const wake of this.notificationWaiters.splice(0))
|
|
90
|
+
wake();
|
|
91
|
+
}
|
|
92
|
+
if (!this.transportClosed) {
|
|
93
|
+
this.transportClosed = true;
|
|
94
|
+
this.transport.close(error);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
fail(error) {
|
|
98
|
+
if (this.terminalError !== undefined)
|
|
99
|
+
return;
|
|
100
|
+
this.terminalError = error;
|
|
101
|
+
this.close(error);
|
|
102
|
+
}
|
|
103
|
+
async requestWorker() {
|
|
104
|
+
while (!this.closed) {
|
|
105
|
+
const work = await this.nextWork(this.requests, this.requestWaiters);
|
|
106
|
+
if (work == null)
|
|
107
|
+
return;
|
|
108
|
+
const v = work.envelope;
|
|
39
109
|
const h = this.handlers.get(v.type_id >>> 0);
|
|
40
110
|
let out;
|
|
41
|
-
if (h == null)
|
|
111
|
+
if (h == null)
|
|
42
112
|
out = { payload: null, error: { code: 404, message: "handler not found" } };
|
|
43
|
-
}
|
|
44
113
|
else {
|
|
45
114
|
try {
|
|
46
115
|
out = await h(v.payload);
|
|
47
116
|
}
|
|
48
117
|
catch {
|
|
49
|
-
// Keep the serve loop alive on request handler errors.
|
|
50
118
|
out = { payload: null, error: { code: 500, message: "internal error" } };
|
|
51
119
|
}
|
|
52
120
|
}
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
response_to: v.request_id,
|
|
57
|
-
payload: out.payload,
|
|
58
|
-
...(out.error != null ? { error: out.error } : {})
|
|
59
|
-
};
|
|
60
|
-
await writeJsonFrame(this.write, resp);
|
|
121
|
+
if (this.closed)
|
|
122
|
+
return;
|
|
123
|
+
await this.writeResponse(v, out);
|
|
61
124
|
}
|
|
62
125
|
}
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
126
|
+
async notificationWorker() {
|
|
127
|
+
while (!this.closed) {
|
|
128
|
+
const work = await this.nextWork(this.notifications, this.notificationWaiters);
|
|
129
|
+
if (work == null)
|
|
130
|
+
return;
|
|
131
|
+
const v = work.envelope;
|
|
132
|
+
const h = this.handlers.get(v.type_id >>> 0);
|
|
133
|
+
if (h == null)
|
|
134
|
+
continue;
|
|
135
|
+
try {
|
|
136
|
+
await h(v.payload);
|
|
137
|
+
}
|
|
138
|
+
catch { /* Notification failures are isolated. */ }
|
|
139
|
+
}
|
|
66
140
|
}
|
|
141
|
+
async nextWork(queue, waiters) {
|
|
142
|
+
while (!this.closed) {
|
|
143
|
+
const work = queue.shift();
|
|
144
|
+
if (work != null)
|
|
145
|
+
return work;
|
|
146
|
+
await new Promise((resolve) => waiters.push(resolve));
|
|
147
|
+
}
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
wakeOne(waiters) {
|
|
151
|
+
waiters.shift()?.();
|
|
152
|
+
}
|
|
153
|
+
async writeResponse(request, out) {
|
|
154
|
+
const resp = {
|
|
155
|
+
type_id: request.type_id,
|
|
156
|
+
request_id: 0,
|
|
157
|
+
response_to: request.request_id,
|
|
158
|
+
payload: out.payload,
|
|
159
|
+
...(out.error != null ? { error: out.error } : {}),
|
|
160
|
+
};
|
|
161
|
+
const write = this.writeChain.then(() => writeJsonFrame(this.transport.write, resp));
|
|
162
|
+
this.writeChain = write.catch(() => { });
|
|
163
|
+
await write;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
function positiveInteger(value, name) {
|
|
167
|
+
if (!Number.isSafeInteger(value) || value <= 0)
|
|
168
|
+
throw new RangeError(`${name} must be a positive integer`);
|
|
169
|
+
return value;
|
|
170
|
+
}
|
|
171
|
+
function nonNegativeInteger(value, name) {
|
|
172
|
+
if (!Number.isSafeInteger(value) || value < 0)
|
|
173
|
+
throw new RangeError(`${name} must be a non-negative integer`);
|
|
174
|
+
return value;
|
|
67
175
|
}
|
|
@@ -131,27 +131,27 @@ export async function connectTunnel(grant, opts) {
|
|
|
131
131
|
endpoint_instance_id: endpointInstanceId
|
|
132
132
|
};
|
|
133
133
|
const attachJson = JSON.stringify(attach);
|
|
134
|
-
const
|
|
134
|
+
const liveness = opts.liveness ?? defaultLiveness(idleTimeoutSeconds);
|
|
135
135
|
return await connectCore({
|
|
136
136
|
path: "tunnel",
|
|
137
137
|
wsUrl: tunnelUrl,
|
|
138
138
|
channelId,
|
|
139
139
|
e2eePskB64u,
|
|
140
140
|
defaultSuite: checkedGrant.default_suite,
|
|
141
|
-
opts: { ...opts,
|
|
141
|
+
opts: { ...opts, liveness },
|
|
142
142
|
attach: { attachJson, endpointInstanceId }
|
|
143
143
|
});
|
|
144
144
|
}
|
|
145
|
-
function
|
|
145
|
+
function defaultLiveness(idleTimeoutSeconds) {
|
|
146
146
|
if (!Number.isFinite(idleTimeoutSeconds) || idleTimeoutSeconds <= 0)
|
|
147
|
-
return
|
|
147
|
+
return false;
|
|
148
148
|
const idleMs = Math.floor(idleTimeoutSeconds * 1000);
|
|
149
149
|
if (idleMs <= 0)
|
|
150
|
-
return
|
|
150
|
+
return false;
|
|
151
151
|
let interval = Math.floor(idleMs / 2);
|
|
152
152
|
if (interval < 500)
|
|
153
153
|
interval = 500;
|
|
154
154
|
if (interval >= idleMs)
|
|
155
155
|
interval = Math.floor(idleMs / 2);
|
|
156
|
-
return interval;
|
|
156
|
+
return { intervalMs: interval, timeoutMs: Math.min(10_000, interval) };
|
|
157
157
|
}
|
package/dist/utils/errors.d.ts
CHANGED
|
@@ -6,7 +6,7 @@ export declare class AbortError extends Error {
|
|
|
6
6
|
}
|
|
7
7
|
export type FlowersecPath = "auto" | "tunnel" | "direct";
|
|
8
8
|
export type FlowersecStage = "validate" | "connect" | "attach" | "handshake" | "secure" | "yamux" | "rpc" | "close";
|
|
9
|
-
export type FlowersecErrorCode = "timeout" | "canceled" | "invalid_version" | "invalid_input" | "invalid_option" | "invalid_endpoint_instance_id" | "invalid_psk" | "invalid_suite" | "missing_grant" | "missing_connect_info" | "missing_conn" | "missing_handler" | "missing_stream_kind" | "role_mismatch" | "missing_tunnel_url" | "missing_ws_url" | "missing_origin" | "missing_channel_id" | "missing_token" | "missing_init_exp" | "timestamp_after_init_exp" | "timestamp_out_of_skew" | "auth_tag_mismatch" | "resolve_failed" | "transport_policy_denied" | "credential_commit_failed" | "random_failed" | "upgrade_failed" | "dial_failed" | "attach_failed" | "too_many_connections" | "expected_attach" | "invalid_attach" | "invalid_token" | "channel_mismatch" | "init_exp_mismatch" | "idle_timeout_mismatch" | "token_replay" | "tenant_mismatch" | "policy_denied" | "policy_error" | "replace_rate_limited" | "handshake_failed" | "ping_failed" | "mux_failed" | "accept_stream_failed" | "open_stream_failed" | "stream_hello_failed" | "rpc_failed" | "not_connected";
|
|
9
|
+
export type FlowersecErrorCode = "timeout" | "canceled" | "invalid_version" | "invalid_input" | "invalid_option" | "invalid_endpoint_instance_id" | "invalid_psk" | "invalid_suite" | "missing_grant" | "missing_connect_info" | "missing_conn" | "missing_handler" | "missing_stream_kind" | "role_mismatch" | "missing_tunnel_url" | "missing_ws_url" | "missing_origin" | "missing_channel_id" | "missing_token" | "missing_init_exp" | "timestamp_after_init_exp" | "timestamp_out_of_skew" | "auth_tag_mismatch" | "resolve_failed" | "transport_policy_denied" | "credential_commit_failed" | "random_failed" | "upgrade_failed" | "dial_failed" | "attach_failed" | "too_many_connections" | "expected_attach" | "invalid_attach" | "invalid_token" | "channel_mismatch" | "init_exp_mismatch" | "idle_timeout_mismatch" | "token_replay" | "tenant_mismatch" | "policy_denied" | "policy_error" | "replace_rate_limited" | "handshake_failed" | "ping_failed" | "mux_failed" | "accept_stream_failed" | "open_stream_failed" | "stream_hello_failed" | "rpc_failed" | "resource_exhausted" | "not_connected";
|
|
10
10
|
export declare class FlowersecError extends Error {
|
|
11
11
|
readonly code: FlowersecErrorCode;
|
|
12
12
|
readonly stage: FlowersecStage;
|
|
@@ -7,28 +7,41 @@ export declare class WsCloseError extends Error {
|
|
|
7
7
|
export type WebSocketLike = {
|
|
8
8
|
binaryType: string;
|
|
9
9
|
readyState: number;
|
|
10
|
+
/** Bytes accepted by send() but not yet transmitted by the implementation. */
|
|
11
|
+
readonly bufferedAmount: number;
|
|
10
12
|
send(data: string | ArrayBuffer | Uint8Array): void;
|
|
11
13
|
close(code?: number, reason?: string): void;
|
|
12
14
|
addEventListener(type: "open" | "message" | "error" | "close", listener: (ev: any) => void): void;
|
|
13
15
|
removeEventListener(type: "open" | "message" | "error" | "close", listener: (ev: any) => void): void;
|
|
14
16
|
};
|
|
17
|
+
export type WebSocketLimits = Readonly<{
|
|
18
|
+
maxInboundQueuedBytes: number;
|
|
19
|
+
outboundLowWatermarkBytes: number;
|
|
20
|
+
outboundHighWatermarkBytes: number;
|
|
21
|
+
outboundHardLimitBytes: number;
|
|
22
|
+
outboundDrainTimeoutMs: number;
|
|
23
|
+
}>;
|
|
24
|
+
export declare const DEFAULT_WEB_SOCKET_LIMITS: WebSocketLimits;
|
|
25
|
+
export type WebSocketBinaryTransportOptions = Readonly<{
|
|
26
|
+
webSocketLimits?: Partial<WebSocketLimits>;
|
|
27
|
+
observer?: ClientObserverLike;
|
|
28
|
+
}>;
|
|
15
29
|
export declare class WebSocketBinaryTransport {
|
|
16
30
|
private readonly ws;
|
|
17
31
|
private readonly observer;
|
|
18
32
|
private readonly queue;
|
|
19
33
|
private queueHead;
|
|
20
34
|
private queueBytes;
|
|
21
|
-
private readonly
|
|
35
|
+
private readonly limits;
|
|
22
36
|
private waiters;
|
|
23
37
|
private waitersHead;
|
|
24
38
|
private waitersSettled;
|
|
25
39
|
private messageChain;
|
|
26
40
|
private error;
|
|
27
41
|
private localCloseRequested;
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
}>);
|
|
42
|
+
private writeChain;
|
|
43
|
+
private pendingOutboundBytes;
|
|
44
|
+
constructor(ws: WebSocketLike, opts?: WebSocketBinaryTransportOptions);
|
|
32
45
|
readBinary(opts?: Readonly<{
|
|
33
46
|
signal?: AbortSignal;
|
|
34
47
|
timeoutMs?: number;
|
|
@@ -42,6 +55,8 @@ export declare class WebSocketBinaryTransport {
|
|
|
42
55
|
private readonly onError;
|
|
43
56
|
private readonly onClose;
|
|
44
57
|
private push;
|
|
58
|
+
private sendWithBackpressure;
|
|
59
|
+
private failAndClose;
|
|
45
60
|
private shiftQueue;
|
|
46
61
|
private shiftWaiter;
|
|
47
62
|
private compactWaitersMaybe;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { normalizeObserver } from "../observability/observer.js";
|
|
1
|
+
import { emitObserverDiagnostic, normalizeObserver } from "../observability/observer.js";
|
|
2
2
|
import { AbortError, TimeoutError, throwIfAborted } from "../utils/errors.js";
|
|
3
3
|
export class WsCloseError extends Error {
|
|
4
4
|
code;
|
|
@@ -17,6 +17,13 @@ export class WsCloseError extends Error {
|
|
|
17
17
|
this.reason = reason;
|
|
18
18
|
}
|
|
19
19
|
}
|
|
20
|
+
export const DEFAULT_WEB_SOCKET_LIMITS = Object.freeze({
|
|
21
|
+
maxInboundQueuedBytes: 4 * (1 << 20),
|
|
22
|
+
outboundLowWatermarkBytes: 256 * 1024,
|
|
23
|
+
outboundHighWatermarkBytes: 1 << 20,
|
|
24
|
+
outboundHardLimitBytes: 4 * (1 << 20),
|
|
25
|
+
outboundDrainTimeoutMs: 10_000,
|
|
26
|
+
});
|
|
20
27
|
// WebSocketBinaryTransport adapts WebSocket messages to binary reads/writes.
|
|
21
28
|
export class WebSocketBinaryTransport {
|
|
22
29
|
// Underlying WebSocket instance (browser or polyfill).
|
|
@@ -30,7 +37,7 @@ export class WebSocketBinaryTransport {
|
|
|
30
37
|
// Current buffered byte count for backpressure.
|
|
31
38
|
queueBytes = 0;
|
|
32
39
|
// Maximum buffered bytes before closing the socket.
|
|
33
|
-
|
|
40
|
+
limits;
|
|
34
41
|
// Pending readers waiting for the next frame.
|
|
35
42
|
waiters = [];
|
|
36
43
|
// Read cursor for waiters to avoid Array.shift() O(n).
|
|
@@ -43,10 +50,13 @@ export class WebSocketBinaryTransport {
|
|
|
43
50
|
error = null;
|
|
44
51
|
// Tracks whether the close is initiated locally to avoid double-reporting.
|
|
45
52
|
localCloseRequested = false;
|
|
53
|
+
// Promise tail used to preserve write order and apply one shared backpressure lane.
|
|
54
|
+
writeChain = Promise.resolve();
|
|
55
|
+
pendingOutboundBytes = 0;
|
|
46
56
|
constructor(ws, opts = {}) {
|
|
47
57
|
this.ws = ws;
|
|
48
58
|
this.observer = normalizeObserver(opts.observer);
|
|
49
|
-
this.
|
|
59
|
+
this.limits = normalizeWebSocketLimits(opts.webSocketLimits);
|
|
50
60
|
this.ws.binaryType = "arraybuffer";
|
|
51
61
|
this.ws.addEventListener("message", this.onMessage);
|
|
52
62
|
this.ws.addEventListener("error", this.onError);
|
|
@@ -116,7 +126,26 @@ export class WebSocketBinaryTransport {
|
|
|
116
126
|
throwIfAborted(opts.signal, "write aborted");
|
|
117
127
|
if (this.error != null)
|
|
118
128
|
throw this.error;
|
|
119
|
-
this.
|
|
129
|
+
if (frame.byteLength > this.limits.outboundHardLimitBytes ||
|
|
130
|
+
this.pendingOutboundBytes + this.ws.bufferedAmount + frame.byteLength > this.limits.outboundHardLimitBytes) {
|
|
131
|
+
const err = new Error("ws send queue exceeds hard limit");
|
|
132
|
+
this.failAndClose(err, "send_buffer_exceeded");
|
|
133
|
+
throw err;
|
|
134
|
+
}
|
|
135
|
+
this.pendingOutboundBytes += frame.byteLength;
|
|
136
|
+
let handedToWebSocket = false;
|
|
137
|
+
const write = this.writeChain
|
|
138
|
+
.then(() => this.sendWithBackpressure(frame, opts.signal, () => {
|
|
139
|
+
handedToWebSocket = true;
|
|
140
|
+
this.pendingOutboundBytes = Math.max(0, this.pendingOutboundBytes - frame.byteLength);
|
|
141
|
+
}))
|
|
142
|
+
.finally(() => {
|
|
143
|
+
if (!handedToWebSocket) {
|
|
144
|
+
this.pendingOutboundBytes = Math.max(0, this.pendingOutboundBytes - frame.byteLength);
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
this.writeChain = write.catch(() => { });
|
|
148
|
+
await write;
|
|
120
149
|
}
|
|
121
150
|
// close tears down listeners and rejects pending readers.
|
|
122
151
|
close() {
|
|
@@ -147,7 +176,7 @@ export class WebSocketBinaryTransport {
|
|
|
147
176
|
return;
|
|
148
177
|
}
|
|
149
178
|
if (data instanceof ArrayBuffer) {
|
|
150
|
-
if (this.
|
|
179
|
+
if (this.queueBytes + data.byteLength > this.limits.maxInboundQueuedBytes) {
|
|
151
180
|
this.fail(new Error("ws recv buffer exceeded"), "recv_buffer_exceeded");
|
|
152
181
|
this.localCloseRequested = true;
|
|
153
182
|
this.observer.onWsClose("local");
|
|
@@ -159,7 +188,7 @@ export class WebSocketBinaryTransport {
|
|
|
159
188
|
}
|
|
160
189
|
if (ArrayBuffer.isView(data)) {
|
|
161
190
|
const view = data;
|
|
162
|
-
if (this.
|
|
191
|
+
if (this.queueBytes + view.byteLength > this.limits.maxInboundQueuedBytes) {
|
|
163
192
|
this.fail(new Error("ws recv buffer exceeded"), "recv_buffer_exceeded");
|
|
164
193
|
this.localCloseRequested = true;
|
|
165
194
|
this.observer.onWsClose("local");
|
|
@@ -170,7 +199,7 @@ export class WebSocketBinaryTransport {
|
|
|
170
199
|
return;
|
|
171
200
|
}
|
|
172
201
|
if (typeof Blob !== "undefined" && data instanceof Blob) {
|
|
173
|
-
if (this.
|
|
202
|
+
if (this.queueBytes + data.size > this.limits.maxInboundQueuedBytes) {
|
|
174
203
|
this.fail(new Error("ws recv buffer exceeded"), "recv_buffer_exceeded");
|
|
175
204
|
this.localCloseRequested = true;
|
|
176
205
|
this.observer.onWsClose("local");
|
|
@@ -216,7 +245,7 @@ export class WebSocketBinaryTransport {
|
|
|
216
245
|
w.resolve(b);
|
|
217
246
|
return;
|
|
218
247
|
}
|
|
219
|
-
if (this.
|
|
248
|
+
if (this.queueBytes + b.length > this.limits.maxInboundQueuedBytes) {
|
|
220
249
|
this.fail(new Error("ws recv buffer exceeded"), "recv_buffer_exceeded");
|
|
221
250
|
this.localCloseRequested = true;
|
|
222
251
|
this.observer.onWsClose("local");
|
|
@@ -226,6 +255,58 @@ export class WebSocketBinaryTransport {
|
|
|
226
255
|
this.queue.push(b);
|
|
227
256
|
this.queueBytes += b.length;
|
|
228
257
|
}
|
|
258
|
+
async sendWithBackpressure(frame, signal, onHandedToWebSocket) {
|
|
259
|
+
throwIfAborted(signal, "write aborted");
|
|
260
|
+
if (this.error != null)
|
|
261
|
+
throw this.error;
|
|
262
|
+
if (frame.byteLength > this.limits.outboundHardLimitBytes) {
|
|
263
|
+
const err = new Error("ws send frame exceeds hard limit");
|
|
264
|
+
this.failAndClose(err, "send_buffer_exceeded");
|
|
265
|
+
throw err;
|
|
266
|
+
}
|
|
267
|
+
const startedAt = Date.now();
|
|
268
|
+
const mustDrain = this.ws.bufferedAmount + frame.byteLength > this.limits.outboundHighWatermarkBytes ||
|
|
269
|
+
this.ws.bufferedAmount + frame.byteLength > this.limits.outboundHardLimitBytes;
|
|
270
|
+
while (mustDrain) {
|
|
271
|
+
throwIfAborted(signal, "write aborted");
|
|
272
|
+
if (this.error != null)
|
|
273
|
+
throw this.error;
|
|
274
|
+
if (Date.now() - startedAt >= this.limits.outboundDrainTimeoutMs) {
|
|
275
|
+
const err = new TimeoutError("ws send buffer drain timeout");
|
|
276
|
+
this.failAndClose(err, "send_buffer_timeout");
|
|
277
|
+
throw err;
|
|
278
|
+
}
|
|
279
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
280
|
+
if (this.ws.bufferedAmount <= this.limits.outboundLowWatermarkBytes &&
|
|
281
|
+
this.ws.bufferedAmount + frame.byteLength <= this.limits.outboundHardLimitBytes) {
|
|
282
|
+
break;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
this.ws.send(frame);
|
|
286
|
+
onHandedToWebSocket();
|
|
287
|
+
if (this.ws.bufferedAmount > this.limits.outboundHardLimitBytes) {
|
|
288
|
+
const err = new Error("ws send buffer exceeded");
|
|
289
|
+
this.failAndClose(err, "send_buffer_exceeded");
|
|
290
|
+
throw err;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
failAndClose(err, reason) {
|
|
294
|
+
emitObserverDiagnostic(this.observer, {
|
|
295
|
+
stage: "transport",
|
|
296
|
+
code_domain: "event",
|
|
297
|
+
code: reason === "send_buffer_timeout" ? "queue_pressure" : "resource_limit_reached",
|
|
298
|
+
result: "fail",
|
|
299
|
+
resource: "websocket_outbound_bytes",
|
|
300
|
+
current: this.ws.bufferedAmount + this.pendingOutboundBytes,
|
|
301
|
+
limit: this.limits.outboundHardLimitBytes,
|
|
302
|
+
});
|
|
303
|
+
this.fail(err, reason);
|
|
304
|
+
if (!this.localCloseRequested) {
|
|
305
|
+
this.localCloseRequested = true;
|
|
306
|
+
this.observer.onWsClose("local");
|
|
307
|
+
this.ws.close();
|
|
308
|
+
}
|
|
309
|
+
}
|
|
229
310
|
shiftQueue() {
|
|
230
311
|
if (this.queueHead >= this.queue.length)
|
|
231
312
|
return undefined;
|
|
@@ -299,3 +380,24 @@ export class WebSocketBinaryTransport {
|
|
|
299
380
|
}
|
|
300
381
|
}
|
|
301
382
|
}
|
|
383
|
+
function normalizeWebSocketLimits(input) {
|
|
384
|
+
const limits = {
|
|
385
|
+
maxInboundQueuedBytes: input?.maxInboundQueuedBytes ?? DEFAULT_WEB_SOCKET_LIMITS.maxInboundQueuedBytes,
|
|
386
|
+
outboundLowWatermarkBytes: input?.outboundLowWatermarkBytes ?? DEFAULT_WEB_SOCKET_LIMITS.outboundLowWatermarkBytes,
|
|
387
|
+
outboundHighWatermarkBytes: input?.outboundHighWatermarkBytes ?? DEFAULT_WEB_SOCKET_LIMITS.outboundHighWatermarkBytes,
|
|
388
|
+
outboundHardLimitBytes: input?.outboundHardLimitBytes ?? DEFAULT_WEB_SOCKET_LIMITS.outboundHardLimitBytes,
|
|
389
|
+
outboundDrainTimeoutMs: input?.outboundDrainTimeoutMs ?? DEFAULT_WEB_SOCKET_LIMITS.outboundDrainTimeoutMs,
|
|
390
|
+
};
|
|
391
|
+
for (const [name, value] of Object.entries(limits)) {
|
|
392
|
+
if (!Number.isSafeInteger(value) || value < 0)
|
|
393
|
+
throw new TypeError(`${name} must be a non-negative integer`);
|
|
394
|
+
}
|
|
395
|
+
if (limits.maxInboundQueuedBytes === 0 || limits.outboundHardLimitBytes === 0 || limits.outboundDrainTimeoutMs === 0) {
|
|
396
|
+
throw new TypeError("websocket hard limits and drain timeout must be positive");
|
|
397
|
+
}
|
|
398
|
+
if (limits.outboundLowWatermarkBytes > limits.outboundHighWatermarkBytes ||
|
|
399
|
+
limits.outboundHighWatermarkBytes > limits.outboundHardLimitBytes) {
|
|
400
|
+
throw new TypeError("websocket outbound watermarks must satisfy low <= high <= hard");
|
|
401
|
+
}
|
|
402
|
+
return Object.freeze(limits);
|
|
403
|
+
}
|
package/dist/yamux/byteReader.js
CHANGED
|
@@ -44,6 +44,32 @@ export class ByteReader {
|
|
|
44
44
|
}
|
|
45
45
|
return out;
|
|
46
46
|
}
|
|
47
|
+
// discardExactly consumes bytes without allocating a contiguous output buffer.
|
|
48
|
+
async discardExactly(n) {
|
|
49
|
+
if (n < 0)
|
|
50
|
+
throw new Error("invalid length");
|
|
51
|
+
let remaining = n;
|
|
52
|
+
while (remaining > 0) {
|
|
53
|
+
if (this.buffered === 0) {
|
|
54
|
+
const chunk = await this.readChunk();
|
|
55
|
+
if (chunk == null)
|
|
56
|
+
throw new StreamEOFError();
|
|
57
|
+
if (chunk.length === 0)
|
|
58
|
+
continue;
|
|
59
|
+
this.chunks.push(chunk);
|
|
60
|
+
this.buffered += chunk.length;
|
|
61
|
+
}
|
|
62
|
+
const head = this.chunks[this.chunkHead];
|
|
63
|
+
const take = Math.min(remaining, head.length - this.headOff);
|
|
64
|
+
this.headOff += take;
|
|
65
|
+
this.buffered -= take;
|
|
66
|
+
remaining -= take;
|
|
67
|
+
if (this.headOff === head.length) {
|
|
68
|
+
this.chunkHead++;
|
|
69
|
+
this.headOff = 0;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
47
73
|
// bufferedBytes returns the number of bytes currently buffered.
|
|
48
74
|
bufferedBytes() {
|
|
49
75
|
return this.buffered;
|
package/dist/yamux/errors.d.ts
CHANGED
|
@@ -2,3 +2,10 @@ export declare class StreamEOFError extends Error {
|
|
|
2
2
|
constructor(message?: string);
|
|
3
3
|
}
|
|
4
4
|
export declare function isStreamEOFError(e: unknown): e is StreamEOFError;
|
|
5
|
+
export declare class YamuxResourceExhaustedError extends Error {
|
|
6
|
+
readonly resource: string;
|
|
7
|
+
readonly current: number;
|
|
8
|
+
readonly limit: number;
|
|
9
|
+
constructor(resource: string, current: number, limit: number);
|
|
10
|
+
}
|
|
11
|
+
export declare function isYamuxResourceExhaustedError(error: unknown): error is YamuxResourceExhaustedError;
|
package/dist/yamux/errors.js
CHANGED
|
@@ -8,3 +8,18 @@ export class StreamEOFError extends Error {
|
|
|
8
8
|
export function isStreamEOFError(e) {
|
|
9
9
|
return e instanceof StreamEOFError;
|
|
10
10
|
}
|
|
11
|
+
export class YamuxResourceExhaustedError extends Error {
|
|
12
|
+
resource;
|
|
13
|
+
current;
|
|
14
|
+
limit;
|
|
15
|
+
constructor(resource, current, limit) {
|
|
16
|
+
super(`yamux ${resource} limit reached (${current}/${limit})`);
|
|
17
|
+
this.name = "YamuxResourceExhaustedError";
|
|
18
|
+
this.resource = resource;
|
|
19
|
+
this.current = current;
|
|
20
|
+
this.limit = limit;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
export function isYamuxResourceExhaustedError(error) {
|
|
24
|
+
return error instanceof YamuxResourceExhaustedError;
|
|
25
|
+
}
|