@floegence/flowersec-core 2.4.1 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -65,6 +65,20 @@ const session = await controller.waitForSession();
65
65
  The immutable callback definition applies to every generation, while each
66
66
  Session gets a fresh router. Terminated Session work is never replayed.
67
67
 
68
+ ### Application streams on any Session
69
+
70
+ ```ts
71
+ import { StreamHandlers } from "@floegence/flowersec-core";
72
+
73
+ const streamHandlers = new StreamHandlers({ maxConcurrentStreams: 32 });
74
+ streamHandlers.handleStream("files/read", async (incoming) => serveFile(incoming));
75
+ await streamHandlers.serve(session);
76
+ ```
77
+
78
+ The portable root, browser, and Node entrypoints share this dispatcher. The
79
+ sealed registrar used by Node `ProxyServer.register(...)` is exported only from
80
+ the Node entrypoint.
81
+
68
82
  For the complete durable `ArtifactLease` spend workflow, see the
69
83
  [TypeScript cookbook](../examples/ts/README.md). Node raw-QUIC-only artifacts
70
84
  may omit `origin`; providing an absolute HTTP(S) origin enables WebSocket
@@ -98,7 +112,7 @@ The Browser and Node `connect(...)` operations are one-shot and never reconnect.
98
112
 
99
113
  The controller has one scheduler and one in-flight attempt. Its states are `idle`, `connecting`, `connected`, `waiting`, `failed`, and `closed`; immutable snapshots expose `ConnectionSnapshot.retryDisposition` while the corresponding retry decision applies and clear it before a new attempt, after connection, and on close. Call `start()` once, observe snapshots with `subscribe(...)`, await an established session with `waitForSession(...)`, and use `retryNow()` only to wake a `waiting` controller. `close()` cancels acquisition, connection, and waiting before closing the current session.
100
114
 
101
- Node `SessionHandlers` accept application stream kinds whose UTF-8 encoding is 1 through 255 bytes and reserve `flowersec.rpc.v2` for Flowersec RPC. `AcceptedSession.serve(...)` half-closes successfully handled streams. A rejected handler Promise resets only that stream; the accept loop and unrelated streams continue.
115
+ `StreamHandlers` and Node `SessionHandlers` accept application stream kinds containing 1 through 128 canonical UTF-8 bytes, reject leading or trailing Unicode whitespace, controls, and unassigned scalars, and reserve `flowersec.rpc.v2` for Flowersec RPC. Successful handlers half-close their stream. A rejected handler Promise resets only that stream; the accept loop and unrelated streams continue.
102
116
 
103
117
  Reliable streams apply bounded per-stream receive backpressure instead of buffering application data without limit. A slow consumer pauses carrier progress until reads release capacity; records retain carrier order, so a rekey behind backpressured DATA completes after the consumer resumes. `closeWrite()` sends the graceful FIN and keeps reads available. `reset()` and `close()` abort both directions. If a write is canceled or fails after its wire commit may have started, only that stream becomes terminal and cannot be reused.
104
118
 
package/dist/facade.d.ts CHANGED
@@ -2,6 +2,8 @@ export type { ByteStream, IncomingStream, JsonObject, JsonPrimitive, JsonValue,
2
2
  export { SessionError, UnreliableMessageError } from "./public/contract.js";
3
3
  export { createStreamMetadata, StreamMetadataError } from "./public/streamMetadata.js";
4
4
  export type { StreamMetadata } from "./public/streamMetadata.js";
5
+ export { HandlerRegistrationError, StreamHandlers, } from "./public/streamHandlers.js";
6
+ export type { StreamHandler, StreamHandlerOptions, } from "./public/streamHandlers.js";
5
7
  export { ArtifactLeaseError, createArtifactLease, } from "./public/artifactLease.js";
6
8
  export type { ArtifactLease } from "./public/artifactLease.js";
7
9
  export { Artifact, ArtifactError, parseArtifact } from "./public/artifact.js";
package/dist/facade.js CHANGED
@@ -1,5 +1,6 @@
1
1
  export { SessionError, UnreliableMessageError } from "./public/contract.js";
2
2
  export { createStreamMetadata, StreamMetadataError } from "./public/streamMetadata.js";
3
+ export { HandlerRegistrationError, StreamHandlers, } from "./public/streamHandlers.js";
3
4
  export { ArtifactLeaseError, createArtifactLease, } from "./public/artifactLease.js";
4
5
  export { Artifact, ArtifactError, parseArtifact } from "./public/artifact.js";
5
6
  export { ConnectError } from "./public/connectError.js";
@@ -2,7 +2,7 @@ import { readU32be, u32be } from "../utils/bin.js";
2
2
  import { SDK_DEFAULTS } from "../defaults.js";
3
3
  export const DEFAULT_MAX_JSON_FRAME_BYTES = SDK_DEFAULTS.rpc.maxJsonFrameBytes;
4
4
  const te = new TextEncoder();
5
- const td = new TextDecoder();
5
+ const td = new TextDecoder("utf-8", { fatal: true });
6
6
  // JsonFramingError marks malformed or oversized frames.
7
7
  export class JsonFramingError extends Error {
8
8
  }
@@ -29,5 +29,12 @@ export async function readJsonFrame(readExactly, maxBytes) {
29
29
  if (maxBytes > 0 && n > maxBytes)
30
30
  throw new JsonFramingError("frame too large");
31
31
  const payload = await read(n);
32
- return JSON.parse(td.decode(payload));
32
+ let text;
33
+ try {
34
+ text = td.decode(payload);
35
+ }
36
+ catch {
37
+ throw new JsonFramingError("invalid UTF-8");
38
+ }
39
+ return JSON.parse(text);
33
40
  }
@@ -1,6 +1,9 @@
1
- import { type IncomingStream, type JsonValue, type OperationOptions, type Session } from "../public/contract.js";
1
+ import { type JsonValue, type OperationOptions, type Session } from "../public/contract.js";
2
2
  import { type DirectAuthorizationDecision, type RuntimeAuthorizationRequest } from "./controlplane.js";
3
+ import { type StreamHandler, type StreamHandlerOptions } from "../public/streamHandlers.js";
3
4
  export type { RuntimeAuthorizationRequest } from "./controlplane.js";
5
+ export { HandlerRegistrationError } from "../public/streamHandlers.js";
6
+ export type { StreamHandler } from "../public/streamHandlers.js";
4
7
  export type AuthorizationDecision = DirectAuthorizationDecision;
5
8
  export type RPCHandlerResult = Readonly<{
6
9
  payload: JsonValue;
@@ -16,20 +19,14 @@ export type RPCHandler = (payload: JsonValue, request: Readonly<{
16
19
  export type NotificationHandler = (payload: JsonValue, request: Readonly<{
17
20
  typeId: number;
18
21
  }>) => Promise<void> | void;
19
- export type StreamHandler = (incoming: IncomingStream, options: OperationOptions) => Promise<void>;
20
- export type SessionHandlerOptions = Readonly<{
21
- maxConcurrentStreams?: number;
22
- }>;
23
- export declare class HandlerRegistrationError extends Error {
24
- readonly code: "invalid_handler" | "already_registered" | "frozen";
25
- constructor(code: "invalid_handler" | "already_registered" | "frozen");
26
- }
22
+ export type SessionHandlerOptions = StreamHandlerOptions;
27
23
  export declare class RPCHandlers {
28
24
  constructor();
29
25
  handleRPC(typeId: number, handler: RPCHandler): void;
30
26
  handleNotification(typeId: number, handler: NotificationHandler): void;
31
27
  }
32
28
  export declare class SessionHandlers {
29
+ private readonly streamHandlerRegistrarBrand;
33
30
  constructor(options?: SessionHandlerOptions);
34
31
  handleRPC(typeId: number, handler: RPCHandler): void;
35
32
  handleNotification(typeId: number, handler: NotificationHandler): void;
@@ -1,4 +1,5 @@
1
1
  import { RpcRouter } from "../rpc/server.js";
2
+ import { assertRpcError } from "../rpc/validate.js";
2
3
  import { acceptReceivedSessionV2, receiveSessionAdmissionV2, rejectSessionAdmissionV2, } from "../connector/sessionAcceptor.js";
3
4
  import { nodeSessionRuntimeV2 } from "./sessionRuntime.js";
4
5
  import { startNodeWebSocketServer, } from "./webSocketServer.js";
@@ -8,18 +9,9 @@ import { unwrapArtifact } from "../public/artifact.js";
8
9
  import { SessionError, } from "../public/contract.js";
9
10
  import { projectSessionV2 } from "../v2/publicSession.js";
10
11
  import { runtimeAuthorizationRequestFromDecoded, } from "./controlplane.js";
11
- const DEFAULT_MAX_CONCURRENT_STREAMS = 64;
12
- const MAX_CONCURRENT_STREAMS = 128;
12
+ import { HandlerRegistrationError, StreamHandlers, freezeStreamHandlers, registerStreamHandlersAtomically, serveFrozenStreamHandlers, } from "../public/streamHandlers.js";
13
+ export { HandlerRegistrationError } from "../public/streamHandlers.js";
13
14
  const DEFAULT_CLEANUP_TIMEOUT_MS = 2_000;
14
- const encoder = new TextEncoder();
15
- export class HandlerRegistrationError extends Error {
16
- code;
17
- constructor(code) {
18
- super(`Flowersec handler registration failed (code=${code})`);
19
- this.code = code;
20
- this.name = "HandlerRegistrationError";
21
- }
22
- }
23
15
  export class RPCHandlers {
24
16
  constructor() {
25
17
  rpcHandlerStates.set(this, createRPCHandlerState());
@@ -33,16 +25,9 @@ export class RPCHandlers {
33
25
  }
34
26
  export class SessionHandlers {
35
27
  constructor(options = {}) {
36
- const maximum = options.maxConcurrentStreams ?? DEFAULT_MAX_CONCURRENT_STREAMS;
37
- if (!Number.isSafeInteger(maximum) ||
38
- maximum < 1 ||
39
- maximum > MAX_CONCURRENT_STREAMS) {
40
- throw new HandlerRegistrationError("invalid_handler");
41
- }
42
28
  sessionHandlerStates.set(this, {
43
- maxConcurrentStreams: maximum,
44
29
  rpc: createRPCHandlerState(),
45
- streams: new Map(),
30
+ streams: new StreamHandlers(options),
46
31
  frozen: false,
47
32
  });
48
33
  }
@@ -56,15 +41,7 @@ export class SessionHandlers {
56
41
  }
57
42
  handleStream(kind, handler) {
58
43
  const state = mutableSessionHandlerState(this);
59
- if (kind.length < 1 ||
60
- encoder.encode(kind).length > 255 ||
61
- kind === "flowersec.rpc.v2" ||
62
- typeof handler !== "function") {
63
- throw new HandlerRegistrationError("invalid_handler");
64
- }
65
- if (state.streams.has(kind))
66
- throw new HandlerRegistrationError("already_registered");
67
- state.streams.set(kind, handler);
44
+ state.streams.handleStream(kind, handler);
68
45
  }
69
46
  }
70
47
  const rpcHandlerStates = new WeakMap();
@@ -88,6 +65,11 @@ function mutableSessionHandlerState(handlers) {
88
65
  throw new HandlerRegistrationError("frozen");
89
66
  return state;
90
67
  }
68
+ /** @internal */
69
+ export function registerSessionStreamHandlersAtomically(handlers, entries) {
70
+ const state = mutableSessionHandlerState(handlers);
71
+ registerStreamHandlersAtomically(state.streams, entries);
72
+ }
91
73
  function registerRPC(state, typeId, handler) {
92
74
  validateRPCRegistration(typeId, handler);
93
75
  if (state.requests.has(typeId) || state.notifications.has(typeId)) {
@@ -161,63 +143,18 @@ function freezeSessionHandlers(handlers) {
161
143
  state.frozen = true;
162
144
  state.snapshot = Object.freeze({
163
145
  rpc: freezeRPCHandlerState(state.rpc),
164
- maxConcurrentStreams: state.maxConcurrentStreams,
165
- streams: new Map(state.streams),
146
+ streams: freezeStreamHandlers(state.streams),
166
147
  });
167
148
  return state.snapshot;
168
149
  }
169
- /** @internal */
170
- export function registerSessionStreamsAtomically(handlers, entries) {
171
- const state = mutableSessionHandlerState(handlers);
172
- const pending = new Set();
173
- for (const [kind, handler] of entries) {
174
- if (kind.length < 1 ||
175
- encoder.encode(kind).length > 255 ||
176
- kind === "flowersec.rpc.v2" ||
177
- typeof handler !== "function")
178
- throw new HandlerRegistrationError("invalid_handler");
179
- if (state.streams.has(kind) || pending.has(kind))
180
- throw new HandlerRegistrationError("already_registered");
181
- pending.add(kind);
182
- }
183
- for (const [kind, handler] of entries)
184
- state.streams.set(kind, handler);
185
- }
186
150
  export class AcceptedSession {
187
151
  constructor() { }
188
152
  get session() {
189
153
  return acceptedSessionState(this).session;
190
154
  }
191
155
  async serve(options = {}) {
192
- const active = new Set();
193
156
  const state = acceptedSessionState(this);
194
- try {
195
- while (true) {
196
- if (options.signal?.aborted)
197
- throw new SessionError("canceled");
198
- const incoming = await this.session.acceptStream(options);
199
- const handler = state.handlers.streams.get(incoming.kind);
200
- if (handler === undefined ||
201
- active.size >= state.handlers.maxConcurrentStreams) {
202
- await incoming.stream.reset();
203
- continue;
204
- }
205
- const task = (async () => {
206
- try {
207
- await handler(incoming, options);
208
- await incoming.stream.closeWrite();
209
- }
210
- catch {
211
- await incoming.stream.reset().catch(() => undefined);
212
- }
213
- })().finally(() => active.delete(task));
214
- active.add(task);
215
- }
216
- }
217
- finally {
218
- await this.close().catch(() => undefined);
219
- await Promise.allSettled(active);
220
- }
157
+ await serveFrozenStreamHandlers(state.handlers.streams, this.session, options, async () => await this.close());
221
158
  }
222
159
  async close() {
223
160
  const state = acceptedSessionState(this);
@@ -264,11 +201,27 @@ export class Acceptor {
264
201
  }
265
202
  async close() {
266
203
  const state = acceptorState(this);
267
- state.abort.abort();
268
- state.accepted.close();
269
- await Promise.all(state.listeners.map(async (listener) => await listener.close()));
270
- await boundedCleanup(state.tasks, state.cleanupTimeoutMs);
271
- state.tasks.clear();
204
+ if (state.lifecycle.completion === undefined) {
205
+ state.lifecycle.completion = closeAcceptor(state);
206
+ }
207
+ await withCleanupTimeout(state.lifecycle.completion, state.cleanupTimeoutMs);
208
+ }
209
+ }
210
+ const MAX_CONCURRENT_ADMISSIONS = 64;
211
+ const MAX_PENDING_ACCEPTED_SESSIONS = 64;
212
+ async function closeAcceptor(state) {
213
+ state.abort.abort();
214
+ const queued = state.accepted.close();
215
+ const cleanup = await Promise.allSettled([
216
+ ...state.listeners.map(async (listener) => await listener.close()),
217
+ ...queued.map(async (accepted) => await accepted.close()),
218
+ ]);
219
+ while (state.tasks.size > 0) {
220
+ await Promise.allSettled([...state.tasks]);
221
+ }
222
+ if (cleanup.some((result) => result.status === "rejected") ||
223
+ state.lifecycle.cleanupFailed) {
224
+ throw new SessionError("operation_failed");
272
225
  }
273
226
  }
274
227
  async function releaseLease(state, leaseId) {
@@ -280,7 +233,7 @@ async function authorizeCarrier(state, carrier) {
280
233
  const received = await receiveSessionAdmissionV2(carrier, state.abort.signal);
281
234
  const decoded = received.decoded;
282
235
  const request = runtimeAuthorizationRequestFromDecoded(decoded);
283
- const decision = await abortableCallback(state.options.authorize(request, { signal: state.abort.signal }), state.abort.signal);
236
+ const decision = await state.options.authorize(request, { signal: state.abort.signal });
284
237
  if (decision.decision !== "allow") {
285
238
  return await rejectSessionAdmissionV2(received, {
286
239
  accepted: false,
@@ -294,9 +247,7 @@ async function authorizeCarrier(state, carrier) {
294
247
  try {
295
248
  const registry = state.options.resolveHandlers === undefined
296
249
  ? new SessionHandlers()
297
- : await abortableCallback(Promise.resolve(state.options.resolveHandlers(request, {
298
- signal: state.abort.signal,
299
- })), state.abort.signal);
250
+ : await state.options.resolveHandlers(request, { signal: state.abort.signal });
300
251
  const handlers = freezeSessionHandlers(registry);
301
252
  const leg = {
302
253
  received,
@@ -326,28 +277,50 @@ async function processCarrier(state, carrier) {
326
277
  const leg = await authorizeCarrier(state, carrier);
327
278
  if (leg === undefined)
328
279
  return;
280
+ let releaseOwnedByAcceptedSession = false;
329
281
  try {
330
282
  if (leg.received.decoded.request.pathKind !== "direct" ||
331
283
  leg.artifact.path.kind !== "direct") {
332
284
  await leg.received.carrier.close().catch(() => undefined);
333
285
  throw new SessionError("operation_failed");
334
286
  }
335
- state.accepted.push(await establishDirect(leg, state.abort.signal, leg.leaseId === undefined ? undefined : () => releaseLease(state, leg.leaseId)));
287
+ const accepted = await establishDirect(leg, state.abort.signal, leg.leaseId === undefined ? undefined : () => releaseLease(state, leg.leaseId));
288
+ releaseOwnedByAcceptedSession = true;
289
+ if (state.accepted.push(accepted) === "rejected") {
290
+ try {
291
+ await accepted.close();
292
+ }
293
+ catch {
294
+ state.lifecycle.cleanupFailed = true;
295
+ throw new SessionError("operation_failed");
296
+ }
297
+ }
336
298
  }
337
299
  catch (error) {
338
- await releaseLease(state, leg.leaseId).catch(() => undefined);
300
+ if (!releaseOwnedByAcceptedSession) {
301
+ await releaseLease(state, leg.leaseId).catch(() => undefined);
302
+ }
339
303
  throw error;
340
304
  }
341
305
  }
342
306
  async function runAcceptLoop(state) {
343
307
  while (!state.abort.signal.aborted) {
344
308
  try {
309
+ while (state.admissions.size >= MAX_CONCURRENT_ADMISSIONS) {
310
+ await Promise.race(state.admissions);
311
+ if (state.abort.signal.aborted)
312
+ return;
313
+ }
345
314
  const carrier = await state.accept({ signal: state.abort.signal });
346
315
  const task = processCarrier(state, carrier)
347
316
  .catch(async () => {
348
317
  await carrier.close().catch(() => undefined);
349
318
  })
350
- .finally(() => state.tasks.delete(task));
319
+ .finally(() => {
320
+ state.admissions.delete(task);
321
+ state.tasks.delete(task);
322
+ });
323
+ state.admissions.add(task);
351
324
  state.tasks.add(task);
352
325
  }
353
326
  catch (error) {
@@ -362,16 +335,19 @@ class AcceptedQueue {
362
335
  waiters = new Set();
363
336
  failure;
364
337
  push(value) {
365
- if (this.failure !== undefined) {
366
- void value.close();
367
- return;
368
- }
338
+ if (this.failure !== undefined)
339
+ return "rejected";
369
340
  const waiter = this.waiters.values().next().value;
370
- if (waiter === undefined)
341
+ if (waiter === undefined) {
342
+ if (this.values.length >= MAX_PENDING_ACCEPTED_SESSIONS)
343
+ return "rejected";
371
344
  this.values.push(value);
345
+ return "queued";
346
+ }
372
347
  else {
373
348
  this.waiters.delete(waiter);
374
349
  waiter.resolve(value);
350
+ return "delivered";
375
351
  }
376
352
  }
377
353
  async shift(signal) {
@@ -412,6 +388,7 @@ class AcceptedQueue {
412
388
  }
413
389
  close() {
414
390
  this.fail(new SessionError("closed"));
391
+ return this.values.splice(0);
415
392
  }
416
393
  }
417
394
  const acceptorStates = new WeakMap();
@@ -482,6 +459,7 @@ export async function createAcceptor(options) {
482
459
  const accepted = new AcceptedQueue();
483
460
  const abort = new AbortController();
484
461
  const tasks = new Set();
462
+ const admissions = new Set();
485
463
  let started = false;
486
464
  const state = {
487
465
  listeners,
@@ -490,7 +468,9 @@ export async function createAcceptor(options) {
490
468
  accepted,
491
469
  abort,
492
470
  tasks,
471
+ admissions,
493
472
  cleanupTimeoutMs,
473
+ lifecycle: { cleanupFailed: false },
494
474
  start() {
495
475
  if (started)
496
476
  return;
@@ -502,52 +482,26 @@ export async function createAcceptor(options) {
502
482
  acceptorStates.set(acceptor, state);
503
483
  return Object.freeze(acceptor);
504
484
  }
505
- async function boundedCleanup(tasks, timeoutMs) {
506
- await Promise.race([
507
- Promise.allSettled([...tasks]).then(() => undefined),
508
- new Promise((resolve) => setTimeout(resolve, timeoutMs)),
509
- ]);
510
- }
511
- async function abortableCallback(promise, signal) {
512
- if (signal.aborted)
513
- throw signal.reason instanceof Error ? signal.reason : new SessionError("closed");
514
- return await new Promise((resolve, reject) => {
515
- let settled = false;
516
- const cleanup = () => signal.removeEventListener("abort", abort);
517
- const abort = () => {
518
- if (settled)
519
- return;
520
- settled = true;
521
- cleanup();
522
- reject(signal.reason instanceof Error ? signal.reason : new SessionError("closed"));
523
- };
524
- signal.addEventListener("abort", abort, { once: true });
525
- void promise.then((value) => {
526
- if (settled)
527
- return;
528
- settled = true;
529
- cleanup();
530
- resolve(value);
531
- }, (error) => {
532
- if (settled)
533
- return;
534
- settled = true;
535
- cleanup();
536
- reject(error);
537
- });
538
- });
485
+ async function withCleanupTimeout(completion, timeoutMs) {
486
+ let timer;
487
+ try {
488
+ await Promise.race([
489
+ completion,
490
+ new Promise((_, reject) => {
491
+ timer = setTimeout(() => reject(new SessionError("timeout")), timeoutMs);
492
+ }),
493
+ ]);
494
+ }
495
+ finally {
496
+ if (timer !== undefined)
497
+ clearTimeout(timer);
498
+ }
539
499
  }
540
500
  function validRPCError(error) {
541
- if (!Number.isSafeInteger(error.code) ||
542
- error.code < 1 ||
543
- error.code > 0xffff_ffff) {
544
- return { code: 500, message: "handler failed" };
501
+ try {
502
+ return assertRpcError(error);
545
503
  }
546
- if (error.message !== undefined &&
547
- encoder.encode(error.message).length > 1024) {
504
+ catch {
548
505
  return { code: 500, message: "handler failed" };
549
506
  }
550
- return error.message === undefined
551
- ? { code: error.code }
552
- : { code: error.code, message: error.message };
553
507
  }
@@ -1,4 +1,4 @@
1
- import { randomBytes as nodeRandomBytes } from "node:crypto";
1
+ import { randomBytes as nodeRandomBytes, timingSafeEqual } from "node:crypto";
2
2
  import { buildFSB2RequestV2, canonicalizeCandidatesV2, computeSessionContractHashV2, decodeArtifactV2JSON, decodeFSB2RequestV2, encodeArtifactV2JSON, encodeFSB2RequestV2, } from "../v2/artifact.js";
3
3
  import { wrapArtifact } from "../public/artifact.js";
4
4
  import { base64urlDecode, base64urlEncode } from "../utils/base64url.js";
@@ -413,7 +413,11 @@ function validTCPAddress(value) {
413
413
  return port >= 1 && port <= 65_535;
414
414
  }
415
415
  function validObservedText(value) { return value.length > 0 && value.length <= 512 && !/[\u0000-\u001f\u007f]/u.test(value); }
416
- function bytesEqual(a, b) { return a.length === b.length && a.every((value, index) => value === b[index]); }
416
+ function bytesEqual(a, b) {
417
+ if (a.length !== b.length)
418
+ return false;
419
+ return timingSafeEqual(a, b);
420
+ }
417
421
  function strictObject(bytes) {
418
422
  let value;
419
423
  try {
@@ -1,11 +1,11 @@
1
1
  export { connect, createConnectionController } from "./connectSession.js";
2
2
  export { AcceptedSession, Acceptor, HandlerRegistrationError, RPCHandlers, SessionHandlers, createAcceptor, } from "./acceptor.js";
3
- export type { AcceptorListener, AcceptorOptions, AuthorizationDecision, RPCHandler, RPCHandlerResult, NotificationHandler, SessionHandlerOptions, StreamHandler, } from "./acceptor.js";
3
+ export type { AcceptorListener, AcceptorOptions, AuthorizationDecision, RPCHandler, RPCHandlerResult, NotificationHandler, SessionHandlerOptions, } from "./acceptor.js";
4
4
  export { TunnelRuntime, createTunnelRuntime } from "./tunnelRuntime.js";
5
5
  export type { TunnelAuthorizationDecision, TunnelRuntimeListener, TunnelRuntimeOptions, } from "./tunnelRuntime.js";
6
6
  export type { ConnectionControllerOptions, SessionOptions, SessionTLSOptions, } from "./connectSession.js";
7
7
  export { AuthorizationRecord, AuthorizationResponse, TunnelAuthorizationResponse, ControlPlaneError, EndpointSet, IssuedArtifact, Issuer, RuntimeAuthorizationRequest, authorizeRuntime, authorizeTunnelRuntime, createEndpointSet, parseAuthorizationRecord, parseRuntimeAuthorizationRequest, rejectRuntime, rejectTunnelRuntime, } from "./controlplane.js";
8
8
  export type { ArtifactMetadata, ControlPlaneErrorCode, DirectIssueOptions, IssuedTunnelPair, Scope, SessionOptions as ControlPlaneSessionOptions, TunnelIssueOptions, } from "./controlplane.js";
9
9
  export { ProxyServer, ProxyServerError } from "./proxyServer.js";
10
- export type { ProxyServerOptions } from "./proxyServer.js";
10
+ export type { ProxyServerOptions, StreamHandlerRegistrar } from "./proxyServer.js";
11
11
  export * from "../facade.js";
@@ -1,4 +1,6 @@
1
+ import { StreamHandlers } from "../public/streamHandlers.js";
1
2
  import { SessionHandlers } from "./acceptor.js";
3
+ export type StreamHandlerRegistrar = StreamHandlers | SessionHandlers;
2
4
  export type ProxyServerOptions = Readonly<{
3
5
  upstream: string;
4
6
  upstreamOrigin: string;
@@ -26,6 +28,6 @@ export declare class ProxyServerError extends Error {
26
28
  export declare class ProxyServer {
27
29
  #private;
28
30
  constructor(options: ProxyServerOptions);
29
- register(handlers: SessionHandlers): void;
31
+ register(handlers: StreamHandlerRegistrar): void;
30
32
  close(): Promise<void>;
31
33
  }
@@ -2,7 +2,8 @@ import { createRequire } from "node:module";
2
2
  import { SessionError } from "../public/contract.js";
3
3
  import { writeJsonFrame } from "../framing/jsonframe.js";
4
4
  import { ProxyByteReader, writeAll } from "../proxy/stream.js";
5
- import { registerSessionStreamsAtomically, SessionHandlers, } from "./acceptor.js";
5
+ import { StreamHandlers, registerStreamHandlersAtomically, } from "../public/streamHandlers.js";
6
+ import { SessionHandlers, registerSessionStreamHandlersAtomically, } from "./acceptor.js";
6
7
  const HTTP_KIND = "flowersec-proxy/http1";
7
8
  const WS_KIND = "flowersec-proxy/ws";
8
9
  const WIRE_VERSION = 1;
@@ -38,13 +39,20 @@ export class ProxyServer {
38
39
  register(handlers) {
39
40
  if (this.#closed)
40
41
  throw new ProxyServerError("closed");
41
- if (!(handlers instanceof SessionHandlers))
42
- throw new ProxyServerError("handler_registration");
43
42
  try {
44
- registerSessionStreamsAtomically(handlers, [
43
+ const registrations = [
45
44
  [HTTP_KIND, this.#httpHandler()],
46
45
  [WS_KIND, this.#webSocketHandler()],
47
- ]);
46
+ ];
47
+ if (handlers instanceof StreamHandlers) {
48
+ registerStreamHandlersAtomically(handlers, registrations);
49
+ }
50
+ else if (handlers instanceof SessionHandlers) {
51
+ registerSessionStreamHandlersAtomically(handlers, registrations);
52
+ }
53
+ else {
54
+ throw new TypeError("invalid Flowersec stream handler registrar");
55
+ }
48
56
  }
49
57
  catch (error) {
50
58
  this.#report(error);
@@ -36,13 +36,6 @@ export async function commitArtifactLeaseSpend(lease, signal) {
36
36
  const leaseState = artifactLeaseStates.get(lease);
37
37
  if (leaseState === undefined || leaseState.state !== "idle")
38
38
  throw new ArtifactLeaseError();
39
- leaseState.state = "spending";
40
- try {
41
- await leaseState.commitSpend(signal);
42
- leaseState.state = "consumed";
43
- }
44
- catch (error) {
45
- leaseState.state = "idle";
46
- throw error;
47
- }
39
+ leaseState.state = "consumed";
40
+ await leaseState.commitSpend(signal);
48
41
  }
@@ -0,0 +1,20 @@
1
+ import { type IncomingStream, type OperationOptions, type Session } from "./contract.js";
2
+ export type StreamHandler = (incoming: IncomingStream, options: OperationOptions) => Promise<void>;
3
+ export type StreamHandlerOptions = Readonly<{
4
+ maxConcurrentStreams?: number;
5
+ }>;
6
+ export declare class HandlerRegistrationError extends Error {
7
+ readonly code: "invalid_handler" | "already_registered" | "frozen";
8
+ constructor(code: "invalid_handler" | "already_registered" | "frozen");
9
+ }
10
+ export type FrozenStreamHandlers = Readonly<{
11
+ maxConcurrentStreams: number;
12
+ streams: ReadonlyMap<string, StreamHandler>;
13
+ }>;
14
+ /** Carrier-neutral application-stream handlers for any established Session. */
15
+ export declare class StreamHandlers {
16
+ private readonly streamHandlerRegistrarBrand;
17
+ constructor(options?: StreamHandlerOptions);
18
+ handleStream(kind: string, handler: StreamHandler): void;
19
+ serve(session: Session, options?: OperationOptions): Promise<void>;
20
+ }
@@ -0,0 +1,124 @@
1
+ import { SessionError, } from "./contract.js";
2
+ import { validApplicationStreamKind } from "../v2/protocol.js";
3
+ const DEFAULT_MAX_CONCURRENT_STREAMS = 64;
4
+ const MAX_CONCURRENT_STREAMS = 128;
5
+ export class HandlerRegistrationError extends Error {
6
+ code;
7
+ constructor(code) {
8
+ super(`Flowersec handler registration failed (code=${code})`);
9
+ this.code = code;
10
+ this.name = "HandlerRegistrationError";
11
+ }
12
+ }
13
+ const streamHandlerStates = new WeakMap();
14
+ /** Carrier-neutral application-stream handlers for any established Session. */
15
+ export class StreamHandlers {
16
+ constructor(options = {}) {
17
+ const maximum = options.maxConcurrentStreams ?? DEFAULT_MAX_CONCURRENT_STREAMS;
18
+ if (!Number.isSafeInteger(maximum) ||
19
+ maximum < 1 ||
20
+ maximum > MAX_CONCURRENT_STREAMS) {
21
+ throw new HandlerRegistrationError("invalid_handler");
22
+ }
23
+ streamHandlerStates.set(this, {
24
+ maxConcurrentStreams: maximum,
25
+ streams: new Map(),
26
+ frozen: false,
27
+ });
28
+ }
29
+ handleStream(kind, handler) {
30
+ registerStreamHandlersAtomically(this, [[kind, handler]]);
31
+ }
32
+ async serve(session, options = {}) {
33
+ if (session === null || typeof session !== "object") {
34
+ throw new HandlerRegistrationError("invalid_handler");
35
+ }
36
+ await serveFrozenStreamHandlers(freezeStreamHandlers(this), session, options, async () => await session.close());
37
+ }
38
+ }
39
+ function mutableStreamHandlerState(handlers) {
40
+ const state = streamHandlerStates.get(handlers);
41
+ if (state === undefined)
42
+ throw new HandlerRegistrationError("invalid_handler");
43
+ if (state.frozen)
44
+ throw new HandlerRegistrationError("frozen");
45
+ return state;
46
+ }
47
+ function registerIntoState(state, entries) {
48
+ if (entries.length === 0)
49
+ throw new HandlerRegistrationError("invalid_handler");
50
+ const pending = new Set();
51
+ for (const [kind, handler] of entries) {
52
+ if (!validApplicationStreamKind(kind) ||
53
+ kind === "flowersec.rpc.v2" ||
54
+ typeof handler !== "function") {
55
+ throw new HandlerRegistrationError("invalid_handler");
56
+ }
57
+ if (state.streams.has(kind) || pending.has(kind)) {
58
+ throw new HandlerRegistrationError("already_registered");
59
+ }
60
+ pending.add(kind);
61
+ }
62
+ for (const [kind, handler] of entries)
63
+ state.streams.set(kind, handler);
64
+ }
65
+ /** @internal */
66
+ export function registerStreamHandlersAtomically(handlers, entries) {
67
+ registerIntoState(mutableStreamHandlerState(handlers), entries);
68
+ }
69
+ /** @internal */
70
+ export function freezeStreamHandlers(handlers) {
71
+ const state = streamHandlerStates.get(handlers);
72
+ if (state === undefined)
73
+ throw new HandlerRegistrationError("invalid_handler");
74
+ if (state.snapshot !== undefined)
75
+ return state.snapshot;
76
+ state.frozen = true;
77
+ state.snapshot = Object.freeze({
78
+ maxConcurrentStreams: state.maxConcurrentStreams,
79
+ streams: new Map(state.streams),
80
+ });
81
+ return state.snapshot;
82
+ }
83
+ /** @internal */
84
+ export async function serveFrozenStreamHandlers(snapshot, session, options, close) {
85
+ const active = new Set();
86
+ const controller = new AbortController();
87
+ const abortFromCaller = () => controller.abort(options.signal?.reason);
88
+ if (options.signal?.aborted === true)
89
+ abortFromCaller();
90
+ else
91
+ options.signal?.addEventListener("abort", abortFromCaller, { once: true });
92
+ const handlerOptions = Object.freeze({
93
+ signal: controller.signal,
94
+ });
95
+ try {
96
+ while (true) {
97
+ if (controller.signal.aborted)
98
+ throw new SessionError("canceled");
99
+ const incoming = await session.acceptStream(handlerOptions);
100
+ const handler = snapshot.streams.get(incoming.kind);
101
+ if (handler === undefined ||
102
+ active.size >= snapshot.maxConcurrentStreams) {
103
+ await incoming.stream.reset();
104
+ continue;
105
+ }
106
+ const task = (async () => {
107
+ try {
108
+ await handler(incoming, handlerOptions);
109
+ await incoming.stream.closeWrite();
110
+ }
111
+ catch {
112
+ await incoming.stream.reset().catch(() => undefined);
113
+ }
114
+ })().finally(() => active.delete(task));
115
+ active.add(task);
116
+ }
117
+ }
118
+ finally {
119
+ options.signal?.removeEventListener("abort", abortFromCaller);
120
+ controller.abort(new SessionError("closed"));
121
+ await close().catch(() => undefined);
122
+ await Promise.allSettled(active);
123
+ }
124
+ }
@@ -1,5 +1,5 @@
1
1
  import { DEFAULT_MAX_JSON_FRAME_BYTES, readJsonFrame, writeJsonFrame } from "../framing/jsonframe.js";
2
- import { assertRpcEnvelope } from "./validate.js";
2
+ import { assertRpcEnvelope, assertRpcError } from "./validate.js";
3
3
  import { SDK_DEFAULTS } from "../defaults.js";
4
4
  const DEFAULT_RPC_SERVER_OPTIONS = Object.freeze({
5
5
  maxConcurrentRequests: SDK_DEFAULTS.rpc.maxConcurrentRequests,
@@ -217,12 +217,21 @@ export class RpcServer {
217
217
  waiters.shift()?.();
218
218
  }
219
219
  async writeResponse(request, out) {
220
+ let error;
221
+ if (out.error != null) {
222
+ try {
223
+ error = assertRpcError(out.error);
224
+ }
225
+ catch {
226
+ error = { code: 500, message: "internal error" };
227
+ }
228
+ }
220
229
  const resp = {
221
230
  type_id: request.type_id,
222
231
  request_id: 0,
223
232
  response_to: request.request_id,
224
233
  payload: out.payload,
225
- ...(out.error != null ? { error: out.error } : {}),
234
+ ...(error != null ? { error } : {}),
226
235
  };
227
236
  await this.writeEnvelope(resp);
228
237
  }
@@ -1,2 +1,3 @@
1
- import type { RpcEnvelope } from "./wire.js";
1
+ import type { RpcEnvelope, RpcError } from "./wire.js";
2
2
  export declare function assertRpcEnvelope(v: unknown): RpcEnvelope;
3
+ export declare function assertRpcError(value: unknown): RpcError;
@@ -1,4 +1,6 @@
1
1
  import { isSafeU32Number, isSafeU64Number } from "../utils/number.js";
2
+ const encoder = new TextEncoder();
3
+ const strictDecoder = new TextDecoder("utf-8", { fatal: true });
2
4
  // assertRpcEnvelope validates numeric fields that are u32/u64 in the IDL.
3
5
  //
4
6
  // The wire format is JSON, so JS numbers are used. For u64 we enforce the safe integer range
@@ -15,13 +17,29 @@ export function assertRpcEnvelope(v) {
15
17
  throw new Error("bad rpc envelope: response_to");
16
18
  // payload: unknown (JSON)
17
19
  if (o.error != null) {
18
- if (typeof o.error !== "object" || o.error == null)
19
- throw new Error("bad rpc envelope: error");
20
- if (!isSafeU32Number(o.error.code))
21
- throw new Error("bad rpc envelope: error.code");
22
- const msg = o.error.message;
23
- if (msg !== undefined && typeof msg !== "string")
24
- throw new Error("bad rpc envelope: error.message");
20
+ assertRpcError(o.error);
25
21
  }
26
22
  return o;
27
23
  }
24
+ export function assertRpcError(value) {
25
+ if (typeof value !== "object" || value == null)
26
+ throw new Error("bad rpc envelope: error");
27
+ const error = value;
28
+ if (Object.keys(error).some((key) => key !== "code" && key !== "message")) {
29
+ throw new Error("bad rpc envelope: error shape");
30
+ }
31
+ if (!isSafeU32Number(error.code) || error.code === 0) {
32
+ throw new Error("bad rpc envelope: error.code");
33
+ }
34
+ const message = error.message;
35
+ if (message !== undefined && typeof message !== "string") {
36
+ throw new Error("bad rpc envelope: error.message");
37
+ }
38
+ if (typeof message === "string") {
39
+ const encoded = encoder.encode(message);
40
+ if (encoded.byteLength > 1_024 || strictDecoder.decode(encoded) !== message) {
41
+ throw new Error("bad rpc envelope: error.message");
42
+ }
43
+ }
44
+ return message === undefined ? { code: error.code } : { code: error.code, message };
45
+ }
@@ -567,16 +567,21 @@ function bytesEqual(left, right) {
567
567
  }
568
568
  function validateOpenKind(value) {
569
569
  const encoded = encoder.encode(value);
570
- if (!validOpenUnicodeString(value, MAX_OPEN_KIND_BYTES, false)) {
570
+ if (!validApplicationStreamKind(value)) {
571
571
  throw new ProtocolV2Error("invalid OPEN kind");
572
572
  }
573
- const scalars = Array.from(value);
574
- if (isUnicodeWhitespace(scalars[0].codePointAt(0)) ||
575
- isUnicodeWhitespace(scalars.at(-1).codePointAt(0))) {
576
- throw new ProtocolV2Error("OPEN kind has leading or trailing Unicode whitespace");
577
- }
578
573
  return encoded;
579
574
  }
575
+ /** @internal */
576
+ export function validApplicationStreamKind(value) {
577
+ if (typeof value !== "string" ||
578
+ !validOpenUnicodeString(value, MAX_OPEN_KIND_BYTES, false)) {
579
+ return false;
580
+ }
581
+ const scalars = Array.from(value);
582
+ return !(isUnicodeWhitespace(scalars[0].codePointAt(0)) ||
583
+ isUnicodeWhitespace(scalars.at(-1).codePointAt(0)));
584
+ }
580
585
  function canonicalMetadata(raw, allowEmpty) {
581
586
  if (raw.length === 0 && allowEmpty)
582
587
  return encoder.encode("{}");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@floegence/flowersec-core",
3
- "version": "2.4.1",
3
+ "version": "2.5.0",
4
4
  "description": "Flowersec core TypeScript library for carrier-neutral encrypted sessions and multiplexed streams.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -77,7 +77,7 @@
77
77
  "ws": "^8.21.2"
78
78
  },
79
79
  "optionalDependencies": {
80
- "@floegence/flowersec-node-native": "2.4.1"
80
+ "@floegence/flowersec-node-native": "2.5.0"
81
81
  },
82
82
  "devDependencies": {
83
83
  "@playwright/test": "1.62.1",
@@ -95,5 +95,5 @@
95
95
  "vite": "^8.2.1",
96
96
  "vitest": "4.1.10"
97
97
  },
98
- "flowersecSourceCommit": "3605cb0c32551ec5bc2f799cd2e086c5c3102177"
98
+ "flowersecSourceCommit": "9838dbf4ecd425679be6ce39274dd80eb33c1ab9"
99
99
  }
@@ -1,20 +1,20 @@
1
1
  {
2
2
  "bomFormat": "CycloneDX",
3
3
  "specVersion": "1.5",
4
- "serialNumber": "urn:uuid:1b2d0f50-da64-544b-8761-aaf9dd5a7c79",
4
+ "serialNumber": "urn:uuid:aa95f71f-a09a-5de4-8522-e341d9e45e95",
5
5
  "version": 1,
6
6
  "metadata": {
7
7
  "component": {
8
8
  "type": "library",
9
9
  "name": "@floegence/flowersec-core",
10
- "version": "2.4.1",
11
- "purl": "pkg:npm/%40floegence/flowersec-core@2.4.1",
12
- "bom-ref": "pkg:npm/%40floegence/flowersec-core@2.4.1"
10
+ "version": "2.5.0",
11
+ "purl": "pkg:npm/%40floegence/flowersec-core@2.5.0",
12
+ "bom-ref": "pkg:npm/%40floegence/flowersec-core@2.5.0"
13
13
  },
14
14
  "properties": [
15
15
  {
16
16
  "name": "flowersec:source-inventory-sha256",
17
- "value": "661d332941b82dbf168619a507636c87c3d13befebe1b70ddec45674655e1941"
17
+ "value": "6e49aafbd7bd94f6524b61df3a4eed908c4df609841a553c15d155ebee6095ad"
18
18
  }
19
19
  ]
20
20
  },
@@ -190,7 +190,7 @@
190
190
  ],
191
191
  "dependencies": [
192
192
  {
193
- "ref": "pkg:npm/%40floegence/flowersec-core@2.4.1",
193
+ "ref": "pkg:npm/%40floegence/flowersec-core@2.5.0",
194
194
  "dependsOn": [
195
195
  "pkg:npm/%40noble/ciphers@2.3.0",
196
196
  "pkg:npm/%40noble/curves@2.3.0",
package/sbom/spdx.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "dataLicense": "CC0-1.0",
4
4
  "SPDXID": "SPDXRef-DOCUMENT",
5
5
  "name": "flowersec-ts",
6
- "documentNamespace": "https://github.com/floegence/flowersec/sbom/flowersec-ts/661d332941b82dbf168619a507636c87c3d13befebe1b70ddec45674655e1941",
6
+ "documentNamespace": "https://github.com/floegence/flowersec/sbom/flowersec-ts/6e49aafbd7bd94f6524b61df3a4eed908c4df609841a553c15d155ebee6095ad",
7
7
  "creationInfo": {
8
8
  "created": "1970-01-01T00:00:00Z",
9
9
  "creators": [
@@ -13,19 +13,19 @@
13
13
  "packages": [
14
14
  {
15
15
  "name": "@floegence/flowersec-core",
16
- "SPDXID": "SPDXRef-Package-4eeba6fa52c374d21b02",
17
- "versionInfo": "2.4.1",
16
+ "SPDXID": "SPDXRef-Package-83d1827278ec8ca210fa",
17
+ "versionInfo": "2.5.0",
18
18
  "downloadLocation": "NOASSERTION",
19
19
  "filesAnalyzed": false,
20
20
  "licenseConcluded": "NOASSERTION",
21
21
  "licenseDeclared": "NOASSERTION",
22
22
  "copyrightText": "NOASSERTION",
23
- "comment": "Flowersec source inventory SHA-256: 661d332941b82dbf168619a507636c87c3d13befebe1b70ddec45674655e1941",
23
+ "comment": "Flowersec source inventory SHA-256: 6e49aafbd7bd94f6524b61df3a4eed908c4df609841a553c15d155ebee6095ad",
24
24
  "externalRefs": [
25
25
  {
26
26
  "referenceCategory": "PACKAGE-MANAGER",
27
27
  "referenceType": "purl",
28
- "referenceLocator": "pkg:npm/%40floegence/flowersec-core@2.4.1"
28
+ "referenceLocator": "pkg:npm/%40floegence/flowersec-core@2.5.0"
29
29
  }
30
30
  ]
31
31
  },
@@ -136,30 +136,30 @@
136
136
  {
137
137
  "spdxElementId": "SPDXRef-DOCUMENT",
138
138
  "relationshipType": "DESCRIBES",
139
- "relatedSpdxElement": "SPDXRef-Package-4eeba6fa52c374d21b02"
139
+ "relatedSpdxElement": "SPDXRef-Package-83d1827278ec8ca210fa"
140
140
  },
141
141
  {
142
- "spdxElementId": "SPDXRef-Package-4eeba6fa52c374d21b02",
142
+ "spdxElementId": "SPDXRef-Package-83d1827278ec8ca210fa",
143
143
  "relationshipType": "DEPENDS_ON",
144
144
  "relatedSpdxElement": "SPDXRef-Package-5ce913a03239b02770fb"
145
145
  },
146
146
  {
147
- "spdxElementId": "SPDXRef-Package-4eeba6fa52c374d21b02",
147
+ "spdxElementId": "SPDXRef-Package-83d1827278ec8ca210fa",
148
148
  "relationshipType": "DEPENDS_ON",
149
149
  "relatedSpdxElement": "SPDXRef-Package-01009adf60db02c13634"
150
150
  },
151
151
  {
152
- "spdxElementId": "SPDXRef-Package-4eeba6fa52c374d21b02",
152
+ "spdxElementId": "SPDXRef-Package-83d1827278ec8ca210fa",
153
153
  "relationshipType": "DEPENDS_ON",
154
154
  "relatedSpdxElement": "SPDXRef-Package-8815b117c8a1d5f7eeb0"
155
155
  },
156
156
  {
157
- "spdxElementId": "SPDXRef-Package-4eeba6fa52c374d21b02",
157
+ "spdxElementId": "SPDXRef-Package-83d1827278ec8ca210fa",
158
158
  "relationshipType": "DEPENDS_ON",
159
159
  "relatedSpdxElement": "SPDXRef-Package-f8b45a289df643042b94"
160
160
  },
161
161
  {
162
- "spdxElementId": "SPDXRef-Package-4eeba6fa52c374d21b02",
162
+ "spdxElementId": "SPDXRef-Package-83d1827278ec8ca210fa",
163
163
  "relationshipType": "DEPENDS_ON",
164
164
  "relatedSpdxElement": "SPDXRef-Package-b779412f685822663496"
165
165
  },