@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
|
@@ -14,6 +14,7 @@ export class SecureChannel {
|
|
|
14
14
|
transport;
|
|
15
15
|
// Maximum allowed bytes per record frame.
|
|
16
16
|
maxRecordBytes;
|
|
17
|
+
outboundRecordChunkBytes;
|
|
17
18
|
// Upper bound for buffered plaintext in memory.
|
|
18
19
|
maxBufferedBytes;
|
|
19
20
|
// Active encryption keys and nonce prefixes for the current epoch.
|
|
@@ -48,6 +49,11 @@ export class SecureChannel {
|
|
|
48
49
|
constructor(args) {
|
|
49
50
|
this.transport = args.transport;
|
|
50
51
|
this.maxRecordBytes = args.maxRecordBytes;
|
|
52
|
+
const maxPlain = Math.max(1, maxPlaintextBytes(this.maxRecordBytes));
|
|
53
|
+
this.outboundRecordChunkBytes = args.outboundRecordChunkBytes ?? Math.min(64 * 1024, maxPlain);
|
|
54
|
+
if (!Number.isSafeInteger(this.outboundRecordChunkBytes) || this.outboundRecordChunkBytes <= 0 || this.outboundRecordChunkBytes > maxPlain) {
|
|
55
|
+
throw new RangeError("outboundRecordChunkBytes must be a positive integer within the record plaintext limit");
|
|
56
|
+
}
|
|
51
57
|
this.maxBufferedBytes = Math.max(0, args.maxBufferedBytes ?? 4 * (1 << 20));
|
|
52
58
|
this.sendKey = args.sendKey;
|
|
53
59
|
this.recvKey = args.recvKey;
|
|
@@ -64,13 +70,9 @@ export class SecureChannel {
|
|
|
64
70
|
}
|
|
65
71
|
// write splits payloads into record-sized chunks and queues them for send.
|
|
66
72
|
async write(plaintext) {
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
const chunk = plaintext.slice(off, Math.min(plaintext.length, off + maxPlain));
|
|
71
|
-
await this.enqueueSend("app", chunk);
|
|
72
|
-
off += chunk.length;
|
|
73
|
-
}
|
|
73
|
+
if (plaintext.length === 0)
|
|
74
|
+
return;
|
|
75
|
+
await this.enqueueSend("app", plaintext.slice());
|
|
74
76
|
}
|
|
75
77
|
// read resolves with the next plaintext chunk or throws on errors/close.
|
|
76
78
|
async read() {
|
|
@@ -216,8 +218,15 @@ export class SecureChannel {
|
|
|
216
218
|
try {
|
|
217
219
|
let frame;
|
|
218
220
|
if (req.kind === "app") {
|
|
219
|
-
const
|
|
220
|
-
|
|
221
|
+
const payload = req.payload ?? new Uint8Array();
|
|
222
|
+
for (let offset = 0; offset < payload.length; offset += this.outboundRecordChunkBytes) {
|
|
223
|
+
const chunk = payload.subarray(offset, Math.min(payload.length, offset + this.outboundRecordChunkBytes));
|
|
224
|
+
const seq = this.reserveSendSeq();
|
|
225
|
+
frame = encryptRecord(this.sendKey, this.sendNoncePrefix, RECORD_FLAG_APP, seq, chunk, this.maxRecordBytes);
|
|
226
|
+
await this.transport.writeBinary(frame);
|
|
227
|
+
}
|
|
228
|
+
req.resolve();
|
|
229
|
+
continue;
|
|
221
230
|
}
|
|
222
231
|
else if (req.kind === "ping") {
|
|
223
232
|
const seq = this.reserveSendSeq();
|
package/dist/facade.d.ts
CHANGED
|
@@ -12,6 +12,9 @@ export type { ConnectArtifact, CorrelationContext, CorrelationKV, DirectClientCo
|
|
|
12
12
|
export { assertConnectArtifact } from "./connect/artifact.js";
|
|
13
13
|
export type { ClientObserverLike } from "./observability/observer.js";
|
|
14
14
|
export type { Client, ClientPath } from "./client.js";
|
|
15
|
+
export type { LivenessOptions } from "./client-connect/connectCore.js";
|
|
16
|
+
export type { WebSocketLimits } from "./ws-client/binaryTransport.js";
|
|
17
|
+
export type { YamuxLimits } from "./yamux/session.js";
|
|
15
18
|
export type { FlowersecErrorCode, FlowersecPath, FlowersecStage } from "./utils/errors.js";
|
|
16
19
|
export { FlowersecError } from "./utils/errors.js";
|
|
17
20
|
export { AllowPlaintext, AllowPlaintextForLoopback, RequireTLS, } from "./client-connect/transportSecurity.js";
|
|
@@ -1,42 +1,16 @@
|
|
|
1
|
-
import type { ConnectArtifact } from "../connect/artifact.js";
|
|
2
|
-
import type { ChannelInitGrant } from "../gen/flowersec/controlplane/v1.gen.js";
|
|
3
|
-
import type { DirectConnectInfo } from "../gen/flowersec/direct/v1.gen.js";
|
|
4
1
|
import type { ClientObserverLike } from "../observability/observer.js";
|
|
5
2
|
import type { DirectConnectOptions } from "../direct-client/connect.js";
|
|
6
3
|
import type { TunnelConnectOptions } from "../tunnel-client/connect.js";
|
|
7
4
|
import type { AutoReconnectConfig, ConnectConfig as ReconnectConnectConfig } from "../reconnect/index.js";
|
|
8
|
-
import { type
|
|
9
|
-
|
|
10
|
-
|
|
5
|
+
import { type ArtifactSource } from "../reconnect/artifactControlplane.js";
|
|
6
|
+
export type NodeReconnectConfig = Readonly<{
|
|
7
|
+
source: ArtifactSource;
|
|
8
|
+
connect?: Omit<TunnelConnectOptions, "observer" | "signal"> | Omit<DirectConnectOptions, "observer" | "signal">;
|
|
11
9
|
observer?: ClientObserverLike;
|
|
12
10
|
autoReconnect?: AutoReconnectConfig;
|
|
13
11
|
}>;
|
|
14
|
-
type
|
|
15
|
-
type
|
|
16
|
-
type ArtifactAwareTunnelReconnectConfig = ArtifactAwareReconnectConfig & Readonly<{
|
|
17
|
-
artifact?: ConnectArtifact;
|
|
18
|
-
getArtifact?: (args: ArtifactFactoryArgs) => Promise<ConnectArtifact>;
|
|
19
|
-
artifactControlplane?: RequestConnectArtifactInput | RequestEntryConnectArtifactInput;
|
|
20
|
-
}>;
|
|
21
|
-
type ArtifactAwareDirectReconnectConfig = ArtifactAwareReconnectConfig & Readonly<{
|
|
22
|
-
artifact?: ConnectArtifact;
|
|
23
|
-
getArtifact?: (args: ArtifactFactoryArgs) => Promise<ConnectArtifact>;
|
|
24
|
-
artifactControlplane?: RequestConnectArtifactInput | RequestEntryConnectArtifactInput;
|
|
25
|
-
}>;
|
|
26
|
-
export type TunnelNodeReconnectConfig = SharedReconnectOptions & ArtifactAwareTunnelReconnectConfig & Readonly<{
|
|
27
|
-
mode?: "tunnel";
|
|
28
|
-
connect?: TunnelReconnectConnectOptions;
|
|
29
|
-
grant?: ChannelInitGrant;
|
|
30
|
-
getGrant?: () => Promise<ChannelInitGrant>;
|
|
31
|
-
}>;
|
|
32
|
-
export type DirectNodeReconnectConfig = SharedReconnectOptions & ArtifactAwareDirectReconnectConfig & Readonly<{
|
|
33
|
-
mode: "direct";
|
|
34
|
-
connect?: DirectReconnectConnectOptions;
|
|
35
|
-
directInfo?: DirectConnectInfo;
|
|
36
|
-
getDirectInfo?: () => Promise<DirectConnectInfo>;
|
|
37
|
-
}>;
|
|
38
|
-
export type NodeReconnectConfig = TunnelNodeReconnectConfig | DirectNodeReconnectConfig;
|
|
39
|
-
export declare function createTunnelNodeReconnectConfig(config: TunnelNodeReconnectConfig): ReconnectConnectConfig;
|
|
40
|
-
export declare function createDirectNodeReconnectConfig(config: DirectNodeReconnectConfig): ReconnectConnectConfig;
|
|
12
|
+
export type TunnelNodeReconnectConfig = NodeReconnectConfig;
|
|
13
|
+
export type DirectNodeReconnectConfig = NodeReconnectConfig;
|
|
41
14
|
export declare function createNodeReconnectConfig(config: NodeReconnectConfig): ReconnectConnectConfig;
|
|
42
|
-
export
|
|
15
|
+
export declare const createTunnelNodeReconnectConfig: typeof createNodeReconnectConfig;
|
|
16
|
+
export declare const createDirectNodeReconnectConfig: typeof createNodeReconnectConfig;
|
|
@@ -1,78 +1,20 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
if (config.
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
}
|
|
10
|
-
async function resolveDirectInfo(config) {
|
|
11
|
-
if (config.getDirectInfo)
|
|
12
|
-
return await config.getDirectInfo();
|
|
13
|
-
if (config.directInfo)
|
|
14
|
-
return config.directInfo;
|
|
15
|
-
throw new Error("Direct reconnect config requires `getDirectInfo` or `directInfo`");
|
|
16
|
-
}
|
|
17
|
-
export function createTunnelNodeReconnectConfig(config) {
|
|
18
|
-
let traceId = config.artifact?.correlation?.trace_id;
|
|
19
|
-
return {
|
|
20
|
-
...(config.observer === undefined ? {} : { observer: config.observer }),
|
|
21
|
-
...(config.autoReconnect === undefined ? {} : { autoReconnect: config.autoReconnect }),
|
|
22
|
-
connectOnce: async ({ signal, observer }) => {
|
|
23
|
-
if (config.getArtifact || config.artifact || config.artifactControlplane) {
|
|
24
|
-
const artifact = await resolveConnectArtifact(config, traceId, signal);
|
|
25
|
-
if (artifact.transport !== "tunnel") {
|
|
26
|
-
throw new Error("Tunnel reconnect config requires a tunnel ConnectArtifact");
|
|
27
|
-
}
|
|
28
|
-
traceId = updateTraceId(traceId, artifact);
|
|
29
|
-
const connectOptions = {
|
|
30
|
-
...(config.connect === undefined ? {} : config.connect),
|
|
31
|
-
signal,
|
|
32
|
-
observer,
|
|
33
|
-
};
|
|
34
|
-
return await connectNode(artifact, connectOptions);
|
|
35
|
-
}
|
|
36
|
-
const connectOptions = {
|
|
37
|
-
...(config.connect === undefined ? {} : config.connect),
|
|
38
|
-
signal,
|
|
39
|
-
observer,
|
|
40
|
-
};
|
|
41
|
-
return await connectTunnelNode(await resolveTunnelGrant(config), connectOptions);
|
|
42
|
-
},
|
|
43
|
-
};
|
|
44
|
-
}
|
|
45
|
-
export function createDirectNodeReconnectConfig(config) {
|
|
46
|
-
let traceId = config.artifact?.correlation?.trace_id;
|
|
1
|
+
import { createArtifactResolver, updateTraceId } from "../reconnect/artifactControlplane.js";
|
|
2
|
+
import { connectNode } from "./connect.js";
|
|
3
|
+
export function createNodeReconnectConfig(config) {
|
|
4
|
+
if (config.source.kind === "once" && config.autoReconnect?.enabled) {
|
|
5
|
+
throw new Error("automatic reconnect requires a refreshable artifact source");
|
|
6
|
+
}
|
|
7
|
+
let traceId = config.source.kind === "once" ? config.source.artifact.correlation?.trace_id : undefined;
|
|
8
|
+
const acquire = createArtifactResolver(config.source);
|
|
47
9
|
return {
|
|
48
10
|
...(config.observer === undefined ? {} : { observer: config.observer }),
|
|
49
11
|
...(config.autoReconnect === undefined ? {} : { autoReconnect: config.autoReconnect }),
|
|
50
12
|
connectOnce: async ({ signal, observer }) => {
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
throw new Error("Direct reconnect config requires a direct ConnectArtifact");
|
|
55
|
-
}
|
|
56
|
-
traceId = updateTraceId(traceId, artifact);
|
|
57
|
-
const connectOptions = {
|
|
58
|
-
...(config.connect === undefined ? {} : config.connect),
|
|
59
|
-
signal,
|
|
60
|
-
observer,
|
|
61
|
-
};
|
|
62
|
-
return await connectNode(artifact, connectOptions);
|
|
63
|
-
}
|
|
64
|
-
const connectOptions = {
|
|
65
|
-
...(config.connect === undefined ? {} : config.connect),
|
|
66
|
-
signal,
|
|
67
|
-
observer,
|
|
68
|
-
};
|
|
69
|
-
return await connectDirectNode(await resolveDirectInfo(config), connectOptions);
|
|
13
|
+
const artifact = await acquire({ ...(traceId === undefined ? {} : { traceId }), signal });
|
|
14
|
+
traceId = updateTraceId(traceId, artifact);
|
|
15
|
+
return await connectNode(artifact, { ...(config.connect ?? {}), signal, observer });
|
|
70
16
|
},
|
|
71
17
|
};
|
|
72
18
|
}
|
|
73
|
-
export
|
|
74
|
-
|
|
75
|
-
return createDirectNodeReconnectConfig(config);
|
|
76
|
-
}
|
|
77
|
-
return createTunnelNodeReconnectConfig(config);
|
|
78
|
-
}
|
|
19
|
+
export const createTunnelNodeReconnectConfig = createNodeReconnectConfig;
|
|
20
|
+
export const createDirectNodeReconnectConfig = createNodeReconnectConfig;
|
package/dist/node/wsFactory.js
CHANGED
|
@@ -6,13 +6,13 @@ export type AttachReason = "send_failed" | "too_many_connections" | "expected_at
|
|
|
6
6
|
export type HandshakeResult = "ok" | "fail";
|
|
7
7
|
export type HandshakeReason = "auth_tag_mismatch" | "handshake_failed" | "invalid_suite" | "invalid_version" | "timestamp_after_init_exp" | "timestamp_out_of_skew" | "timeout" | "canceled";
|
|
8
8
|
export type WsCloseKind = "local" | "peer_or_error";
|
|
9
|
-
export type WsErrorReason = "error" | "recv_buffer_exceeded" | "unexpected_text_frame" | "unexpected_message_type";
|
|
9
|
+
export type WsErrorReason = "error" | "recv_buffer_exceeded" | "send_buffer_exceeded" | "send_buffer_timeout" | "unexpected_text_frame" | "unexpected_message_type";
|
|
10
10
|
export type RpcCallResult = "ok" | "rpc_error" | "handler_not_found" | "transport_error" | "canceled";
|
|
11
11
|
export type DiagnosticEvent = Readonly<{
|
|
12
12
|
v: 1;
|
|
13
13
|
namespace: "connect";
|
|
14
14
|
path: ClientPath | "auto";
|
|
15
|
-
stage: "validate" | "normalize" | "scope" | "connect" | "attach" | "handshake" | "close" | "reconnect";
|
|
15
|
+
stage: "validate" | "normalize" | "scope" | "connect" | "attach" | "handshake" | "transport" | "yamux" | "rpc" | "close" | "reconnect";
|
|
16
16
|
code_domain: "error" | "event";
|
|
17
17
|
code: string;
|
|
18
18
|
result: "ok" | "fail" | "retry" | "skip";
|
|
@@ -20,6 +20,9 @@ export type DiagnosticEvent = Readonly<{
|
|
|
20
20
|
attempt_seq: number;
|
|
21
21
|
trace_id?: string;
|
|
22
22
|
session_id?: string;
|
|
23
|
+
resource?: string;
|
|
24
|
+
current?: number;
|
|
25
|
+
limit?: number;
|
|
23
26
|
}>;
|
|
24
27
|
type ObserverContext = Readonly<{
|
|
25
28
|
path?: ClientPath | "auto";
|
|
@@ -54,6 +54,9 @@ function buildDiagnosticEvent(context, event) {
|
|
|
54
54
|
...(context.sessionId === undefined
|
|
55
55
|
? {}
|
|
56
56
|
: { session_id: context.sessionId }),
|
|
57
|
+
...(event.resource === undefined ? {} : { resource: event.resource }),
|
|
58
|
+
...(event.current === undefined ? {} : { current: event.current }),
|
|
59
|
+
...(event.limit === undefined ? {} : { limit: event.limit }),
|
|
57
60
|
});
|
|
58
61
|
}
|
|
59
62
|
function mapConnectDiagnostic(path, result, reason) {
|
|
@@ -33,6 +33,8 @@ export type RegisterProxyIntegrationOptions = Readonly<{
|
|
|
33
33
|
maxChunkBytes?: number;
|
|
34
34
|
maxBodyBytes?: number;
|
|
35
35
|
maxWsFrameBytes?: number;
|
|
36
|
+
maxConcurrentHttpStreams?: number;
|
|
37
|
+
maxQueuedHttpRequests?: number;
|
|
36
38
|
timeoutMs?: number;
|
|
37
39
|
pathPolicy?: ProxyRuntimePathPolicy;
|
|
38
40
|
externalOrigin?: string;
|
|
@@ -181,6 +181,12 @@ function buildRuntimeOptions(preset, runtime) {
|
|
|
181
181
|
maxBodyBytes: runtime?.maxBodyBytes ?? preset.limits.max_body_bytes,
|
|
182
182
|
maxWsFrameBytes: runtime?.maxWsFrameBytes ?? preset.limits.max_ws_frame_bytes,
|
|
183
183
|
timeoutMs: runtime?.timeoutMs ?? preset.limits.timeout_ms ?? 0,
|
|
184
|
+
...(runtime?.maxConcurrentHttpStreams === undefined
|
|
185
|
+
? {}
|
|
186
|
+
: { maxConcurrentHttpStreams: runtime.maxConcurrentHttpStreams }),
|
|
187
|
+
...(runtime?.maxQueuedHttpRequests === undefined
|
|
188
|
+
? {}
|
|
189
|
+
: { maxQueuedHttpRequests: runtime.maxQueuedHttpRequests }),
|
|
184
190
|
...(runtime?.pathPolicy === undefined ? {} : { pathPolicy: runtime.pathPolicy }),
|
|
185
191
|
...(runtime?.externalOrigin === undefined ? {} : { externalOrigin: runtime.externalOrigin }),
|
|
186
192
|
...(runtime?.runtimeRegistrationToken === undefined ? {} : { runtimeRegistrationToken: runtime.runtimeRegistrationToken }),
|
package/dist/proxy/runtime.d.ts
CHANGED
|
@@ -15,6 +15,8 @@ export type ProxyRuntimeLimits = Readonly<{
|
|
|
15
15
|
maxChunkBytes: number;
|
|
16
16
|
maxBodyBytes: number;
|
|
17
17
|
maxWsFrameBytes: number;
|
|
18
|
+
maxConcurrentHttpStreams: number;
|
|
19
|
+
maxQueuedHttpRequests: number;
|
|
18
20
|
}>;
|
|
19
21
|
export type ProxyRuntime = Readonly<{
|
|
20
22
|
limits: ProxyRuntimeLimits;
|
|
@@ -40,6 +42,8 @@ export type ProxyRuntimeOptions = Readonly<{
|
|
|
40
42
|
maxChunkBytes?: number;
|
|
41
43
|
maxBodyBytes?: number;
|
|
42
44
|
maxWsFrameBytes?: number;
|
|
45
|
+
maxConcurrentHttpStreams?: number;
|
|
46
|
+
maxQueuedHttpRequests?: number;
|
|
43
47
|
timeoutMs?: number;
|
|
44
48
|
extraRequestHeaders?: readonly string[];
|
|
45
49
|
extraResponseHeaders?: readonly string[];
|
package/dist/proxy/runtime.js
CHANGED
|
@@ -2,6 +2,7 @@ import { DEFAULT_MAX_JSON_FRAME_BYTES, readJsonFrame, writeJsonFrame } from "../
|
|
|
2
2
|
import { createByteReader } from "../streamio/index.js";
|
|
3
3
|
import { base64urlEncode } from "../utils/base64url.js";
|
|
4
4
|
import { readU32be, u32be } from "../utils/bin.js";
|
|
5
|
+
import { AbortError, FlowersecError, isFlowersecError } from "../utils/errors.js";
|
|
5
6
|
import { CookieJar } from "./cookieJar.js";
|
|
6
7
|
import { DEFAULT_MAX_BODY_BYTES, DEFAULT_MAX_CHUNK_BYTES, DEFAULT_MAX_WS_FRAME_BYTES, PROXY_KIND_HTTP1, PROXY_KIND_WS, PROXY_PROTOCOL_VERSION } from "./constants.js";
|
|
7
8
|
import { filterRequestHeaders, filterResponseHeaders, filterWsOpenHeaders } from "./headerPolicy.js";
|
|
@@ -129,6 +130,123 @@ function normalizeMaxBytes(name, v, defaultValue) {
|
|
|
129
130
|
return defaultValue;
|
|
130
131
|
return n;
|
|
131
132
|
}
|
|
133
|
+
const DEFAULT_MAX_CONCURRENT_HTTP_STREAMS = 24;
|
|
134
|
+
const DEFAULT_MAX_QUEUED_HTTP_REQUESTS = 128;
|
|
135
|
+
function normalizePositiveLimit(name, value, defaultValue) {
|
|
136
|
+
if (value == null)
|
|
137
|
+
return defaultValue;
|
|
138
|
+
if (!Number.isFinite(value) || !Number.isSafeInteger(value) || value <= 0) {
|
|
139
|
+
throw new Error(`${name} must be a positive safe integer`);
|
|
140
|
+
}
|
|
141
|
+
return value;
|
|
142
|
+
}
|
|
143
|
+
function normalizeNonNegativeLimit(name, value, defaultValue) {
|
|
144
|
+
if (value == null)
|
|
145
|
+
return defaultValue;
|
|
146
|
+
if (!Number.isFinite(value) || !Number.isSafeInteger(value) || value < 0) {
|
|
147
|
+
throw new Error(`${name} must be a non-negative safe integer`);
|
|
148
|
+
}
|
|
149
|
+
return value;
|
|
150
|
+
}
|
|
151
|
+
class HttpStreamAdmission {
|
|
152
|
+
path;
|
|
153
|
+
maxConcurrent;
|
|
154
|
+
maxQueued;
|
|
155
|
+
active = 0;
|
|
156
|
+
pending = [];
|
|
157
|
+
closed = false;
|
|
158
|
+
constructor(path, maxConcurrent, maxQueued) {
|
|
159
|
+
this.path = path;
|
|
160
|
+
this.maxConcurrent = maxConcurrent;
|
|
161
|
+
this.maxQueued = maxQueued;
|
|
162
|
+
}
|
|
163
|
+
acquire(signal) {
|
|
164
|
+
if (this.closed)
|
|
165
|
+
return Promise.reject(this.closedError());
|
|
166
|
+
if (signal?.aborted)
|
|
167
|
+
return Promise.reject(this.abortedError());
|
|
168
|
+
if (this.active < this.maxConcurrent && this.pending.length === 0) {
|
|
169
|
+
this.active++;
|
|
170
|
+
return Promise.resolve(this.createRelease());
|
|
171
|
+
}
|
|
172
|
+
if (this.pending.length >= this.maxQueued) {
|
|
173
|
+
return Promise.reject(new FlowersecError({
|
|
174
|
+
path: this.path,
|
|
175
|
+
stage: "yamux",
|
|
176
|
+
code: "resource_exhausted",
|
|
177
|
+
message: "proxy runtime HTTP request queue is full",
|
|
178
|
+
}));
|
|
179
|
+
}
|
|
180
|
+
return new Promise((resolve, reject) => {
|
|
181
|
+
const waiter = {
|
|
182
|
+
resolve,
|
|
183
|
+
reject,
|
|
184
|
+
...(signal === undefined ? {} : { signal }),
|
|
185
|
+
};
|
|
186
|
+
waiter.onAbort = () => {
|
|
187
|
+
const index = this.pending.indexOf(waiter);
|
|
188
|
+
if (index < 0)
|
|
189
|
+
return;
|
|
190
|
+
this.pending.splice(index, 1);
|
|
191
|
+
this.cleanupWaiter(waiter);
|
|
192
|
+
reject(this.abortedError());
|
|
193
|
+
};
|
|
194
|
+
signal?.addEventListener("abort", waiter.onAbort, { once: true });
|
|
195
|
+
this.pending.push(waiter);
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
close() {
|
|
199
|
+
if (this.closed)
|
|
200
|
+
return;
|
|
201
|
+
this.closed = true;
|
|
202
|
+
for (const waiter of this.pending.splice(0)) {
|
|
203
|
+
this.cleanupWaiter(waiter);
|
|
204
|
+
waiter.reject(this.closedError());
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
assertOpen() {
|
|
208
|
+
if (this.closed)
|
|
209
|
+
throw this.closedError();
|
|
210
|
+
}
|
|
211
|
+
createRelease() {
|
|
212
|
+
let released = false;
|
|
213
|
+
return () => {
|
|
214
|
+
if (released)
|
|
215
|
+
return;
|
|
216
|
+
released = true;
|
|
217
|
+
this.active = Math.max(0, this.active - 1);
|
|
218
|
+
this.drain();
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
drain() {
|
|
222
|
+
while (!this.closed && this.active < this.maxConcurrent && this.pending.length > 0) {
|
|
223
|
+
const waiter = this.pending.shift();
|
|
224
|
+
this.cleanupWaiter(waiter);
|
|
225
|
+
if (waiter.signal?.aborted) {
|
|
226
|
+
waiter.reject(this.abortedError());
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
this.active++;
|
|
230
|
+
waiter.resolve(this.createRelease());
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
cleanupWaiter(waiter) {
|
|
234
|
+
if (waiter.onAbort != null) {
|
|
235
|
+
waiter.signal?.removeEventListener("abort", waiter.onAbort);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
abortedError() {
|
|
239
|
+
return new AbortError("proxy HTTP request canceled while waiting for stream admission");
|
|
240
|
+
}
|
|
241
|
+
closedError() {
|
|
242
|
+
return new FlowersecError({
|
|
243
|
+
path: this.path,
|
|
244
|
+
stage: "close",
|
|
245
|
+
code: "not_connected",
|
|
246
|
+
message: "proxy runtime is disposed",
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
}
|
|
132
250
|
async function writeChunkFrames(stream, body, chunkSize, maxBodyBytes) {
|
|
133
251
|
if (maxBodyBytes > 0 && body.length > maxBodyBytes)
|
|
134
252
|
throw new Error("request body too large");
|
|
@@ -169,6 +287,9 @@ export function createProxyRuntime(opts) {
|
|
|
169
287
|
const maxChunkBytes = normalizeMaxBytes("maxChunkBytes", opts.maxChunkBytes, DEFAULT_MAX_CHUNK_BYTES);
|
|
170
288
|
const maxBodyBytes = normalizeMaxBytes("maxBodyBytes", opts.maxBodyBytes, DEFAULT_MAX_BODY_BYTES);
|
|
171
289
|
const maxWsFrameBytes = normalizeMaxBytes("maxWsFrameBytes", opts.maxWsFrameBytes, DEFAULT_MAX_WS_FRAME_BYTES);
|
|
290
|
+
const maxConcurrentHttpStreams = normalizePositiveLimit("maxConcurrentHttpStreams", opts.maxConcurrentHttpStreams, DEFAULT_MAX_CONCURRENT_HTTP_STREAMS);
|
|
291
|
+
const maxQueuedHttpRequests = normalizeNonNegativeLimit("maxQueuedHttpRequests", opts.maxQueuedHttpRequests, DEFAULT_MAX_QUEUED_HTTP_REQUESTS);
|
|
292
|
+
const httpStreamAdmission = new HttpStreamAdmission(client.path, maxConcurrentHttpStreams, maxQueuedHttpRequests);
|
|
172
293
|
const timeoutMs = normalizeTimeoutMs(opts.timeoutMs);
|
|
173
294
|
const extraRequestHeaders = opts.extraRequestHeaders ?? [];
|
|
174
295
|
const extraResponseHeaders = opts.extraResponseHeaders ?? [];
|
|
@@ -203,6 +324,7 @@ export function createProxyRuntime(opts) {
|
|
|
203
324
|
const dispatchFetch = (req, port) => {
|
|
204
325
|
const ac = new AbortController();
|
|
205
326
|
let stream = null;
|
|
327
|
+
let releaseAdmission = null;
|
|
206
328
|
port.onmessage = (ev) => {
|
|
207
329
|
const m = ev.data;
|
|
208
330
|
if (m && typeof m === "object" && m.type === "flowersec-proxy:abort") {
|
|
@@ -215,6 +337,8 @@ export function createProxyRuntime(opts) {
|
|
|
215
337
|
assertPathPolicyAllows("http", path, pathPolicy);
|
|
216
338
|
const requestID = req.id.trim() !== "" ? req.id : randomB64u(18);
|
|
217
339
|
const externalOrigin = externalOriginOverride ?? normalizeExternalOrigin(req.external_origin);
|
|
340
|
+
releaseAdmission = await httpStreamAdmission.acquire(ac.signal);
|
|
341
|
+
httpStreamAdmission.assertOpen();
|
|
218
342
|
stream = await client.openStream(PROXY_KIND_HTTP1, { signal: ac.signal });
|
|
219
343
|
const reader = createByteReader(stream, { signal: ac.signal });
|
|
220
344
|
const filteredReqHeaders = filterRequestHeaders(req.headers, { extraAllowed: extraRequestHeaders });
|
|
@@ -264,8 +388,17 @@ export function createProxyRuntime(opts) {
|
|
|
264
388
|
}
|
|
265
389
|
catch (e) {
|
|
266
390
|
const msg = e instanceof Error ? e.message : String(e);
|
|
267
|
-
const
|
|
268
|
-
|
|
391
|
+
const code = isFlowersecError(e) ? e.code : undefined;
|
|
392
|
+
const status = e instanceof ProxyRuntimePolicyError
|
|
393
|
+
? e.status
|
|
394
|
+
: code === "resource_exhausted" || code === "not_connected"
|
|
395
|
+
? 503
|
|
396
|
+
: 502;
|
|
397
|
+
port.postMessage({
|
|
398
|
+
type: "flowersec-proxy:response_error",
|
|
399
|
+
status,
|
|
400
|
+
message: msg,
|
|
401
|
+
});
|
|
269
402
|
try {
|
|
270
403
|
stream?.reset(new Error(msg));
|
|
271
404
|
}
|
|
@@ -274,6 +407,7 @@ export function createProxyRuntime(opts) {
|
|
|
274
407
|
}
|
|
275
408
|
}
|
|
276
409
|
finally {
|
|
410
|
+
releaseAdmission?.();
|
|
277
411
|
try {
|
|
278
412
|
port.close();
|
|
279
413
|
}
|
|
@@ -312,10 +446,18 @@ export function createProxyRuntime(opts) {
|
|
|
312
446
|
return { stream, protocol: resp.protocol ?? "" };
|
|
313
447
|
}
|
|
314
448
|
return {
|
|
315
|
-
limits: {
|
|
449
|
+
limits: {
|
|
450
|
+
maxJsonFrameBytes,
|
|
451
|
+
maxChunkBytes,
|
|
452
|
+
maxBodyBytes,
|
|
453
|
+
maxWsFrameBytes,
|
|
454
|
+
maxConcurrentHttpStreams,
|
|
455
|
+
maxQueuedHttpRequests,
|
|
456
|
+
},
|
|
316
457
|
dispatchFetch,
|
|
317
458
|
openWebSocketStream,
|
|
318
459
|
dispose: () => {
|
|
460
|
+
httpStreamAdmission.close();
|
|
319
461
|
sw?.removeEventListener("message", onMessage);
|
|
320
462
|
sw?.removeEventListener("controllerchange", registerRuntime);
|
|
321
463
|
}
|
|
@@ -36,12 +36,16 @@ export declare function resolveRuntimeLimitsFromScope(scope: ProxyRuntimeScopeV1
|
|
|
36
36
|
maxChunkBytes?: number;
|
|
37
37
|
maxBodyBytes?: number;
|
|
38
38
|
maxWsFrameBytes?: number;
|
|
39
|
+
maxConcurrentHttpStreams?: number;
|
|
40
|
+
maxQueuedHttpRequests?: number;
|
|
39
41
|
timeoutMs?: number;
|
|
40
42
|
}> | undefined): Readonly<{
|
|
41
43
|
maxJsonFrameBytes?: number;
|
|
42
44
|
maxChunkBytes?: number;
|
|
43
45
|
maxBodyBytes?: number;
|
|
44
46
|
maxWsFrameBytes?: number;
|
|
47
|
+
maxConcurrentHttpStreams?: number;
|
|
48
|
+
maxQueuedHttpRequests?: number;
|
|
45
49
|
timeoutMs?: number;
|
|
46
50
|
}> | undefined;
|
|
47
51
|
export declare function resolvePresetInputFromScope(scope: ProxyRuntimeScopeV1, presetOverride: ProxyPresetInput | undefined): ProxyPresetInput | undefined;
|
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
import type { ConnectArtifact } from "../connect/artifact.js";
|
|
2
2
|
import { type RequestConnectArtifactInput, type RequestEntryConnectArtifactInput } from "../controlplane/index.js";
|
|
3
|
-
export type
|
|
3
|
+
export type ArtifactAcquireContext = Readonly<{
|
|
4
4
|
traceId?: string;
|
|
5
5
|
signal?: AbortSignal;
|
|
6
6
|
}>;
|
|
7
|
-
export type
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
7
|
+
export type ArtifactSource = Readonly<{
|
|
8
|
+
kind: "once";
|
|
9
|
+
artifact: ConnectArtifact;
|
|
10
|
+
}> | Readonly<{
|
|
11
|
+
kind: "refreshable";
|
|
12
|
+
acquire: (context: ArtifactAcquireContext) => Promise<ConnectArtifact>;
|
|
11
13
|
}>;
|
|
12
|
-
export declare function
|
|
14
|
+
export declare function createControlplaneArtifactSource(input: RequestConnectArtifactInput | RequestEntryConnectArtifactInput): ArtifactSource;
|
|
15
|
+
export declare function createArtifactResolver(source: ArtifactSource): (context: ArtifactAcquireContext) => Promise<ConnectArtifact>;
|
|
13
16
|
export declare function updateTraceId(current: string | undefined, artifact: ConnectArtifact): string | undefined;
|
|
@@ -1,33 +1,34 @@
|
|
|
1
1
|
import { requestConnectArtifact, requestEntryConnectArtifact, } from "../controlplane/index.js";
|
|
2
|
-
export
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
const input = {
|
|
17
|
-
...config.artifactControlplane,
|
|
2
|
+
export function createControlplaneArtifactSource(input) {
|
|
3
|
+
return {
|
|
4
|
+
kind: "refreshable",
|
|
5
|
+
acquire: async ({ traceId, signal }) => {
|
|
6
|
+
const correlation = traceId === undefined ? input.correlation : { traceId };
|
|
7
|
+
if ("entryTicket" in input) {
|
|
8
|
+
return await requestEntryConnectArtifact({
|
|
9
|
+
...input,
|
|
10
|
+
...(correlation === undefined ? {} : { correlation }),
|
|
11
|
+
...(signal === undefined ? {} : { signal }),
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
return await requestConnectArtifact({
|
|
15
|
+
...input,
|
|
18
16
|
...(correlation === undefined ? {} : { correlation }),
|
|
19
17
|
...(signal === undefined ? {} : { signal }),
|
|
20
|
-
};
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
18
|
+
});
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
export function createArtifactResolver(source) {
|
|
23
|
+
let consumed = false;
|
|
24
|
+
return async (context) => {
|
|
25
|
+
if (source.kind === "refreshable")
|
|
26
|
+
return await source.acquire(context);
|
|
27
|
+
if (consumed)
|
|
28
|
+
throw new Error("one-time artifact source has already been consumed");
|
|
29
|
+
consumed = true;
|
|
30
|
+
return source.artifact;
|
|
31
|
+
};
|
|
31
32
|
}
|
|
32
33
|
export function updateTraceId(current, artifact) {
|
|
33
34
|
return artifact.correlation?.trace_id ?? current;
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { Client } from "../client.js";
|
|
2
2
|
import { type ClientObserverLike } from "../observability/observer.js";
|
|
3
|
+
export type { ArtifactAcquireContext, ArtifactSource } from "./artifactControlplane.js";
|
|
4
|
+
export { createArtifactResolver, createControlplaneArtifactSource } from "./artifactControlplane.js";
|
|
3
5
|
export type ConnectionStatus = "disconnected" | "connecting" | "connected" | "error";
|
|
4
6
|
export type AutoReconnectConfig = Readonly<{
|
|
5
7
|
enabled?: boolean;
|
package/dist/reconnect/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { emitObserverDiagnostic, withObserverContext } from "../observability/observer.js";
|
|
2
|
+
export { createArtifactResolver, createControlplaneArtifactSource } from "./artifactControlplane.js";
|
|
2
3
|
function normalizeAutoReconnect(cfg) {
|
|
3
4
|
if (!cfg?.enabled) {
|
|
4
5
|
return {
|
|
@@ -159,6 +160,12 @@ export function createReconnectManager() {
|
|
|
159
160
|
},
|
|
160
161
|
onRpcCall: (...args) => user?.onRpcCall?.(...args),
|
|
161
162
|
onRpcNotify: (...args) => user?.onRpcNotify?.(...args),
|
|
163
|
+
onDiagnosticEvent: (event) => {
|
|
164
|
+
user?.onDiagnosticEvent?.(event);
|
|
165
|
+
if (event.code === "liveness_timeout") {
|
|
166
|
+
startReconnect(t, cfg, new Error("liveness timeout"));
|
|
167
|
+
}
|
|
168
|
+
},
|
|
162
169
|
}, {
|
|
163
170
|
attemptSeq: currentAttemptSeq,
|
|
164
171
|
});
|