@floegence/flowersec-core 0.21.1 → 0.22.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.
@@ -0,0 +1,480 @@
1
+ import { createRequire } from "node:module";
2
+ import { DEFAULT_MAX_JSON_FRAME_BYTES, readJsonFrame, writeJsonFrame } from "../framing/jsonframe.js";
3
+ import { RpcRouter, RpcServer } from "../rpc/server.js";
4
+ import { createByteReader } from "../streamio/index.js";
5
+ import { readU32be, u32be } from "../utils/bin.js";
6
+ 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
+ import { filterRequestHeaders, filterResponseHeaders, filterWsOpenHeaders, isSafeHeaderValue, isValidHeaderName, normalizeHeaderName } from "./headerPolicy.js";
8
+ export async function serveProxySession(session, options, signal) {
9
+ const compiled = compileOptions(options);
10
+ const active = new Set();
11
+ try {
12
+ while (!signal?.aborted) {
13
+ const accepted = await session.acceptStream(signal === undefined ? {} : { signal });
14
+ if (accepted.kind === "rpc") {
15
+ if (active.size >= compiled.maxConcurrentStreams) {
16
+ await accepted.stream.reset(new Error("proxy stream concurrency exhausted"));
17
+ continue;
18
+ }
19
+ const task = serveRPCStream(accepted.stream, options.rpcRouter ?? new RpcRouter(), options.rpcServerOptions, signal)
20
+ .catch(() => { })
21
+ .finally(() => active.delete(task));
22
+ active.add(task);
23
+ continue;
24
+ }
25
+ if (accepted.kind !== PROXY_KIND_HTTP1 && accepted.kind !== PROXY_KIND_WS) {
26
+ await accepted.stream.reset(new Error(`unsupported proxy stream kind ${accepted.kind}`));
27
+ continue;
28
+ }
29
+ if (active.size >= compiled.maxConcurrentStreams) {
30
+ await accepted.stream.reset(new Error("proxy stream concurrency exhausted"));
31
+ continue;
32
+ }
33
+ const task = serveProxyStreamCompiled(accepted.kind, accepted.stream, compiled, signal)
34
+ .catch(() => { })
35
+ .finally(() => active.delete(task));
36
+ active.add(task);
37
+ }
38
+ }
39
+ finally {
40
+ await Promise.allSettled(active);
41
+ }
42
+ }
43
+ async function serveRPCStream(stream, router, options, signal) {
44
+ const reader = createByteReader(stream, signal === undefined ? {} : { signal });
45
+ const server = new RpcServer({
46
+ readExactly: (length) => reader.readExactly(length),
47
+ write: (bytes) => stream.write(bytes),
48
+ close: (error) => { void stream.reset(asError(error)); },
49
+ }, options, router);
50
+ await server.serve(signal);
51
+ }
52
+ export function serveProxyStream(kind, stream, options, signal) {
53
+ return serveProxyStreamCompiled(kind, stream, compileOptions(options), signal);
54
+ }
55
+ async function serveProxyStreamCompiled(kind, stream, options, signal) {
56
+ try {
57
+ if (kind === PROXY_KIND_HTTP1)
58
+ await serveHTTP(stream, options, signal);
59
+ else
60
+ await serveWebSocket(stream, options, signal);
61
+ }
62
+ finally {
63
+ try {
64
+ await stream.close();
65
+ }
66
+ catch { /* The peer may already have reset the stream. */ }
67
+ }
68
+ }
69
+ async function serveHTTP(stream, options, signal) {
70
+ const reader = createByteReader(stream, signal === undefined ? {} : { signal });
71
+ let requestId = "unknown";
72
+ try {
73
+ const meta = assertHTTPRequestMeta(await readJsonFrame(reader, options.maxJsonFrameBytes));
74
+ requestId = meta.request_id;
75
+ const path = parsePath(meta.path);
76
+ const body = await readBody(reader, options.maxChunkBytes, options.maxBodyBytes);
77
+ const headers = requestHeaders(meta.headers, options);
78
+ applyExternalOrigin(headers, meta.external_origin);
79
+ const target = new URL(options.upstream);
80
+ target.pathname = path.pathname;
81
+ target.search = path.search;
82
+ target.hash = "";
83
+ const timeoutMs = resolveTimeout(meta.timeout_ms, options);
84
+ const controller = new AbortController();
85
+ const onAbort = () => controller.abort(signal?.reason);
86
+ signal?.addEventListener("abort", onAbort, { once: true });
87
+ const timer = timeoutMs > 0 ? setTimeout(() => controller.abort(new Error("upstream request timeout")), timeoutMs) : undefined;
88
+ try {
89
+ const method = meta.method.trim().toUpperCase();
90
+ const response = await options.fetch(target, {
91
+ method,
92
+ headers,
93
+ redirect: "manual",
94
+ signal: controller.signal,
95
+ ...((method === "GET" || method === "HEAD") ? {} : { body: body.slice().buffer }),
96
+ });
97
+ const responseHeaders = collectResponseHeaders(response.headers, options);
98
+ const responseMeta = {
99
+ v: PROXY_PROTOCOL_VERSION,
100
+ request_id: requestId,
101
+ ok: true,
102
+ status: response.status,
103
+ headers: responseHeaders,
104
+ };
105
+ await writeJsonFrame(stream, responseMeta);
106
+ if (response.body == null) {
107
+ await stream.write(u32be(0));
108
+ return;
109
+ }
110
+ const bodyReader = response.body.getReader();
111
+ let total = 0;
112
+ while (true) {
113
+ const next = await bodyReader.read();
114
+ if (next.done)
115
+ break;
116
+ const bytes = next.value;
117
+ total += bytes.length;
118
+ if (total > options.maxBodyBytes)
119
+ throw new ProxyServerError("response_body_too_large", "response body too large");
120
+ for (let offset = 0; offset < bytes.length; offset += options.maxChunkBytes) {
121
+ const chunk = bytes.subarray(offset, Math.min(bytes.length, offset + options.maxChunkBytes));
122
+ await stream.write(u32be(chunk.length));
123
+ await stream.write(chunk);
124
+ }
125
+ }
126
+ await stream.write(u32be(0));
127
+ }
128
+ finally {
129
+ if (timer != null)
130
+ clearTimeout(timer);
131
+ signal?.removeEventListener("abort", onAbort);
132
+ }
133
+ }
134
+ catch (error) {
135
+ await writeHTTPError(stream, requestId, classifyHTTPError(error), error);
136
+ }
137
+ }
138
+ async function serveWebSocket(stream, options, signal) {
139
+ const reader = createByteReader(stream, signal === undefined ? {} : { signal });
140
+ let connId = "unknown";
141
+ let raw;
142
+ try {
143
+ const meta = assertWSOpenMeta(await readJsonFrame(reader, options.maxJsonFrameBytes));
144
+ connId = meta.conn_id;
145
+ const path = parsePath(meta.path);
146
+ const target = new URL(options.upstream);
147
+ target.protocol = target.protocol === "https:" ? "wss:" : "ws:";
148
+ target.pathname = path.pathname;
149
+ target.search = path.search;
150
+ target.hash = "";
151
+ const filtered = serverWSHeaders(meta.headers, options);
152
+ const protocolsHeader = filtered.find((header) => header.name === "sec-websocket-protocol")?.value ?? "";
153
+ const protocols = protocolsHeader.split(",").map((value) => value.trim()).filter((value) => value !== "");
154
+ const headers = { Origin: options.upstreamOrigin };
155
+ for (const header of filtered) {
156
+ if (header.name === "sec-websocket-protocol")
157
+ continue;
158
+ headers[header.name] = headers[header.name] == null ? header.value : `${headers[header.name]}, ${header.value}`;
159
+ }
160
+ const require = createRequire(import.meta.url);
161
+ const module = require("ws");
162
+ const WebSocketCtor = module.WebSocket ?? module;
163
+ raw = new WebSocketCtor(target.toString(), protocols, {
164
+ headers,
165
+ maxPayload: options.maxWsFrameBytes,
166
+ perMessageDeflate: false,
167
+ handshakeTimeout: Math.min(options.defaultTimeoutMs, options.maxTimeoutMs),
168
+ });
169
+ await waitForUpstreamOpen(raw, signal);
170
+ const response = {
171
+ v: PROXY_PROTOCOL_VERSION,
172
+ conn_id: connId,
173
+ ok: true,
174
+ protocol: String(raw.protocol ?? ""),
175
+ };
176
+ await writeJsonFrame(stream, response);
177
+ let writeChain = Promise.resolve();
178
+ let terminalResolve;
179
+ const terminal = new Promise((resolve) => { terminalResolve = resolve; });
180
+ const writeFrame = (op, payload) => {
181
+ const write = writeChain.then(() => writeWSFrame(stream, op, payload, options.maxWsFrameBytes));
182
+ writeChain = write.catch(() => { });
183
+ void write.catch((error) => terminalResolve(asError(error)));
184
+ };
185
+ raw.on("message", (data, isBinary) => writeFrame(isBinary ? 2 : 1, toBytes(data)));
186
+ raw.on("ping", (data) => writeFrame(9, toBytes(data)));
187
+ raw.on("pong", (data) => writeFrame(10, toBytes(data)));
188
+ raw.on("close", (code, reason) => {
189
+ const reasonBytes = toBytes(reason);
190
+ const payload = new Uint8Array(2 + reasonBytes.length);
191
+ payload[0] = (code >>> 8) & 0xff;
192
+ payload[1] = code & 0xff;
193
+ payload.set(reasonBytes, 2);
194
+ writeFrame(8, payload);
195
+ terminalResolve();
196
+ });
197
+ raw.on("error", (error) => terminalResolve(error));
198
+ const inbound = (async () => {
199
+ while (true) {
200
+ const frame = await readWSFrame(reader, options.maxWsFrameBytes);
201
+ switch (frame.op) {
202
+ case 1:
203
+ raw.send(frame.payload, { binary: false });
204
+ break;
205
+ case 2:
206
+ raw.send(frame.payload, { binary: true });
207
+ break;
208
+ case 8:
209
+ raw.close(frame.payload.length >= 2 ? (frame.payload[0] << 8) | frame.payload[1] : 1000, new TextDecoder().decode(frame.payload.subarray(2)));
210
+ return;
211
+ case 9:
212
+ raw.ping(frame.payload);
213
+ break;
214
+ case 10:
215
+ raw.pong(frame.payload);
216
+ break;
217
+ default: throw new Error("invalid websocket frame operation");
218
+ }
219
+ }
220
+ })();
221
+ const result = await Promise.race([inbound.then(() => undefined), terminal]);
222
+ if (result != null)
223
+ throw result;
224
+ await writeChain;
225
+ }
226
+ catch (error) {
227
+ if (raw == null || raw.readyState !== 1)
228
+ await writeWSOpenError(stream, connId, classifyWSError(error), error);
229
+ }
230
+ finally {
231
+ try {
232
+ raw?.close();
233
+ }
234
+ catch { /* Best effort. */ }
235
+ }
236
+ }
237
+ function compileOptions(input) {
238
+ const upstream = new URL(input.upstream);
239
+ if ((upstream.protocol !== "http:" && upstream.protocol !== "https:") || upstream.username !== "" || upstream.password !== "") {
240
+ throw new Error("upstream must be an http(s) URL without credentials");
241
+ }
242
+ const allowedHosts = new Set((input.allowedUpstreamHosts?.length ? input.allowedUpstreamHosts : ["127.0.0.1"]).map((host) => host.trim().toLowerCase()));
243
+ if (!allowedHosts.has(upstream.hostname.toLowerCase()))
244
+ throw new Error("upstream host is not allowed");
245
+ const upstreamOrigin = normalizeOrigin(input.upstreamOrigin ?? upstream.origin);
246
+ const maxJsonFrameBytes = positive(input.maxJsonFrameBytes, DEFAULT_MAX_JSON_FRAME_BYTES, "maxJsonFrameBytes");
247
+ const maxChunkBytes = positive(input.maxChunkBytes, DEFAULT_MAX_CHUNK_BYTES, "maxChunkBytes");
248
+ const maxBodyBytes = positive(input.maxBodyBytes, DEFAULT_MAX_BODY_BYTES, "maxBodyBytes");
249
+ const maxWsFrameBytes = positive(input.maxWsFrameBytes, DEFAULT_MAX_WS_FRAME_BYTES, "maxWsFrameBytes");
250
+ const defaultTimeoutMs = nonNegative(input.defaultTimeoutMs, 30_000, "defaultTimeoutMs");
251
+ const maxTimeoutMs = nonNegative(input.maxTimeoutMs, 300_000, "maxTimeoutMs");
252
+ if (maxTimeoutMs > 0 && defaultTimeoutMs > maxTimeoutMs)
253
+ throw new Error("defaultTimeoutMs exceeds maxTimeoutMs");
254
+ return {
255
+ upstream,
256
+ upstreamOrigin,
257
+ maxJsonFrameBytes,
258
+ maxChunkBytes,
259
+ maxBodyBytes,
260
+ maxWsFrameBytes,
261
+ defaultTimeoutMs,
262
+ maxTimeoutMs,
263
+ maxConcurrentStreams: positive(input.maxConcurrentStreams, 64, "maxConcurrentStreams"),
264
+ extraRequestHeaders: input.extraRequestHeaders ?? [],
265
+ extraResponseHeaders: input.extraResponseHeaders ?? [],
266
+ blockedResponseHeaders: normalizeNames(input.blockedResponseHeaders),
267
+ extraWsHeaders: input.extraWsHeaders ?? [],
268
+ forbiddenCookieNames: normalizeNames(input.forbiddenCookieNames),
269
+ forbiddenCookieNamePrefixes: [...normalizeNames(input.forbiddenCookieNamePrefixes)],
270
+ fetch: input.fetch ?? globalThis.fetch.bind(globalThis),
271
+ };
272
+ }
273
+ function assertHTTPRequestMeta(input) {
274
+ if (typeof input !== "object" || input == null || Array.isArray(input))
275
+ throw new ProxyServerError("invalid_request_meta", "invalid HTTP request meta");
276
+ const meta = input;
277
+ if (meta.v !== PROXY_PROTOCOL_VERSION || typeof meta.request_id !== "string" || meta.request_id.trim() === "")
278
+ throw new ProxyServerError("invalid_request_meta", "invalid request ID");
279
+ if (typeof meta.method !== "string" || meta.method.trim() === "" || typeof meta.path !== "string" || !Array.isArray(meta.headers))
280
+ throw new ProxyServerError("invalid_request_meta", "invalid HTTP request meta");
281
+ if (meta.timeout_ms !== undefined && (!Number.isSafeInteger(meta.timeout_ms) || meta.timeout_ms < 0))
282
+ throw new ProxyServerError("invalid_request_meta", "invalid timeout_ms");
283
+ return { ...meta, request_id: meta.request_id.trim(), method: meta.method.trim() };
284
+ }
285
+ function assertWSOpenMeta(input) {
286
+ if (typeof input !== "object" || input == null || Array.isArray(input))
287
+ throw new ProxyServerError("invalid_ws_open_meta", "invalid websocket open meta");
288
+ const meta = input;
289
+ if (meta.v !== PROXY_PROTOCOL_VERSION || typeof meta.conn_id !== "string" || meta.conn_id.trim() === "" || typeof meta.path !== "string" || !Array.isArray(meta.headers)) {
290
+ throw new ProxyServerError("invalid_ws_open_meta", "invalid websocket open meta");
291
+ }
292
+ return { ...meta, conn_id: meta.conn_id.trim() };
293
+ }
294
+ function parsePath(input) {
295
+ const path = input.trim();
296
+ if (!path.startsWith("/") || path.startsWith("//") || path.includes("://") || /[\r\n\t ]/.test(path))
297
+ throw new ProxyServerError("invalid_request_meta", "invalid path");
298
+ return new URL(path, "http://flowersec.invalid");
299
+ }
300
+ function requestHeaders(input, options) {
301
+ const filtered = filterRequestHeaders(input, { extraAllowed: options.extraRequestHeaders });
302
+ const headers = new Headers();
303
+ for (const header of filtered)
304
+ headers.append(header.name, header.value);
305
+ for (const header of input) {
306
+ if (normalizeHeaderName(header.name) !== "cookie" || !isSafeHeaderValue(header.value))
307
+ continue;
308
+ const cookie = filterCookie(header.value, options);
309
+ if (cookie !== "")
310
+ headers.append("cookie", cookie);
311
+ }
312
+ return headers;
313
+ }
314
+ function serverWSHeaders(input, options) {
315
+ const filtered = filterWsOpenHeaders(input, { extraAllowed: options.extraWsHeaders });
316
+ return filtered.flatMap((header) => header.name === "cookie" ? [{ ...header, value: filterCookie(header.value, options) }] : [header]).filter((header) => header.value !== "");
317
+ }
318
+ function filterCookie(input, options) {
319
+ return input.split(";").map((part) => part.trim()).filter((part) => {
320
+ const index = part.indexOf("=");
321
+ if (index <= 0)
322
+ return false;
323
+ const name = part.slice(0, index).trim().toLowerCase();
324
+ if (options.forbiddenCookieNames.has(name))
325
+ return false;
326
+ return !options.forbiddenCookieNamePrefixes.some((prefix) => name.startsWith(prefix));
327
+ }).join("; ");
328
+ }
329
+ function collectResponseHeaders(input, options) {
330
+ const raw = [];
331
+ const setCookies = input.getSetCookie?.() ?? [];
332
+ input.forEach((value, name) => {
333
+ if (name.toLowerCase() !== "set-cookie")
334
+ raw.push({ name, value });
335
+ });
336
+ for (const value of setCookies)
337
+ raw.push({ name: "set-cookie", value });
338
+ return filterResponseHeaders(raw, { extraAllowed: options.extraResponseHeaders }).passthrough
339
+ .concat(raw.filter((header) => normalizeHeaderName(header.name) === "set-cookie"))
340
+ .filter((header) => !options.blockedResponseHeaders.has(normalizeHeaderName(header.name)));
341
+ }
342
+ function applyExternalOrigin(headers, input) {
343
+ if (input == null || input.trim() === "")
344
+ return;
345
+ const external = normalizeOrigin(input);
346
+ const current = headers.get("origin");
347
+ if (current != null && normalizeOrigin(current) !== external)
348
+ throw new ProxyServerError("invalid_request_meta", "external_origin conflicts with origin header");
349
+ headers.set("x-forwarded-proto", new URL(external).protocol.slice(0, -1));
350
+ headers.set("host", new URL(external).host);
351
+ }
352
+ async function readBody(reader, maxChunkBytes, maxBodyBytes) {
353
+ const chunks = [];
354
+ let total = 0;
355
+ while (true) {
356
+ const length = readU32be(await reader.readExactly(4), 0);
357
+ if (length === 0)
358
+ break;
359
+ if (length > maxChunkBytes)
360
+ throw new ProxyServerError("request_body_too_large", "request chunk too large");
361
+ total += length;
362
+ if (total > maxBodyBytes)
363
+ throw new ProxyServerError("request_body_too_large", "request body too large");
364
+ chunks.push(await reader.readExactly(length));
365
+ }
366
+ const body = new Uint8Array(total);
367
+ let offset = 0;
368
+ for (const chunk of chunks) {
369
+ body.set(chunk, offset);
370
+ offset += chunk.length;
371
+ }
372
+ return body;
373
+ }
374
+ async function writeHTTPError(stream, requestId, code, error) {
375
+ const meta = { v: PROXY_PROTOCOL_VERSION, request_id: requestId.trim() || "unknown", ok: false, error: { code, message: asError(error).message } };
376
+ try {
377
+ await writeJsonFrame(stream, meta);
378
+ await stream.write(u32be(0));
379
+ }
380
+ catch { /* The peer may be gone. */ }
381
+ }
382
+ async function readWSFrame(reader, maxBytes) {
383
+ const header = await reader.readExactly(5);
384
+ const length = readU32be(header, 1);
385
+ if (length > maxBytes)
386
+ throw new Error("websocket frame too large");
387
+ return { op: header[0], payload: await reader.readExactly(length) };
388
+ }
389
+ async function writeWSFrame(stream, op, payload, maxBytes) {
390
+ if (payload.length > maxBytes)
391
+ throw new Error("websocket frame too large");
392
+ const header = new Uint8Array(5);
393
+ header[0] = op;
394
+ header.set(u32be(payload.length), 1);
395
+ await stream.write(header);
396
+ if (payload.length > 0)
397
+ await stream.write(payload);
398
+ }
399
+ async function writeWSOpenError(stream, connId, code, error) {
400
+ const response = { v: PROXY_PROTOCOL_VERSION, conn_id: connId.trim() || "unknown", ok: false, error: { code, message: asError(error).message } };
401
+ try {
402
+ await writeJsonFrame(stream, response);
403
+ }
404
+ catch { /* The peer may be gone. */ }
405
+ }
406
+ function waitForUpstreamOpen(websocket, signal) {
407
+ return new Promise((resolve, reject) => {
408
+ const cleanup = () => { websocket.off("open", onOpen); websocket.off("error", onError); signal?.removeEventListener("abort", onAbort); };
409
+ const onOpen = () => { cleanup(); resolve(); };
410
+ const onError = (error) => { cleanup(); reject(error); };
411
+ const onAbort = () => { cleanup(); reject(signal?.reason ?? new Error("aborted")); };
412
+ websocket.once("open", onOpen);
413
+ websocket.once("error", onError);
414
+ signal?.addEventListener("abort", onAbort, { once: true });
415
+ });
416
+ }
417
+ function resolveTimeout(input, options) {
418
+ const timeout = input == null || input === 0 ? options.defaultTimeoutMs : input;
419
+ return options.maxTimeoutMs > 0 ? Math.min(timeout, options.maxTimeoutMs) : timeout;
420
+ }
421
+ function normalizeOrigin(input) {
422
+ const url = new URL(input);
423
+ if ((url.protocol !== "http:" && url.protocol !== "https:") || url.username !== "" || url.password !== "" || (url.pathname !== "" && url.pathname !== "/") || url.search !== "" || url.hash !== "")
424
+ throw new Error("invalid origin");
425
+ return url.origin;
426
+ }
427
+ function normalizeNames(input) {
428
+ const result = new Set();
429
+ for (const item of input ?? []) {
430
+ const name = normalizeHeaderName(item);
431
+ if (name === "" || !isValidHeaderName(name))
432
+ throw new Error("invalid policy name");
433
+ result.add(name);
434
+ }
435
+ return result;
436
+ }
437
+ function positive(input, fallback, name) {
438
+ const value = input ?? fallback;
439
+ if (!Number.isSafeInteger(value) || value <= 0)
440
+ throw new Error(`${name} must be a positive integer`);
441
+ return value;
442
+ }
443
+ function nonNegative(input, fallback, name) {
444
+ const value = input ?? fallback;
445
+ if (!Number.isSafeInteger(value) || value < 0)
446
+ throw new Error(`${name} must be a non-negative integer`);
447
+ return value;
448
+ }
449
+ function classifyHTTPError(error) {
450
+ if (error instanceof ProxyServerError)
451
+ return error.code;
452
+ if (asError(error).name === "AbortError")
453
+ return "timeout";
454
+ return "upstream_request_failed";
455
+ }
456
+ function classifyWSError(error) {
457
+ if (error instanceof ProxyServerError)
458
+ return error.code;
459
+ return "upstream_ws_dial_failed";
460
+ }
461
+ function toBytes(input) {
462
+ if (input instanceof Uint8Array)
463
+ return input;
464
+ if (input instanceof ArrayBuffer)
465
+ return new Uint8Array(input);
466
+ if (ArrayBuffer.isView(input))
467
+ return new Uint8Array(input.buffer, input.byteOffset, input.byteLength);
468
+ return new TextEncoder().encode(String(input ?? ""));
469
+ }
470
+ function asError(error) {
471
+ return error instanceof Error ? error : new Error(String(error));
472
+ }
473
+ class ProxyServerError extends Error {
474
+ code;
475
+ constructor(code, message) {
476
+ super(message);
477
+ this.code = code;
478
+ this.name = "ProxyServerError";
479
+ }
480
+ }
@@ -1,24 +1,25 @@
1
1
  import { getClientTermination } from "../client-connect/termination.js";
2
2
  import { emitObserverDiagnostic, withObserverContext } from "../observability/observer.js";
3
+ import { SDK_DEFAULTS } from "../defaults.js";
3
4
  export { createArtifactResolver, createControlplaneArtifactSource } from "./artifactControlplane.js";
4
5
  function normalizeAutoReconnect(cfg) {
5
6
  if (!cfg?.enabled) {
6
7
  return {
7
8
  enabled: false,
8
9
  maxAttempts: 1,
9
- initialDelayMs: 500,
10
- maxDelayMs: 10_000,
11
- factor: 1.8,
12
- jitterRatio: 0.2,
10
+ initialDelayMs: SDK_DEFAULTS.reconnect.initialDelayMs,
11
+ maxDelayMs: SDK_DEFAULTS.reconnect.maxDelayMs,
12
+ factor: SDK_DEFAULTS.reconnect.factor,
13
+ jitterRatio: SDK_DEFAULTS.reconnect.jitterRatio,
13
14
  };
14
15
  }
15
16
  return {
16
17
  enabled: true,
17
- maxAttempts: Math.max(1, cfg.maxAttempts ?? 5),
18
- initialDelayMs: Math.max(0, cfg.initialDelayMs ?? 500),
19
- maxDelayMs: Math.max(0, cfg.maxDelayMs ?? 10_000),
20
- factor: Math.max(1, cfg.factor ?? 1.8),
21
- jitterRatio: Math.max(0, cfg.jitterRatio ?? 0.2),
18
+ maxAttempts: Math.max(1, cfg.maxAttempts ?? SDK_DEFAULTS.reconnect.maxAttempts),
19
+ initialDelayMs: Math.max(0, cfg.initialDelayMs ?? SDK_DEFAULTS.reconnect.initialDelayMs),
20
+ maxDelayMs: Math.max(0, cfg.maxDelayMs ?? SDK_DEFAULTS.reconnect.maxDelayMs),
21
+ factor: Math.max(1, cfg.factor ?? SDK_DEFAULTS.reconnect.factor),
22
+ jitterRatio: Math.max(0, cfg.jitterRatio ?? SDK_DEFAULTS.reconnect.jitterRatio),
22
23
  };
23
24
  }
24
25
  function backoffDelayMs(attemptIndex, cfg) {
@@ -13,9 +13,14 @@ export type RpcServerTransport = Readonly<{
13
13
  write(bytes: Uint8Array): Promise<void>;
14
14
  close(error: unknown): void;
15
15
  }>;
16
+ export declare class RpcRouter {
17
+ private readonly handlers;
18
+ register(typeId: number, handler: RpcHandler): void;
19
+ handler(typeId: number): RpcHandler | undefined;
20
+ }
16
21
  export declare class RpcServer {
17
22
  private readonly transport;
18
- private readonly handlers;
23
+ private readonly router;
19
24
  private closed;
20
25
  private readonly options;
21
26
  private readonly requests;
@@ -27,8 +32,9 @@ export declare class RpcServer {
27
32
  private readonly terminalSignal;
28
33
  private signalTerminal;
29
34
  private transportClosed;
30
- constructor(transport: RpcServerTransport, options?: RpcServerOptions);
35
+ constructor(transport: RpcServerTransport, options?: RpcServerOptions, router?: RpcRouter);
31
36
  register(typeId: number, h: RpcHandler): void;
37
+ notify(typeId: number, payload: unknown): Promise<void>;
32
38
  serve(signal?: AbortSignal): Promise<void>;
33
39
  close(error?: unknown): void;
34
40
  private fail;
@@ -37,4 +43,5 @@ export declare class RpcServer {
37
43
  private nextWork;
38
44
  private wakeOne;
39
45
  private writeResponse;
46
+ private writeEnvelope;
40
47
  }
@@ -1,15 +1,24 @@
1
1
  import { DEFAULT_MAX_JSON_FRAME_BYTES, readJsonFrame, writeJsonFrame } from "../framing/jsonframe.js";
2
2
  import { assertRpcEnvelope } from "./validate.js";
3
+ import { SDK_DEFAULTS } from "../defaults.js";
3
4
  const DEFAULT_RPC_SERVER_OPTIONS = Object.freeze({
4
- maxConcurrentRequests: 32,
5
- maxQueuedRequests: 128,
6
- maxQueuedNotifications: 128,
5
+ maxConcurrentRequests: SDK_DEFAULTS.rpc.maxConcurrentRequests,
6
+ maxQueuedRequests: SDK_DEFAULTS.rpc.maxQueuedRequests,
7
+ maxQueuedNotifications: SDK_DEFAULTS.rpc.maxQueuedNotifications,
7
8
  });
9
+ export class RpcRouter {
10
+ handlers = new Map();
11
+ register(typeId, handler) {
12
+ this.handlers.set(typeId >>> 0, handler);
13
+ }
14
+ handler(typeId) {
15
+ return this.handlers.get(typeId >>> 0);
16
+ }
17
+ }
8
18
  // RpcServer dispatches request envelopes to registered handlers.
9
19
  export class RpcServer {
10
20
  transport;
11
- // Registered handlers keyed by type ID.
12
- handlers = new Map();
21
+ router;
13
22
  // Closed flag to stop the serve loop.
14
23
  closed = false;
15
24
  options;
@@ -22,8 +31,9 @@ export class RpcServer {
22
31
  terminalSignal;
23
32
  signalTerminal;
24
33
  transportClosed = false;
25
- constructor(transport, options = {}) {
34
+ constructor(transport, options = {}, router = new RpcRouter()) {
26
35
  this.transport = transport;
36
+ this.router = router;
27
37
  this.terminalSignal = new Promise((resolve) => { this.signalTerminal = resolve; });
28
38
  this.options = {
29
39
  maxConcurrentRequests: positiveInteger(options.maxConcurrentRequests ?? DEFAULT_RPC_SERVER_OPTIONS.maxConcurrentRequests, "maxConcurrentRequests"),
@@ -33,7 +43,17 @@ export class RpcServer {
33
43
  }
34
44
  // register binds a handler to a type ID.
35
45
  register(typeId, h) {
36
- this.handlers.set(typeId >>> 0, h);
46
+ this.router.register(typeId, h);
47
+ }
48
+ async notify(typeId, payload) {
49
+ if (this.closed)
50
+ throw new Error("rpc server closed");
51
+ await this.writeEnvelope({
52
+ type_id: typeId >>> 0,
53
+ request_id: 0,
54
+ response_to: 0,
55
+ payload,
56
+ });
37
57
  }
38
58
  // serve handles request/response frames until closed or aborted.
39
59
  async serve(signal) {
@@ -106,7 +126,7 @@ export class RpcServer {
106
126
  if (work == null)
107
127
  return;
108
128
  const v = work.envelope;
109
- const h = this.handlers.get(v.type_id >>> 0);
129
+ const h = this.router.handler(v.type_id);
110
130
  let out;
111
131
  if (h == null)
112
132
  out = { payload: null, error: { code: 404, message: "handler not found" } };
@@ -129,7 +149,7 @@ export class RpcServer {
129
149
  if (work == null)
130
150
  return;
131
151
  const v = work.envelope;
132
- const h = this.handlers.get(v.type_id >>> 0);
152
+ const h = this.router.handler(v.type_id);
133
153
  if (h == null)
134
154
  continue;
135
155
  try {
@@ -158,7 +178,10 @@ export class RpcServer {
158
178
  payload: out.payload,
159
179
  ...(out.error != null ? { error: out.error } : {}),
160
180
  };
161
- const write = this.writeChain.then(() => writeJsonFrame(this.transport.write, resp));
181
+ await this.writeEnvelope(resp);
182
+ }
183
+ async writeEnvelope(envelope) {
184
+ const write = this.writeChain.then(() => writeJsonFrame(this.transport.write, envelope));
162
185
  this.writeChain = write.catch(() => { });
163
186
  await write;
164
187
  }
@@ -3,14 +3,15 @@ import { decodeHeader, encodeHeader, HEADER_LEN } from "./header.js";
3
3
  import { FLAG_ACK, FLAG_RST, FLAG_SYN, TYPE_DATA, TYPE_GO_AWAY, TYPE_PING, TYPE_WINDOW_UPDATE, YAMUX_VERSION } from "./constants.js";
4
4
  import { YamuxStream } from "./stream.js";
5
5
  import { YamuxResourceExhaustedError } from "./errors.js";
6
+ import { SDK_DEFAULTS } from "../defaults.js";
6
7
  export const DEFAULT_YAMUX_LIMITS = Object.freeze({
7
- maxActiveStreams: 64,
8
- maxInboundStreams: 32,
9
- maxFrameBytes: 256 * 1024,
10
- preferredOutboundFrameBytes: 64 * 1024,
11
- maxStreamReceiveBytes: 256 * 1024,
12
- maxSessionReceiveBytes: 16 * (1 << 20),
13
- maxStreamWriteQueueBytes: 4 * (1 << 20),
8
+ maxActiveStreams: SDK_DEFAULTS.yamux.maxActiveStreams,
9
+ maxInboundStreams: SDK_DEFAULTS.yamux.maxInboundStreams,
10
+ maxFrameBytes: SDK_DEFAULTS.yamux.maxFrameBytes,
11
+ preferredOutboundFrameBytes: SDK_DEFAULTS.yamux.preferredOutboundFrameBytes,
12
+ maxStreamReceiveBytes: SDK_DEFAULTS.yamux.maxStreamReceiveBytes,
13
+ maxSessionReceiveBytes: SDK_DEFAULTS.yamux.maxSessionReceiveBytes,
14
+ maxStreamWriteQueueBytes: SDK_DEFAULTS.e2ee.maxOutboundBufferedBytes,
14
15
  });
15
16
  // YamuxSession multiplexes multiple streams over a single byte stream.
16
17
  export class YamuxSession {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@floegence/flowersec-core",
3
- "version": "0.21.1",
3
+ "version": "0.22.0",
4
4
  "description": "Flowersec core TypeScript library (browser-friendly E2EE + multiplexing over WebSocket).",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -40,6 +40,10 @@
40
40
  "types": "./dist/controlplane/index.d.ts",
41
41
  "default": "./dist/controlplane/index.js"
42
42
  },
43
+ "./endpoint": {
44
+ "types": "./dist/endpoint/index.d.ts",
45
+ "default": "./dist/endpoint/index.js"
46
+ },
43
47
  "./framing": {
44
48
  "types": "./dist/framing/index.d.ts",
45
49
  "default": "./dist/framing/index.js"