@floegence/flowersec-core 0.20.2 → 0.21.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.
@@ -31,7 +31,7 @@ export type ConnectOptionsBase = Readonly<{
31
31
  outboundRecordChunkBytes?: number;
32
32
  /** WebSocket inbound and outbound queue limits. */
33
33
  webSocketLimits?: Partial<WebSocketLimits>;
34
- /** Yamux stream, frame, and receive-memory limits. */
34
+ /** Yamux stream, frame, receive-memory, and per-stream write-queue limits. */
35
35
  yamuxLimits?: Partial<YamuxLimits>;
36
36
  /** Optional factory for creating the WebSocket instance. */
37
37
  wsFactory?: (url: string, origin: string) => WebSocketLike;
@@ -39,6 +39,23 @@ function hasExpectedCapability(data, capabilityNonce) {
39
39
  return false;
40
40
  return data.capabilityNonce === capabilityNonce;
41
41
  }
42
+ function withTrustedExternalOrigin(req, rawOrigin) {
43
+ let externalOrigin;
44
+ try {
45
+ const url = new URL(rawOrigin);
46
+ if (url.protocol === "http:" || url.protocol === "https:") {
47
+ externalOrigin = url.origin;
48
+ }
49
+ }
50
+ catch {
51
+ // Opaque and invalid origins must not become upstream authority metadata.
52
+ }
53
+ const sanitized = { ...req };
54
+ delete sanitized.external_origin;
55
+ return externalOrigin === undefined
56
+ ? sanitized
57
+ : { ...sanitized, external_origin: externalOrigin };
58
+ }
42
59
  function bridgeWebSocket(runtime, msg, port) {
43
60
  const ac = new AbortController();
44
61
  let terminal = false;
@@ -191,7 +208,8 @@ export function registerProxyControllerWindow(opts) {
191
208
  throw new Error("targetWindow is not available");
192
209
  }
193
210
  const onMessage = (ev) => {
194
- if (!allowedOrigins.includes(String(ev.origin ?? "").trim()))
211
+ const eventOrigin = String(ev.origin ?? "").trim();
212
+ if (!allowedOrigins.includes(eventOrigin))
195
213
  return;
196
214
  if (opts.expectedSource != null && ev.source !== opts.expectedSource)
197
215
  return;
@@ -205,9 +223,11 @@ export function registerProxyControllerWindow(opts) {
205
223
  if (!port)
206
224
  return;
207
225
  switch (type) {
208
- case PROXY_WINDOW_FETCH_MSG_TYPE:
209
- opts.runtime.dispatchFetch(data.req, port);
226
+ case PROXY_WINDOW_FETCH_MSG_TYPE: {
227
+ const msg = data;
228
+ opts.runtime.dispatchFetch(withTrustedExternalOrigin(msg.req, eventOrigin), port);
210
229
  return;
230
+ }
211
231
  case PROXY_WINDOW_WS_OPEN_MSG_TYPE:
212
232
  bridgeWebSocket(opts.runtime, data, port);
213
233
  return;
@@ -24,7 +24,6 @@ export type ResolvedProxyPreset = Readonly<{
24
24
  }>;
25
25
  }>;
26
26
  export declare const DEFAULT_PROXY_PRESET_MANIFEST: ProxyPresetManifest;
27
- export declare const CODESERVER_PROXY_PRESET_MANIFEST: ProxyPresetManifest;
28
27
  export type ProxyPresetInput = ProxyPresetManifest | Partial<ProxyPresetLimits>;
29
28
  export declare function assertProxyPresetManifest(value: unknown): ProxyPresetManifest;
30
29
  export declare function resolveNamedProxyPreset(name: string): ProxyPresetManifest;
@@ -17,17 +17,6 @@ export const DEFAULT_PROXY_PRESET_MANIFEST = Object.freeze({
17
17
  max_ws_frame_bytes: DEFAULT_MAX_WS_FRAME_BYTES,
18
18
  },
19
19
  });
20
- export const CODESERVER_PROXY_PRESET_MANIFEST = Object.freeze({
21
- v: 1,
22
- preset_id: "codeserver",
23
- deprecated: true,
24
- limits: {
25
- max_json_frame_bytes: DEFAULT_MAX_JSON_FRAME_BYTES,
26
- max_chunk_bytes: DEFAULT_MAX_CHUNK_BYTES,
27
- max_body_bytes: DEFAULT_MAX_BODY_BYTES,
28
- max_ws_frame_bytes: 32 * 1024 * 1024,
29
- },
30
- });
31
20
  function isRecord(v) {
32
21
  return typeof v === "object" && v != null && !Array.isArray(v);
33
22
  }
@@ -91,8 +80,6 @@ export function resolveNamedProxyPreset(name) {
91
80
  case "":
92
81
  case "default":
93
82
  return DEFAULT_PROXY_PRESET_MANIFEST;
94
- case "codeserver":
95
- return CODESERVER_PROXY_PRESET_MANIFEST;
96
83
  default:
97
84
  throw new Error(`unknown proxy preset: ${name}`);
98
85
  }
@@ -6,7 +6,7 @@ export type ProxyProfile = Readonly<{
6
6
  maxWsFrameBytes: number;
7
7
  timeoutMs: number;
8
8
  }>;
9
- export type ProxyProfileName = "default" | "codeserver";
9
+ export type ProxyProfileName = "default";
10
10
  export declare const PROXY_PROFILE_DEFAULT: Readonly<{
11
11
  maxJsonFrameBytes: number;
12
12
  maxChunkBytes: number;
@@ -14,12 +14,5 @@ export declare const PROXY_PROFILE_DEFAULT: Readonly<{
14
14
  maxWsFrameBytes: number;
15
15
  timeoutMs: number;
16
16
  }>;
17
- export declare const PROXY_PROFILE_CODESERVER: Readonly<{
18
- maxJsonFrameBytes: number;
19
- maxChunkBytes: number;
20
- maxBodyBytes: number;
21
- maxWsFrameBytes: number;
22
- timeoutMs: number;
23
- }>;
24
17
  export declare function resolveProxyProfile(profile?: ProxyProfileName | Partial<ProxyProfile>): ProxyProfile;
25
18
  export declare function profileToPresetManifest(profile?: ProxyProfileName | Partial<ProxyProfile>): ProxyPresetManifest;
@@ -1,4 +1,4 @@
1
- import { CODESERVER_PROXY_PRESET_MANIFEST, DEFAULT_PROXY_PRESET_MANIFEST, resolveProxyPreset, } from "./preset.js";
1
+ import { DEFAULT_PROXY_PRESET_MANIFEST, resolveProxyPreset, } from "./preset.js";
2
2
  function toLegacyProfile(manifest) {
3
3
  const resolved = resolveProxyPreset(manifest);
4
4
  return Object.freeze({
@@ -10,9 +10,7 @@ function toLegacyProfile(manifest) {
10
10
  });
11
11
  }
12
12
  const DEFAULT_PROFILE = toLegacyProfile(DEFAULT_PROXY_PRESET_MANIFEST);
13
- const CODESERVER_PROFILE = toLegacyProfile(CODESERVER_PROXY_PRESET_MANIFEST);
14
13
  export const PROXY_PROFILE_DEFAULT = DEFAULT_PROFILE;
15
- export const PROXY_PROFILE_CODESERVER = CODESERVER_PROFILE;
16
14
  function normalizeSafeInt(name, value) {
17
15
  if (!Number.isFinite(value))
18
16
  throw new Error(`${name} must be a finite number`);
@@ -27,8 +25,6 @@ function resolveNamedProfile(name) {
27
25
  switch (name) {
28
26
  case "default":
29
27
  return DEFAULT_PROFILE;
30
- case "codeserver":
31
- return CODESERVER_PROFILE;
32
28
  default:
33
29
  throw new Error(`unknown proxy profile: ${name}`);
34
30
  }
@@ -55,8 +51,6 @@ export function profileToPresetManifest(profile) {
55
51
  switch (profile) {
56
52
  case "default":
57
53
  return DEFAULT_PROXY_PRESET_MANIFEST;
58
- case "codeserver":
59
- return CODESERVER_PROXY_PRESET_MANIFEST;
60
54
  default:
61
55
  throw new Error(`unknown proxy profile: ${profile}`);
62
56
  }
@@ -8,6 +8,7 @@ type ProxyFetchReq = Readonly<{
8
8
  path: string;
9
9
  headers: readonly Header[];
10
10
  external_origin?: string;
11
+ response_flow_control?: "chunk_credit_v1";
11
12
  body?: ArrayBuffer;
12
13
  }>;
13
14
  export type ProxyRuntimeLimits = Readonly<{
@@ -345,11 +345,44 @@ export function createProxyRuntime(opts) {
345
345
  const ac = new AbortController();
346
346
  let stream = null;
347
347
  let releaseAdmission = null;
348
+ const usesResponseCredits = req.response_flow_control === "chunk_credit_v1";
349
+ let responseCreditAvailable = false;
350
+ let responseCreditResolve = null;
351
+ const wakeResponseCreditWaiter = () => {
352
+ const resolve = responseCreditResolve;
353
+ responseCreditResolve = null;
354
+ resolve?.();
355
+ };
348
356
  port.onmessage = (ev) => {
349
357
  const m = ev.data;
350
- if (m && typeof m === "object" && m.type === "flowersec-proxy:abort") {
358
+ if (!m || typeof m !== "object")
359
+ return;
360
+ if (m.type === "flowersec-proxy:abort") {
361
+ responseCreditAvailable = false;
351
362
  ac.abort("aborted");
363
+ wakeResponseCreditWaiter();
364
+ return;
365
+ }
366
+ if (usesResponseCredits && m.type === "flowersec-proxy:response_credit") {
367
+ responseCreditAvailable = true;
368
+ wakeResponseCreditWaiter();
369
+ }
370
+ };
371
+ const waitForResponseCredit = async () => {
372
+ if (!usesResponseCredits)
373
+ return;
374
+ if (ac.signal.aborted)
375
+ throw new AbortError("aborted");
376
+ while (!responseCreditAvailable) {
377
+ if (ac.signal.aborted)
378
+ throw new AbortError("aborted");
379
+ await new Promise((resolve) => {
380
+ responseCreditResolve = resolve;
381
+ });
352
382
  }
383
+ if (ac.signal.aborted)
384
+ throw new AbortError("aborted");
385
+ responseCreditAvailable = false;
353
386
  };
354
387
  void (async () => {
355
388
  try {
@@ -400,6 +433,9 @@ export function createProxyRuntime(opts) {
400
433
  port.postMessage({ type: "flowersec-proxy:response_meta", status, headers: passthrough });
401
434
  const chunks = await readChunkFrames(reader, maxChunkBytes, maxBodyBytes);
402
435
  for await (const chunk of chunks) {
436
+ await waitForResponseCredit();
437
+ if (ac.signal.aborted)
438
+ throw new AbortError("aborted");
403
439
  // Always transfer an ArrayBuffer (SharedArrayBuffer is not transferable).
404
440
  const ab = chunk.slice().buffer;
405
441
  port.postMessage({ type: "flowersec-proxy:response_chunk", data: ab }, [ab]);
@@ -429,6 +465,7 @@ export function createProxyRuntime(opts) {
429
465
  }
430
466
  }
431
467
  finally {
468
+ wakeResponseCreditWaiter();
432
469
  releaseAdmission?.();
433
470
  try {
434
471
  port.close();
@@ -472,12 +472,25 @@ async function handleFetch(event) {
472
472
  const donePromise = new Promise((resolve, reject) => { doneResolve = resolve; doneReject = reject; });
473
473
 
474
474
  let controller = null;
475
+ let responseCreditOutstanding = false;
476
+
477
+ function requestResponseCredit() {
478
+ if (responseCreditOutstanding) return;
479
+ responseCreditOutstanding = true;
480
+ try { port.postMessage({ type: "flowersec-proxy:response_credit" }); } catch {}
481
+ }
482
+
483
+ function abortRuntimeResponse() {
484
+ responseCreditOutstanding = false;
485
+ try { port.postMessage({ type: "flowersec-proxy:abort" }); } catch {}
486
+ try { port.close(); } catch {}
487
+ }
475
488
 
476
489
  const stream = new ReadableStream({
477
490
  start(c) { controller = c; },
491
+ pull() { requestResponseCredit(); },
478
492
  cancel() {
479
- try { port.postMessage({ type: "flowersec-proxy:abort" }); } catch {}
480
- try { port.close(); } catch {}
493
+ abortRuntimeResponse();
481
494
  }
482
495
  });
483
496
 
@@ -490,7 +503,7 @@ async function handleFetch(event) {
490
503
  metaReject(err);
491
504
  doneReject(err);
492
505
  controller?.error(err);
493
- try { port.close(); } catch {}
506
+ abortRuntimeResponse();
494
507
  return false;
495
508
  }
496
509
  htmlChunks.push(b);
@@ -516,27 +529,30 @@ async function handleFetch(event) {
516
529
  metaReject(err);
517
530
  doneReject(err);
518
531
  controller?.error(err);
519
- try { port.close(); } catch {}
532
+ abortRuntimeResponse();
520
533
  return;
521
534
  }
522
535
  }
523
536
  }
524
537
  metaResolve(m);
538
+ if (shouldInjectHTML) requestResponseCredit();
525
539
  if (controller && !shouldInjectHTML) for (const q of queued) controller.enqueue(q);
526
540
  if (shouldInjectHTML) for (const q of queued) if (!pushInjectChunk(q)) return;
527
541
  queued.length = 0;
528
542
  return;
529
543
  }
530
544
  if (m.type === "flowersec-proxy:response_chunk") {
545
+ responseCreditOutstanding = false;
531
546
  const b = new Uint8Array(m.data);
532
547
  if (shouldInjectHTML) {
533
- pushInjectChunk(b);
548
+ if (pushInjectChunk(b)) requestResponseCredit();
534
549
  return;
535
550
  }
536
551
  if (controller) controller.enqueue(b); else queued.push(b);
537
552
  return;
538
553
  }
539
554
  if (m.type === "flowersec-proxy:response_end") {
555
+ responseCreditOutstanding = false;
540
556
  if (shouldInjectHTML) {
541
557
  doneResolve(htmlChunks);
542
558
  return;
@@ -545,6 +561,7 @@ async function handleFetch(event) {
545
561
  return;
546
562
  }
547
563
  if (m.type === "flowersec-proxy:response_error") {
564
+ responseCreditOutstanding = false;
548
565
  const status = typeof m.status === "number" && Number.isFinite(m.status) ? Math.floor(m.status) : 502;
549
566
  lastErrorStatus = status > 0 ? status : 502;
550
567
  lastErrorMessage = String(m.message || "proxy error");
@@ -572,6 +589,7 @@ async function handleFetch(event) {
572
589
  path,
573
590
  headers: headersToPairs(req.headers),
574
591
  ...(externalOrigin ? { external_origin: externalOrigin } : {}),
592
+ response_flow_control: "chunk_credit_v1",
575
593
  body
576
594
  }
577
595
  }, [port2]);
@@ -16,6 +16,7 @@ export type ProxyWindowFetchRequest = Readonly<{
16
16
  path: string;
17
17
  headers: readonly Header[];
18
18
  external_origin?: string;
19
+ response_flow_control?: "chunk_credit_v1";
19
20
  body?: ArrayBuffer;
20
21
  }>;
21
22
  export type ProxyWindowFetchForwardMsg = Readonly<{
@@ -138,8 +138,9 @@ export function createReconnectManager() {
138
138
  }
139
139
  token += 1;
140
140
  const nextToken = token;
141
+ const reconnectPromise = startConnectLoop(nextToken, cfg);
141
142
  setState({ status: "connecting", error, client: null });
142
- void connectWithRetry(nextToken, cfg).catch(() => {
143
+ void reconnectPromise.catch(() => {
143
144
  // connectWithRetry updates state; keep errors observable via state().
144
145
  });
145
146
  };
@@ -262,6 +263,22 @@ export function createReconnectManager() {
262
263
  }
263
264
  }
264
265
  };
266
+ const startConnectLoop = (t, cfg) => {
267
+ let resolveLoop;
268
+ let rejectLoop;
269
+ const loop = new Promise((resolve, reject) => {
270
+ resolveLoop = resolve;
271
+ rejectLoop = reject;
272
+ });
273
+ let promise;
274
+ promise = loop.finally(() => {
275
+ if (activeConnectPromise === promise)
276
+ activeConnectPromise = null;
277
+ });
278
+ activeConnectPromise = promise;
279
+ void connectWithRetry(t, cfg).then(resolveLoop, rejectLoop);
280
+ return promise;
281
+ };
265
282
  const connect = async (cfg) => {
266
283
  cancelRetrySleep();
267
284
  abortActiveAttempt();
@@ -277,25 +294,18 @@ export function createReconnectManager() {
277
294
  // ignore
278
295
  }
279
296
  }
297
+ const connectPromise = startConnectLoop(t, cfg);
280
298
  setState({ status: "connecting", error: null, client: null });
281
- const p = connectWithRetry(t, cfg);
282
- activeConnectPromise = p;
283
- try {
284
- await p;
285
- }
286
- finally {
287
- if (activeConnectPromise === p)
288
- activeConnectPromise = null;
289
- }
299
+ await connectPromise;
290
300
  };
291
301
  const connectIfNeeded = async (cfg) => {
292
302
  if (isSameConfig(active, cfg)) {
293
- if (s.status === "connected" && s.client)
294
- return;
295
- if (s.status === "connecting" && activeConnectPromise) {
303
+ if (activeConnectPromise) {
296
304
  await activeConnectPromise;
297
305
  return;
298
306
  }
307
+ if (s.status === "connected" && s.client)
308
+ return;
299
309
  }
300
310
  await connect(cfg);
301
311
  };
@@ -12,8 +12,11 @@ export type YamuxLimits = Readonly<{
12
12
  preferredOutboundFrameBytes: number;
13
13
  maxStreamReceiveBytes: number;
14
14
  maxSessionReceiveBytes: number;
15
+ /** Maximum bytes retained by pending write calls on one stream. */
16
+ maxStreamWriteQueueBytes?: number;
15
17
  }>;
16
- export declare const DEFAULT_YAMUX_LIMITS: YamuxLimits;
18
+ type ResolvedYamuxLimits = Readonly<Required<YamuxLimits>>;
19
+ export declare const DEFAULT_YAMUX_LIMITS: ResolvedYamuxLimits;
17
20
  export type ByteDuplex = {
18
21
  /** Reads the next chunk from the underlying connection. */
19
22
  read(): Promise<Uint8Array>;
@@ -60,6 +63,7 @@ export declare class YamuxSession {
60
63
  getStream(id: number): YamuxStream | undefined;
61
64
  writeRaw(chunk: Uint8Array): Promise<void>;
62
65
  outboundFrameBytes(): number;
66
+ streamWriteQueueBytes(): number;
63
67
  releaseReceiveBytes(bytes: number): void;
64
68
  probeLiveness(timeoutMs?: number): Promise<number>;
65
69
  private startLivenessProbe;
@@ -79,3 +83,4 @@ export declare class YamuxSession {
79
83
  private isInboundStreamIdValid;
80
84
  private diagnostic;
81
85
  }
86
+ export {};
@@ -10,6 +10,7 @@ export const DEFAULT_YAMUX_LIMITS = Object.freeze({
10
10
  preferredOutboundFrameBytes: 64 * 1024,
11
11
  maxStreamReceiveBytes: 256 * 1024,
12
12
  maxSessionReceiveBytes: 16 * (1 << 20),
13
+ maxStreamWriteQueueBytes: 4 * (1 << 20),
13
14
  });
14
15
  // YamuxSession multiplexes multiple streams over a single byte stream.
15
16
  export class YamuxSession {
@@ -92,6 +93,9 @@ export class YamuxSession {
92
93
  outboundFrameBytes() {
93
94
  return this.limits.preferredOutboundFrameBytes;
94
95
  }
96
+ streamWriteQueueBytes() {
97
+ return this.limits.maxStreamWriteQueueBytes;
98
+ }
95
99
  releaseReceiveBytes(bytes) {
96
100
  this.sessionReceiveBytes = Math.max(0, this.sessionReceiveBytes - Math.max(0, bytes));
97
101
  }
@@ -436,6 +440,7 @@ function normalizeYamuxLimits(input) {
436
440
  preferredOutboundFrameBytes: input?.preferredOutboundFrameBytes ?? Math.min(DEFAULT_YAMUX_LIMITS.preferredOutboundFrameBytes, maxFrameBytes),
437
441
  maxStreamReceiveBytes: input?.maxStreamReceiveBytes ?? DEFAULT_YAMUX_LIMITS.maxStreamReceiveBytes,
438
442
  maxSessionReceiveBytes: input?.maxSessionReceiveBytes ?? DEFAULT_YAMUX_LIMITS.maxSessionReceiveBytes,
443
+ maxStreamWriteQueueBytes: input?.maxStreamWriteQueueBytes ?? DEFAULT_YAMUX_LIMITS.maxStreamWriteQueueBytes,
439
444
  };
440
445
  for (const [name, value] of Object.entries(limits)) {
441
446
  if (!Number.isSafeInteger(value) || value <= 0)
@@ -13,6 +13,7 @@ export declare class YamuxStream {
13
13
  private error;
14
14
  private resetTask;
15
15
  private writeChain;
16
+ private writeQueueBytes;
16
17
  private finalized;
17
18
  constructor(session: YamuxSession, id: number, state: StreamState);
18
19
  open(): Promise<void>;
@@ -1,6 +1,7 @@
1
1
  import { concatBytes } from "../utils/bin.js";
2
2
  import { encodeHeader } from "./header.js";
3
3
  import { DEFAULT_MAX_STREAM_WINDOW, FLAG_ACK, FLAG_FIN, FLAG_RST, FLAG_SYN, TYPE_DATA, TYPE_WINDOW_UPDATE } from "./constants.js";
4
+ import { YamuxResourceExhaustedError } from "./errors.js";
4
5
  // YamuxStream manages per-stream flow control and state transitions.
5
6
  export class YamuxStream {
6
7
  // Stream identifier within the session.
@@ -24,6 +25,7 @@ export class YamuxStream {
24
25
  error = null;
25
26
  resetTask;
26
27
  writeChain = Promise.resolve();
28
+ writeQueueBytes = 0;
27
29
  finalized = false;
28
30
  constructor(session, id, state) {
29
31
  this.session = session;
@@ -87,10 +89,23 @@ export class YamuxStream {
87
89
  }
88
90
  // write sends DATA frames, respecting the send window.
89
91
  async write(data) {
90
- const payload = data.slice();
91
- const write = this.writeChain.then(() => this.writeSerial(payload));
92
- this.writeChain = write.catch(() => { });
93
- await write;
92
+ this.ensureWritable();
93
+ const byteCount = data.byteLength;
94
+ const nextQueueBytes = this.writeQueueBytes + byteCount;
95
+ const limit = this.session.streamWriteQueueBytes();
96
+ if (!Number.isSafeInteger(nextQueueBytes) || nextQueueBytes > limit) {
97
+ throw new YamuxResourceExhaustedError("stream_write_queue_bytes", nextQueueBytes, limit);
98
+ }
99
+ this.writeQueueBytes = nextQueueBytes;
100
+ try {
101
+ const payload = data.slice();
102
+ const write = this.writeChain.then(() => this.writeSerial(payload));
103
+ this.writeChain = write.catch(() => { });
104
+ await write;
105
+ }
106
+ finally {
107
+ this.writeQueueBytes = Math.max(0, this.writeQueueBytes - byteCount);
108
+ }
94
109
  }
95
110
  async writeSerial(data) {
96
111
  this.ensureWritable();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@floegence/flowersec-core",
3
- "version": "0.20.2",
3
+ "version": "0.21.1",
4
4
  "description": "Flowersec core TypeScript library (browser-friendly E2EE + multiplexing over WebSocket).",
5
5
  "license": "MIT",
6
6
  "repository": {