@fuzdev/fuz_app 0.16.0 → 0.17.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.
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Backend-initiated broadcast notification plumbing — generic across consumers.
3
+ *
4
+ * Builds a typed `{method_name: (input) => Promise<void>}` object from a list
5
+ * of action specs. Each call validates input against the spec, wraps it in a
6
+ * JSON-RPC notification, and either broadcasts to every connection or
7
+ * fans out with a per-connection ACL predicate.
8
+ *
9
+ * Counterpart to `register_action_ws`: that handles request-scoped dispatch
10
+ * (frontend-initiated), this handles broadcast (backend-initiated). Together
11
+ * they cover the two primitives fuz_app consumers share. Request-scoped
12
+ * streaming (`completion_progress`, `tx_apply` events) stays on
13
+ * `ctx.notify` inside a handler — it's socket-scoped, not broadcast.
14
+ *
15
+ * Extracted from zzz's `backend_actions_api.ts` as part of the websockets
16
+ * quest (Phase 3) to stop the pattern from drifting across zzz, tx, and
17
+ * undying.
18
+ *
19
+ * @module
20
+ */
21
+ import { type Logger as LoggerType } from '@fuzdev/fuz_util/log.js';
22
+ import type { ActionPeer } from './action_peer.js';
23
+ import type { ActionSpecUnion } from './action_spec.js';
24
+ import { type ConnectionIdentity } from './transports_ws_backend.js';
25
+ /**
26
+ * Per-connection delivery predicate for subscription ACLs.
27
+ *
28
+ * Called once per connection for every broadcast send. Returning `false`
29
+ * skips that connection. Keep it fast — this runs in the broadcast hot path.
30
+ *
31
+ * `input` is the already-validated payload (matches the spec's input schema);
32
+ * `method` is the action method name.
33
+ */
34
+ export type ShouldDeliverFn = (connection: ConnectionIdentity, method: string, input: unknown) => boolean;
35
+ /** Options for `create_broadcast_api`. */
36
+ export interface CreateBroadcastApiOptions {
37
+ /** The peer holding the transport registry used for sends. */
38
+ peer: ActionPeer;
39
+ /**
40
+ * Notification specs to expose as broadcast methods. Typically the
41
+ * `remote_notification` specs whose initiator is `backend` (or `both`).
42
+ * Other kinds are accepted — the helper only uses `spec.method` and
43
+ * `spec.input` — but the typical use is notifications.
44
+ */
45
+ specs: ReadonlyArray<ActionSpecUnion>;
46
+ /** Logger for validation/send errors. Defaults to a `[broadcast]` namespace. */
47
+ log?: LoggerType | null;
48
+ /**
49
+ * Optional per-connection ACL predicate. When set, the broadcast fans out
50
+ * via the transport's `broadcast_filtered` (feature-detected) — each
51
+ * connection's identity is checked before the message is sent. When
52
+ * unset, the transport broadcasts unfiltered via `transport.send`.
53
+ *
54
+ * Requires a transport that implements `FilterableBroadcastTransport`
55
+ * (today: only `BackendWebsocketTransport`). If set and the active
56
+ * transport is not filterable, the send is skipped and an error logged.
57
+ */
58
+ should_deliver?: ShouldDeliverFn;
59
+ }
60
+ /**
61
+ * Loose base shape for a broadcast API. Consumers typically declare a
62
+ * stricter per-method interface (e.g. `BackendActionsApi`) and pin it via
63
+ * the type parameter on `create_broadcast_api`.
64
+ */
65
+ export type BroadcastApi = Record<string, (input: never) => Promise<void>>;
66
+ /**
67
+ * Builds a typed broadcast API from a set of action specs.
68
+ *
69
+ * For each spec, adds a method keyed by `spec.method` that:
70
+ * - Validates `input` against the spec's Zod schema (logs and returns on failure)
71
+ * - Creates a JSON-RPC notification from the validated input
72
+ * - Broadcasts via the peer (filtered by `should_deliver` when supplied)
73
+ *
74
+ * Silently returns when no transport is ready (e.g. before any clients
75
+ * connect). Errors during send are logged but never thrown — broadcasts are
76
+ * fire-and-forget from the handler's perspective.
77
+ *
78
+ * ## Typed consumer surface
79
+ *
80
+ * Consumers declare an explicit interface and pin it via the type parameter:
81
+ *
82
+ * ```ts
83
+ * export interface BackendActionsApi {
84
+ * filer_change: (input: ActionInputs['filer_change']) => Promise<void>;
85
+ * workspace_changed: (input: ActionInputs['workspace_changed']) => Promise<void>;
86
+ * }
87
+ *
88
+ * const api = create_broadcast_api<BackendActionsApi>({
89
+ * peer: backend.peer,
90
+ * specs: [filer_change_action_spec, workspace_changed_action_spec],
91
+ * });
92
+ * ```
93
+ *
94
+ * The cast is unchecked — callers must keep the interface and the `specs`
95
+ * array in sync. Codegen (`action_collections.gen.ts`) is a natural fit
96
+ * if the consumer already generates per-method type maps.
97
+ */
98
+ export declare const create_broadcast_api: <TApi = BroadcastApi>(options: CreateBroadcastApiOptions) => TApi;
99
+ //# sourceMappingURL=broadcast_api.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"broadcast_api.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/actions/broadcast_api.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAEH,OAAO,EAAS,KAAK,MAAM,IAAI,UAAU,EAAC,MAAM,yBAAyB,CAAC;AAG1E,OAAO,KAAK,EAAC,UAAU,EAAC,MAAM,kBAAkB,CAAC;AACjD,OAAO,KAAK,EAAC,eAAe,EAAC,MAAM,kBAAkB,CAAC;AACtD,OAAO,EAEN,KAAK,kBAAkB,EACvB,MAAM,4BAA4B,CAAC;AAEpC;;;;;;;;GAQG;AACH,MAAM,MAAM,eAAe,GAAG,CAC7B,UAAU,EAAE,kBAAkB,EAC9B,MAAM,EAAE,MAAM,EACd,KAAK,EAAE,OAAO,KACV,OAAO,CAAC;AAEb,0CAA0C;AAC1C,MAAM,WAAW,yBAAyB;IACzC,8DAA8D;IAC9D,IAAI,EAAE,UAAU,CAAC;IACjB;;;;;OAKG;IACH,KAAK,EAAE,aAAa,CAAC,eAAe,CAAC,CAAC;IACtC,gFAAgF;IAChF,GAAG,CAAC,EAAE,UAAU,GAAG,IAAI,CAAC;IACxB;;;;;;;;;OASG;IACH,cAAc,CAAC,EAAE,eAAe,CAAC;CACjC;AAED;;;;GAIG;AACH,MAAM,MAAM,YAAY,GAAG,MAAM,CAAC,MAAM,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC;AAE3E;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AACH,eAAO,MAAM,oBAAoB,GAAI,IAAI,GAAG,YAAY,EACvD,SAAS,yBAAyB,KAChC,IAoDF,CAAC"}
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Backend-initiated broadcast notification plumbing — generic across consumers.
3
+ *
4
+ * Builds a typed `{method_name: (input) => Promise<void>}` object from a list
5
+ * of action specs. Each call validates input against the spec, wraps it in a
6
+ * JSON-RPC notification, and either broadcasts to every connection or
7
+ * fans out with a per-connection ACL predicate.
8
+ *
9
+ * Counterpart to `register_action_ws`: that handles request-scoped dispatch
10
+ * (frontend-initiated), this handles broadcast (backend-initiated). Together
11
+ * they cover the two primitives fuz_app consumers share. Request-scoped
12
+ * streaming (`completion_progress`, `tx_apply` events) stays on
13
+ * `ctx.notify` inside a handler — it's socket-scoped, not broadcast.
14
+ *
15
+ * Extracted from zzz's `backend_actions_api.ts` as part of the websockets
16
+ * quest (Phase 3) to stop the pattern from drifting across zzz, tx, and
17
+ * undying.
18
+ *
19
+ * @module
20
+ */
21
+ import { Logger } from '@fuzdev/fuz_util/log.js';
22
+ import { create_jsonrpc_notification, to_jsonrpc_params } from '../http/jsonrpc_helpers.js';
23
+ import { is_filterable_broadcast_transport, } from './transports_ws_backend.js';
24
+ /**
25
+ * Builds a typed broadcast API from a set of action specs.
26
+ *
27
+ * For each spec, adds a method keyed by `spec.method` that:
28
+ * - Validates `input` against the spec's Zod schema (logs and returns on failure)
29
+ * - Creates a JSON-RPC notification from the validated input
30
+ * - Broadcasts via the peer (filtered by `should_deliver` when supplied)
31
+ *
32
+ * Silently returns when no transport is ready (e.g. before any clients
33
+ * connect). Errors during send are logged but never thrown — broadcasts are
34
+ * fire-and-forget from the handler's perspective.
35
+ *
36
+ * ## Typed consumer surface
37
+ *
38
+ * Consumers declare an explicit interface and pin it via the type parameter:
39
+ *
40
+ * ```ts
41
+ * export interface BackendActionsApi {
42
+ * filer_change: (input: ActionInputs['filer_change']) => Promise<void>;
43
+ * workspace_changed: (input: ActionInputs['workspace_changed']) => Promise<void>;
44
+ * }
45
+ *
46
+ * const api = create_broadcast_api<BackendActionsApi>({
47
+ * peer: backend.peer,
48
+ * specs: [filer_change_action_spec, workspace_changed_action_spec],
49
+ * });
50
+ * ```
51
+ *
52
+ * The cast is unchecked — callers must keep the interface and the `specs`
53
+ * array in sync. Codegen (`action_collections.gen.ts`) is a natural fit
54
+ * if the consumer already generates per-method type maps.
55
+ */
56
+ export const create_broadcast_api = (options) => {
57
+ const { peer, specs, should_deliver } = options;
58
+ const log = options.log === undefined ? new Logger('[broadcast]') : options.log;
59
+ const api = {};
60
+ for (const spec of specs) {
61
+ const { method } = spec;
62
+ api[method] = async (input) => {
63
+ const parsed = spec.input.safeParse(input);
64
+ if (!parsed.success) {
65
+ log?.error(`[${method}] input validation failed:`, parsed.error.issues);
66
+ return;
67
+ }
68
+ // Resolve the broadcast target deterministically — no fallback.
69
+ // Broadcast is 1→N over a specific primary transport; falling through
70
+ // to "any ready transport" would send to an unexpected audience.
71
+ // Silent skip when no ready transport (e.g. before any clients connect).
72
+ const transport_name = peer.default_send_options.transport_name;
73
+ const transport = transport_name
74
+ ? peer.transports.get_transport_by_name(transport_name)
75
+ : peer.transports.get_current_transport();
76
+ if (!transport?.is_ready())
77
+ return;
78
+ const notification = create_jsonrpc_notification(method, to_jsonrpc_params(parsed.data));
79
+ try {
80
+ if (should_deliver) {
81
+ if (!is_filterable_broadcast_transport(transport)) {
82
+ log?.error(`[${method}] should_deliver set but transport ${transport.transport_name} does not support per-connection filtering`);
83
+ return;
84
+ }
85
+ transport.broadcast_filtered(notification, (identity) => should_deliver(identity, method, parsed.data));
86
+ return;
87
+ }
88
+ const result = await transport.send(notification);
89
+ if (result !== null) {
90
+ log?.error(`[${method}] failed to send notification:`, result.error);
91
+ }
92
+ }
93
+ catch (error) {
94
+ log?.error(`[${method}] unexpected error:`, error);
95
+ }
96
+ };
97
+ }
98
+ return api;
99
+ };
@@ -12,6 +12,16 @@
12
12
  import type { ActionEventEnvironment } from './action_event_types.js';
13
13
  import type { ActionPeer } from './action_peer.js';
14
14
  import type { ActionEventDataUnion } from './action_event_data.js';
15
+ import type { TransportName } from './transports.js';
16
+ /**
17
+ * Optional per-method transport selector. Return the transport to use for a
18
+ * given method, or `undefined` to let the peer pick via its fallback rules.
19
+ *
20
+ * Useful when methods are registered on different backend dispatchers — e.g.
21
+ * a streaming action mounted on the WebSocket endpoint while the rest of the
22
+ * RPC surface lives on HTTP.
23
+ */
24
+ export type TransportForMethod = (method: string) => TransportName | undefined;
15
25
  /** Duck-typed action history — consumers pass their concrete Actions cell. */
16
26
  export interface RpcClientActionHistory {
17
27
  add_from_json: (json: {
@@ -27,6 +37,13 @@ export interface CreateRpcClientOptions {
27
37
  environment: ActionEventEnvironment;
28
38
  /** Optional action history tracking (duck-typed Actions cell). */
29
39
  actions?: RpcClientActionHistory;
40
+ /**
41
+ * Optional per-method transport selector. When provided, the client calls
42
+ * `peer.send(msg, {transport_name})` with the returned transport for each
43
+ * `request_response` / `remote_notification` dispatch. Returning `undefined`
44
+ * falls back to the peer's default selection.
45
+ */
46
+ transport_for_method?: TransportForMethod;
30
47
  }
31
48
  /**
32
49
  * Creates a Proxy-based API from action specs.
@@ -1 +1 @@
1
- {"version":3,"file":"rpc_client.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/actions/rpc_client.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAQH,OAAO,KAAK,EAAC,sBAAsB,EAAC,MAAM,yBAAyB,CAAC;AAOpE,OAAO,KAAK,EAAC,UAAU,EAAC,MAAM,kBAAkB,CAAC;AACjD,OAAO,KAAK,EAAC,oBAAoB,EAAC,MAAM,wBAAwB,CAAC;AAMjE,8EAA8E;AAC9E,MAAM,WAAW,sBAAsB;IACtC,aAAa,EAAE,CAAC,IAAI,EAAE;QAAC,MAAM,EAAE,MAAM,CAAC;QAAC,iBAAiB,EAAE,oBAAoB,CAAA;KAAC,KAC5E;QACA,sBAAsB,EAAE,CAAC,KAAK,EAAE,GAAG,KAAK,IAAI,CAAC;KAC5C,GACD,SAAS,CAAC;CACb;AAED,uCAAuC;AACvC,MAAM,WAAW,sBAAsB;IACtC,IAAI,EAAE,UAAU,CAAC;IACjB,WAAW,EAAE,sBAAsB,CAAC;IACpC,kEAAkE;IAClE,OAAO,CAAC,EAAE,sBAAsB,CAAC;CACjC;AAED;;;;;;;;;;GAUG;AACH,eAAO,MAAM,iBAAiB,GAC7B,SAAS,sBAAsB,KAC7B,MAAM,CAAC,MAAM,EAAE,CAAC,GAAG,IAAI,EAAE,KAAK,CAAC,GAAG,CAAC,KAAK,GAAG,CAgB7C,CAAC"}
1
+ {"version":3,"file":"rpc_client.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/actions/rpc_client.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAQH,OAAO,KAAK,EAAC,sBAAsB,EAAC,MAAM,yBAAyB,CAAC;AAOpE,OAAO,KAAK,EAAC,UAAU,EAAC,MAAM,kBAAkB,CAAC;AACjD,OAAO,KAAK,EAAC,oBAAoB,EAAC,MAAM,wBAAwB,CAAC;AACjE,OAAO,KAAK,EAAC,aAAa,EAAC,MAAM,iBAAiB,CAAC;AAEnD;;;;;;;GAOG;AACH,MAAM,MAAM,kBAAkB,GAAG,CAAC,MAAM,EAAE,MAAM,KAAK,aAAa,GAAG,SAAS,CAAC;AAM/E,8EAA8E;AAC9E,MAAM,WAAW,sBAAsB;IACtC,aAAa,EAAE,CAAC,IAAI,EAAE;QAAC,MAAM,EAAE,MAAM,CAAC;QAAC,iBAAiB,EAAE,oBAAoB,CAAA;KAAC,KAC5E;QACA,sBAAsB,EAAE,CAAC,KAAK,EAAE,GAAG,KAAK,IAAI,CAAC;KAC5C,GACD,SAAS,CAAC;CACb;AAED,uCAAuC;AACvC,MAAM,WAAW,sBAAsB;IACtC,IAAI,EAAE,UAAU,CAAC;IACjB,WAAW,EAAE,sBAAsB,CAAC;IACpC,kEAAkE;IAClE,OAAO,CAAC,EAAE,sBAAsB,CAAC;IACjC;;;;;OAKG;IACH,oBAAoB,CAAC,EAAE,kBAAkB,CAAC;CAC1C;AAED;;;;;;;;;;GAUG;AACH,eAAO,MAAM,iBAAiB,GAC7B,SAAS,sBAAsB,KAC7B,MAAM,CAAC,MAAM,EAAE,CAAC,GAAG,IAAI,EAAE,KAAK,CAAC,GAAG,CAAC,KAAK,GAAG,CAgB7C,CAAC"}
@@ -23,14 +23,14 @@ import { is_send_request, is_notification_send, extract_action_result, } from '.
23
23
  * @returns a Proxy that responds to any method name found in the environment's specs
24
24
  */
25
25
  export const create_rpc_client = (options) => {
26
- const { peer, environment, actions } = options;
26
+ const { peer, environment, actions, transport_for_method } = options;
27
27
  return new Proxy({}, {
28
28
  get(_target, method) {
29
29
  const spec = environment.lookup_action_spec(method);
30
30
  if (!spec) {
31
31
  return undefined;
32
32
  }
33
- return create_action_method(peer, environment, spec, actions);
33
+ return create_action_method(peer, environment, spec, actions, transport_for_method);
34
34
  },
35
35
  has(_target, method) {
36
36
  return environment.lookup_action_spec(method) !== undefined;
@@ -40,16 +40,16 @@ export const create_rpc_client = (options) => {
40
40
  /**
41
41
  * Creates a method that executes an action through its complete lifecycle.
42
42
  */
43
- const create_action_method = (peer, environment, spec, actions) => {
43
+ const create_action_method = (peer, environment, spec, actions, transport_for_method) => {
44
44
  switch (spec.kind) {
45
45
  case 'local_call':
46
46
  return spec.async
47
47
  ? create_async_local_call_method(environment, spec, actions)
48
48
  : create_sync_local_call_method(environment, spec, actions);
49
49
  case 'request_response':
50
- return create_request_response_method(peer, environment, spec, actions);
50
+ return create_request_response_method(peer, environment, spec, actions, transport_for_method);
51
51
  case 'remote_notification':
52
- return create_remote_notification_method(peer, environment, spec, actions);
52
+ return create_remote_notification_method(peer, environment, spec, actions, transport_for_method);
53
53
  }
54
54
  };
55
55
  /**
@@ -94,7 +94,7 @@ const create_async_local_call_method = (environment, spec, actions) => {
94
94
  /**
95
95
  * Creates a request/response method that communicates over the network.
96
96
  */
97
- const create_request_response_method = (peer, environment, spec, actions) => {
97
+ const create_request_response_method = (peer, environment, spec, actions, transport_for_method) => {
98
98
  return async (input) => {
99
99
  const event = create_action_event(environment, spec, input);
100
100
  const action = actions?.add_from_json({
@@ -113,7 +113,8 @@ const create_request_response_method = (peer, environment, spec, actions) => {
113
113
  if (event.data.step !== 'handled') {
114
114
  return extract_action_result(event);
115
115
  }
116
- const response = await peer.send(event.data.request);
116
+ const transport_name = transport_for_method?.(spec.method);
117
+ const response = await peer.send(event.data.request, transport_name ? { transport_name } : undefined);
117
118
  event.transition('receive_response');
118
119
  // TODO @api shouldn't this happen in the peer like the other method calls?
119
120
  event.set_response(response);
@@ -126,7 +127,7 @@ const create_request_response_method = (peer, environment, spec, actions) => {
126
127
  * Creates a remote notification method (fire and forget).
127
128
  * Returns Result<{value: void}> for consistency.
128
129
  */
129
- const create_remote_notification_method = (peer, environment, spec, actions) => {
130
+ const create_remote_notification_method = (peer, environment, spec, actions, transport_for_method) => {
130
131
  return async (input) => {
131
132
  const event = create_action_event(environment, spec, input);
132
133
  const action = actions?.add_from_json({
@@ -138,7 +139,8 @@ const create_remote_notification_method = (peer, environment, spec, actions) =>
138
139
  if (!is_notification_send(event.data))
139
140
  throw Error(); // TODO @many maybe make this an assertion helper?
140
141
  if (event.data.step === 'handled') {
141
- const send_result = await peer.send(event.data.notification);
142
+ const transport_name = transport_for_method?.(spec.method);
143
+ const send_result = await peer.send(event.data.notification, transport_name ? { transport_name } : undefined);
142
144
  // Check if notification failed to send
143
145
  if (send_result !== null) {
144
146
  environment.log?.error('notification send failed:', send_result.error);
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Frontend WebSocket client — portable, Svelte-reactive, implements `WebsocketConnection`.
3
+ *
4
+ * Plain class with `$state` runes (no Cell inheritance, no app coupling).
5
+ * Drop into any SvelteKit frontend as the underlying connection for
6
+ * `FrontendWebsocketTransport`. Handles auto-reconnect with exponential
7
+ * backoff, respects `WS_CLOSE_SESSION_REVOKED` (no reconnect loop after the
8
+ * server revokes auth), and exposes reactive status for UI indicators.
9
+ *
10
+ * First cut: no message queue, no heartbeat. Those live in consumer-specific
11
+ * wrappers today (see zzz's `Socket` Cell); extract into fuz_app when two
12
+ * independent consumers motivate the shape.
13
+ *
14
+ * @module
15
+ */
16
+ import type { Logger } from '@fuzdev/fuz_util/log.js';
17
+ import type { WebsocketConnection } from './transports_ws.js';
18
+ /** Default WebSocket close code (normal closure). */
19
+ export declare const DEFAULT_CLOSE_CODE = 1000;
20
+ /** Base reconnect delay in ms. */
21
+ export declare const DEFAULT_RECONNECT_DELAY = 1000;
22
+ /** Max reconnect delay in ms (cap on exponential backoff). */
23
+ export declare const DEFAULT_RECONNECT_DELAY_MAX = 10000;
24
+ /** Exponential backoff factor: delay = base * factor^(attempt-1). */
25
+ export declare const DEFAULT_BACKOFF_FACTOR = 1.5;
26
+ /**
27
+ * Client-side WebSocket status.
28
+ *
29
+ * - `initial` — never connected; `connect()` has not been called.
30
+ * - `connecting` — WebSocket `readyState === CONNECTING`.
31
+ * - `connected` — WebSocket `readyState === OPEN`.
32
+ * - `reconnecting` — close fired; waiting out backoff before next attempt.
33
+ * - `closed` — socket is not open. Terminal only when `revoked` is `true`
34
+ * or auto-reconnect is disabled; otherwise `connect()` reopens.
35
+ */
36
+ export type SocketStatus = 'initial' | 'connecting' | 'connected' | 'reconnecting' | 'closed';
37
+ export type SocketMessageHandler = (event: MessageEvent) => void;
38
+ export type SocketErrorHandler = (event: Event) => void;
39
+ export interface FrontendWebsocketReconnectOptions {
40
+ /** Base reconnect delay in ms. Defaults to 1000. */
41
+ delay?: number;
42
+ /** Max reconnect delay in ms (cap on exponential backoff). Defaults to 10000. */
43
+ delay_max?: number;
44
+ /** Exponential backoff factor. Defaults to 1.5. */
45
+ factor?: number;
46
+ }
47
+ export interface FrontendWebsocketClientOptions {
48
+ /**
49
+ * Auto-reconnect policy. `false` disables reconnect entirely; `true` or
50
+ * omit for default timing; pass an object to customize.
51
+ */
52
+ reconnect?: boolean | FrontendWebsocketReconnectOptions | null;
53
+ /** Optional logger for diagnostic messages. */
54
+ log?: Logger | null;
55
+ }
56
+ /**
57
+ * Reactive WebSocket client implementing `WebsocketConnection`.
58
+ *
59
+ * Construct with a URL and optional config; call `connect()` to open the
60
+ * socket and begin auto-reconnect. Register message/error handlers via
61
+ * `add_message_handler` / `add_error_handler` — both return unsubscribe
62
+ * functions. `FrontendWebsocketTransport` consumes this as its connection.
63
+ *
64
+ * Session-revocation close codes (`WS_CLOSE_SESSION_REVOKED`) put the client
65
+ * in a permanently-closed state; reconnecting would just loop on 401.
66
+ */
67
+ export declare class FrontendWebsocketClient implements WebsocketConnection, Disposable {
68
+ #private;
69
+ ws: WebSocket | null;
70
+ status: SocketStatus;
71
+ reconnect_count: number;
72
+ current_reconnect_delay: number;
73
+ /** Epoch ms of the most recent successful open. Never cleared on close. */
74
+ last_connect_time: number | null;
75
+ /** Epoch ms of the most recent close event or client-initiated close. */
76
+ last_close_time: number | null;
77
+ /** Close code from the most recent close. Initial `null` means "never closed." */
78
+ last_close_code: number | null;
79
+ /** Reason string from the most recent close event (may be empty). */
80
+ last_close_reason: string | null;
81
+ readonly connected: boolean;
82
+ constructor(url: string, options?: FrontendWebsocketClientOptions);
83
+ get url(): string;
84
+ /**
85
+ * Whether the server has permanently closed the session. Once `true`, all
86
+ * `connect()` calls are no-ops. Distinct from `status:'closed'`, which
87
+ * reflects any closed state (incl. user-initiated `disconnect()`).
88
+ */
89
+ get revoked(): boolean;
90
+ /**
91
+ * Open the WebSocket. No-op on SSR, or if the session has been revoked.
92
+ * Cancels any pending reconnect and tears down any existing connection first;
93
+ * an open prior socket is closed with a normal-closure code.
94
+ */
95
+ connect(): void;
96
+ /**
97
+ * Close the WebSocket, cancel any pending reconnect, and reset the reconnect
98
+ * backoff counters. Puts the client in `closed` status; call `connect()` to
99
+ * reopen. Safe to call more than once.
100
+ */
101
+ disconnect(code?: number): void;
102
+ /** Explicit-resource-management hook — supports `using client = new FrontendWebsocketClient(url)`. */
103
+ [Symbol.dispose](): void;
104
+ send(data: object): boolean;
105
+ add_message_handler(handler: SocketMessageHandler): () => void;
106
+ add_error_handler(handler: SocketErrorHandler): () => void;
107
+ }
108
+ //# sourceMappingURL=socket.svelte.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"socket.svelte.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/actions/socket.svelte.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAGH,OAAO,KAAK,EAAC,MAAM,EAAC,MAAM,yBAAyB,CAAC;AAGpD,OAAO,KAAK,EAAC,mBAAmB,EAAC,MAAM,oBAAoB,CAAC;AAE5D,qDAAqD;AACrD,eAAO,MAAM,kBAAkB,OAAO,CAAC;AACvC,kCAAkC;AAClC,eAAO,MAAM,uBAAuB,OAAO,CAAC;AAC5C,8DAA8D;AAC9D,eAAO,MAAM,2BAA2B,QAAQ,CAAC;AACjD,qEAAqE;AACrE,eAAO,MAAM,sBAAsB,MAAM,CAAC;AAE1C;;;;;;;;;GASG;AACH,MAAM,MAAM,YAAY,GAAG,SAAS,GAAG,YAAY,GAAG,WAAW,GAAG,cAAc,GAAG,QAAQ,CAAC;AAE9F,MAAM,MAAM,oBAAoB,GAAG,CAAC,KAAK,EAAE,YAAY,KAAK,IAAI,CAAC;AACjE,MAAM,MAAM,kBAAkB,GAAG,CAAC,KAAK,EAAE,KAAK,KAAK,IAAI,CAAC;AAExD,MAAM,WAAW,iCAAiC;IACjD,oDAAoD;IACpD,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,iFAAiF;IACjF,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,mDAAmD;IACnD,MAAM,CAAC,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,8BAA8B;IAC9C;;;OAGG;IACH,SAAS,CAAC,EAAE,OAAO,GAAG,iCAAiC,GAAG,IAAI,CAAC;IAC/D,+CAA+C;IAC/C,GAAG,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CACpB;AAED;;;;;;;;;;GAUG;AACH,qBAAa,uBAAwB,YAAW,mBAAmB,EAAE,UAAU;;IAQ9E,EAAE,EAAE,SAAS,GAAG,IAAI,CAAoB;IACxC,MAAM,EAAE,YAAY,CAAyB;IAE7C,eAAe,EAAE,MAAM,CAAiB;IACxC,uBAAuB,EAAE,MAAM,CAAiB;IAChD,2EAA2E;IAC3E,iBAAiB,EAAE,MAAM,GAAG,IAAI,CAAoB;IACpD,yEAAyE;IACzE,eAAe,EAAE,MAAM,GAAG,IAAI,CAAoB;IAClD,kFAAkF;IAClF,eAAe,EAAE,MAAM,GAAG,IAAI,CAAoB;IAClD,qEAAqE;IACrE,iBAAiB,EAAE,MAAM,GAAG,IAAI,CAAoB;IAQpD,QAAQ,CAAC,SAAS,EAAE,OAAO,CAAyC;gBAExD,GAAG,EAAE,MAAM,EAAE,OAAO,GAAE,8BAAmC;IAWrE,IAAI,GAAG,IAAI,MAAM,CAEhB;IAED;;;;OAIG;IACH,IAAI,OAAO,IAAI,OAAO,CAErB;IAED;;;;OAIG;IACH,OAAO,IAAI,IAAI;IA2Bf;;;;OAIG;IACH,UAAU,CAAC,IAAI,GAAE,MAA2B,GAAG,IAAI;IAQnD,sGAAsG;IACtG,CAAC,MAAM,CAAC,OAAO,CAAC,IAAI,IAAI;IAIxB,IAAI,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO;IAW3B,mBAAmB,CAAC,OAAO,EAAE,oBAAoB,GAAG,MAAM,IAAI;IAK9D,iBAAiB,CAAC,OAAO,EAAE,kBAAkB,GAAG,MAAM,IAAI;CA8G1D"}
@@ -0,0 +1,245 @@
1
+ /**
2
+ * Frontend WebSocket client — portable, Svelte-reactive, implements `WebsocketConnection`.
3
+ *
4
+ * Plain class with `$state` runes (no Cell inheritance, no app coupling).
5
+ * Drop into any SvelteKit frontend as the underlying connection for
6
+ * `FrontendWebsocketTransport`. Handles auto-reconnect with exponential
7
+ * backoff, respects `WS_CLOSE_SESSION_REVOKED` (no reconnect loop after the
8
+ * server revokes auth), and exposes reactive status for UI indicators.
9
+ *
10
+ * First cut: no message queue, no heartbeat. Those live in consumer-specific
11
+ * wrappers today (see zzz's `Socket` Cell); extract into fuz_app when two
12
+ * independent consumers motivate the shape.
13
+ *
14
+ * @module
15
+ */
16
+ import { BROWSER } from 'esm-env';
17
+ import { WS_CLOSE_SESSION_REVOKED } from './transports.js';
18
+ /** Default WebSocket close code (normal closure). */
19
+ export const DEFAULT_CLOSE_CODE = 1000;
20
+ /** Base reconnect delay in ms. */
21
+ export const DEFAULT_RECONNECT_DELAY = 1000;
22
+ /** Max reconnect delay in ms (cap on exponential backoff). */
23
+ export const DEFAULT_RECONNECT_DELAY_MAX = 10000;
24
+ /** Exponential backoff factor: delay = base * factor^(attempt-1). */
25
+ export const DEFAULT_BACKOFF_FACTOR = 1.5;
26
+ /**
27
+ * Reactive WebSocket client implementing `WebsocketConnection`.
28
+ *
29
+ * Construct with a URL and optional config; call `connect()` to open the
30
+ * socket and begin auto-reconnect. Register message/error handlers via
31
+ * `add_message_handler` / `add_error_handler` — both return unsubscribe
32
+ * functions. `FrontendWebsocketTransport` consumes this as its connection.
33
+ *
34
+ * Session-revocation close codes (`WS_CLOSE_SESSION_REVOKED`) put the client
35
+ * in a permanently-closed state; reconnecting would just loop on 401.
36
+ */
37
+ export class FrontendWebsocketClient {
38
+ #url;
39
+ #auto_reconnect;
40
+ #reconnect_delay;
41
+ #reconnect_delay_max;
42
+ #backoff_factor;
43
+ #log;
44
+ ws = $state.raw(null);
45
+ status = $state.raw('initial');
46
+ reconnect_count = $state.raw(0);
47
+ current_reconnect_delay = $state.raw(0);
48
+ /** Epoch ms of the most recent successful open. Never cleared on close. */
49
+ last_connect_time = $state.raw(null);
50
+ /** Epoch ms of the most recent close event or client-initiated close. */
51
+ last_close_time = $state.raw(null);
52
+ /** Close code from the most recent close. Initial `null` means "never closed." */
53
+ last_close_code = $state.raw(null);
54
+ /** Reason string from the most recent close event (may be empty). */
55
+ last_close_reason = $state.raw(null);
56
+ #reconnect_timeout = null;
57
+ #revoked = $state.raw(false);
58
+ #message_handlers = new Set();
59
+ #error_handlers = new Set();
60
+ connected = $derived(this.status === 'connected');
61
+ constructor(url, options = {}) {
62
+ this.#url = url;
63
+ const reconnect = options.reconnect;
64
+ this.#auto_reconnect = reconnect !== false;
65
+ const config = typeof reconnect === 'object' && reconnect !== null ? reconnect : {};
66
+ this.#reconnect_delay = config.delay ?? DEFAULT_RECONNECT_DELAY;
67
+ this.#reconnect_delay_max = config.delay_max ?? DEFAULT_RECONNECT_DELAY_MAX;
68
+ this.#backoff_factor = config.factor ?? DEFAULT_BACKOFF_FACTOR;
69
+ this.#log = options.log ?? null;
70
+ }
71
+ get url() {
72
+ return this.#url;
73
+ }
74
+ /**
75
+ * Whether the server has permanently closed the session. Once `true`, all
76
+ * `connect()` calls are no-ops. Distinct from `status:'closed'`, which
77
+ * reflects any closed state (incl. user-initiated `disconnect()`).
78
+ */
79
+ get revoked() {
80
+ return this.#revoked;
81
+ }
82
+ /**
83
+ * Open the WebSocket. No-op on SSR, or if the session has been revoked.
84
+ * Cancels any pending reconnect and tears down any existing connection first;
85
+ * an open prior socket is closed with a normal-closure code.
86
+ */
87
+ connect() {
88
+ if (!BROWSER)
89
+ return;
90
+ if (this.#revoked)
91
+ return;
92
+ this.#cancel_reconnect();
93
+ this.#teardown(DEFAULT_CLOSE_CODE);
94
+ try {
95
+ this.status = 'connecting';
96
+ const ws = new WebSocket(this.#url);
97
+ this.ws = ws;
98
+ ws.addEventListener('open', this.#handle_open);
99
+ ws.addEventListener('close', this.#handle_close);
100
+ ws.addEventListener('error', this.#handle_error);
101
+ ws.addEventListener('message', this.#handle_message);
102
+ }
103
+ catch (error) {
104
+ this.#log?.error('[socket] failed to create WebSocket:', error);
105
+ this.ws = null;
106
+ if (this.#auto_reconnect) {
107
+ this.#schedule_reconnect();
108
+ }
109
+ else {
110
+ this.status = 'closed';
111
+ }
112
+ }
113
+ }
114
+ /**
115
+ * Close the WebSocket, cancel any pending reconnect, and reset the reconnect
116
+ * backoff counters. Puts the client in `closed` status; call `connect()` to
117
+ * reopen. Safe to call more than once.
118
+ */
119
+ disconnect(code = DEFAULT_CLOSE_CODE) {
120
+ this.#cancel_reconnect();
121
+ this.#teardown(code);
122
+ this.status = 'closed';
123
+ this.reconnect_count = 0;
124
+ this.current_reconnect_delay = 0;
125
+ }
126
+ /** Explicit-resource-management hook — supports `using client = new FrontendWebsocketClient(url)`. */
127
+ [Symbol.dispose]() {
128
+ this.disconnect();
129
+ }
130
+ send(data) {
131
+ if (!this.connected || !this.ws)
132
+ return false;
133
+ try {
134
+ this.ws.send(JSON.stringify(data));
135
+ return true;
136
+ }
137
+ catch (error) {
138
+ this.#log?.error('[socket] send failed:', error);
139
+ return false;
140
+ }
141
+ }
142
+ add_message_handler(handler) {
143
+ this.#message_handlers.add(handler);
144
+ return () => this.#message_handlers.delete(handler);
145
+ }
146
+ add_error_handler(handler) {
147
+ this.#error_handlers.add(handler);
148
+ return () => this.#error_handlers.delete(handler);
149
+ }
150
+ #teardown(close_code) {
151
+ if (!this.ws)
152
+ return;
153
+ this.ws.removeEventListener('open', this.#handle_open);
154
+ this.ws.removeEventListener('close', this.#handle_close);
155
+ this.ws.removeEventListener('error', this.#handle_error);
156
+ this.ws.removeEventListener('message', this.#handle_message);
157
+ if (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING) {
158
+ try {
159
+ this.ws.close(close_code);
160
+ }
161
+ catch (error) {
162
+ this.#log?.error('[socket] close failed:', error);
163
+ }
164
+ // Listeners are gone, so `#handle_close` won't fire for this close —
165
+ // record it here so the client-initiated close is still observable.
166
+ this.#record_close(close_code, '');
167
+ }
168
+ this.ws = null;
169
+ }
170
+ #record_close(code, reason) {
171
+ this.last_close_time = Date.now();
172
+ this.last_close_code = code;
173
+ this.last_close_reason = reason;
174
+ }
175
+ #schedule_reconnect() {
176
+ if (!this.#auto_reconnect || this.#revoked)
177
+ return;
178
+ this.#cancel_reconnect();
179
+ this.reconnect_count++;
180
+ this.current_reconnect_delay = Math.round(Math.min(this.#reconnect_delay_max, this.#reconnect_delay * this.#backoff_factor ** (this.reconnect_count - 1)));
181
+ this.status = 'reconnecting';
182
+ this.#reconnect_timeout = setTimeout(() => {
183
+ this.#reconnect_timeout = null;
184
+ this.connect();
185
+ }, this.current_reconnect_delay);
186
+ }
187
+ #cancel_reconnect() {
188
+ if (this.#reconnect_timeout !== null) {
189
+ clearTimeout(this.#reconnect_timeout);
190
+ this.#reconnect_timeout = null;
191
+ }
192
+ }
193
+ #handle_open = (_event) => {
194
+ this.status = 'connected';
195
+ this.reconnect_count = 0;
196
+ this.current_reconnect_delay = 0;
197
+ this.last_connect_time = Date.now();
198
+ this.#cancel_reconnect();
199
+ };
200
+ #handle_close = (event) => {
201
+ // Drop the dead-socket reference so consumers reading `client.ws` never
202
+ // see a CLOSED WebSocket during the reconnect window.
203
+ this.ws = null;
204
+ this.#record_close(event.code, event.reason);
205
+ // Session revocation is terminal — reconnecting would 401 in a loop.
206
+ if (event.code === WS_CLOSE_SESSION_REVOKED) {
207
+ this.#revoked = true;
208
+ this.status = 'closed';
209
+ this.#cancel_reconnect();
210
+ this.reconnect_count = 0;
211
+ this.current_reconnect_delay = 0;
212
+ return;
213
+ }
214
+ // Let `#schedule_reconnect` set `status: 'reconnecting'` directly to avoid
215
+ // a transient `'closed'` flicker; only set `'closed'` when reconnect is off.
216
+ if (this.#auto_reconnect) {
217
+ this.#schedule_reconnect();
218
+ }
219
+ else {
220
+ this.status = 'closed';
221
+ }
222
+ };
223
+ #handle_error = (event) => {
224
+ this.#log?.error('[socket] websocket error:', event);
225
+ for (const handler of this.#error_handlers) {
226
+ try {
227
+ handler(event);
228
+ }
229
+ catch (error) {
230
+ this.#log?.error('[socket] error handler threw:', error);
231
+ }
232
+ }
233
+ // Browsers fire `close` after error; reconnect logic lives there.
234
+ };
235
+ #handle_message = (event) => {
236
+ for (const handler of this.#message_handlers) {
237
+ try {
238
+ handler(event);
239
+ }
240
+ catch (error) {
241
+ this.#log?.error('[socket] message handler threw:', error);
242
+ }
243
+ }
244
+ };
245
+ }
@@ -5,7 +5,7 @@
5
5
  * @module
6
6
  */
7
7
  import type { WSContext } from 'hono/ws';
8
- import type { JsonrpcNotification, JsonrpcRequest, JsonrpcResponseOrError, JsonrpcErrorResponse } from '../http/jsonrpc.js';
8
+ import type { JsonrpcMessageFromServerToClient, JsonrpcNotification, JsonrpcRequest, JsonrpcResponseOrError, JsonrpcErrorResponse } from '../http/jsonrpc.js';
9
9
  import { type Uuid } from '../uuid.js';
10
10
  import { type Transport } from './transports.js';
11
11
  /**
@@ -24,7 +24,21 @@ export interface ConnectionIdentity {
24
24
  /** `api_token.id` for bearer-authenticated connections, else null. */
25
25
  api_token_id: string | null;
26
26
  }
27
- export declare class BackendWebsocketTransport implements Transport {
27
+ /**
28
+ * Structural capability for transports that can broadcast with a
29
+ * per-connection ACL predicate. Named separately from `Transport` so the
30
+ * broadcast API can feature-detect without importing a concrete class.
31
+ *
32
+ * `ConnectionIdentity` is the auth-gated identity shape used today. When a
33
+ * second implementation (e.g. SSE backend transport) lands with a
34
+ * different identity, consider parameterizing on `TIdentity`.
35
+ */
36
+ export interface FilterableBroadcastTransport extends Transport {
37
+ broadcast_filtered: (message: JsonrpcMessageFromServerToClient, predicate: (identity: ConnectionIdentity) => boolean) => number;
38
+ }
39
+ /** Type guard for `FilterableBroadcastTransport`. */
40
+ export declare const is_filterable_broadcast_transport: (transport: Transport) => transport is FilterableBroadcastTransport;
41
+ export declare class BackendWebsocketTransport implements FilterableBroadcastTransport {
28
42
  #private;
29
43
  readonly transport_name: "backend_websocket_rpc";
30
44
  /**
@@ -66,6 +80,17 @@ export declare class BackendWebsocketTransport implements Transport {
66
80
  close_sockets_for_token(api_token_id: string): number;
67
81
  send(message: JsonrpcRequest): Promise<JsonrpcResponseOrError>;
68
82
  send(message: JsonrpcNotification): Promise<JsonrpcErrorResponse | null>;
83
+ /**
84
+ * Broadcast to connections whose identity satisfies a predicate.
85
+ *
86
+ * Used by the broadcast API when a consumer supplies a subscription ACL hook
87
+ * (e.g. tx's `tx_run_created` only reaches the account that owns the run).
88
+ * When no ACL is needed, callers should prefer `send(message)` / `#broadcast`
89
+ * to skip the per-connection predicate overhead.
90
+ *
91
+ * @returns the number of sockets the message was sent to
92
+ */
93
+ broadcast_filtered(message: JsonrpcMessageFromServerToClient, predicate: (identity: ConnectionIdentity) => boolean): number;
69
94
  is_ready(): boolean;
70
95
  }
71
96
  //# sourceMappingURL=transports_ws_backend.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"transports_ws_backend.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/actions/transports_ws_backend.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,SAAS,CAAC;AAEvC,OAAO,KAAK,EAGX,mBAAmB,EACnB,cAAc,EACd,sBAAsB,EACtB,oBAAoB,EACpB,MAAM,oBAAoB,CAAC;AAO5B,OAAO,EAAc,KAAK,IAAI,EAAC,MAAM,YAAY,CAAC;AAClD,OAAO,EAA2B,KAAK,SAAS,EAAC,MAAM,iBAAiB,CAAC;AAIzE;;;;;;;GAOG;AACH,MAAM,WAAW,kBAAkB;IAClC,sEAAsE;IACtE,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,4CAA4C;IAC5C,UAAU,EAAE,IAAI,CAAC;IACjB,sEAAsE;IACtE,YAAY,EAAE,MAAM,GAAG,IAAI,CAAC;CAC5B;AAED,qBAAa,yBAA0B,YAAW,SAAS;;IAC1D,QAAQ,CAAC,cAAc,EAAG,uBAAuB,CAAU;IAY3D;;;;;;;;OAQG;IACH,cAAc,CACb,EAAE,EAAE,SAAS,EACb,UAAU,EAAE,MAAM,GAAG,IAAI,EACzB,UAAU,EAAE,IAAI,EAChB,YAAY,GAAE,MAAM,GAAG,IAAW,GAChC,IAAI;IAQP;;;OAGG;IACH,iBAAiB,CAAC,EAAE,EAAE,SAAS,GAAG,IAAI;IA0BtC;;;;OAIG;IACH,yBAAyB,CAAC,UAAU,EAAE,MAAM,GAAG,MAAM;IAIrD;;;;OAIG;IACH,yBAAyB,CAAC,UAAU,EAAE,IAAI,GAAG,MAAM;IAInD;;;;;;;;OAQG;IACH,uBAAuB,CAAC,YAAY,EAAE,MAAM,GAAG,MAAM;IAsB/C,IAAI,CAAC,OAAO,EAAE,cAAc,GAAG,OAAO,CAAC,sBAAsB,CAAC;IAC9D,IAAI,CAAC,OAAO,EAAE,mBAAmB,GAAG,OAAO,CAAC,oBAAoB,GAAG,IAAI,CAAC;IA4C9E,QAAQ,IAAI,OAAO;CAGnB"}
1
+ {"version":3,"file":"transports_ws_backend.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/actions/transports_ws_backend.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,SAAS,CAAC;AAEvC,OAAO,KAAK,EAEX,gCAAgC,EAChC,mBAAmB,EACnB,cAAc,EACd,sBAAsB,EACtB,oBAAoB,EACpB,MAAM,oBAAoB,CAAC;AAO5B,OAAO,EAAc,KAAK,IAAI,EAAC,MAAM,YAAY,CAAC;AAClD,OAAO,EAA2B,KAAK,SAAS,EAAC,MAAM,iBAAiB,CAAC;AAIzE;;;;;;;GAOG;AACH,MAAM,WAAW,kBAAkB;IAClC,sEAAsE;IACtE,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,4CAA4C;IAC5C,UAAU,EAAE,IAAI,CAAC;IACjB,sEAAsE;IACtE,YAAY,EAAE,MAAM,GAAG,IAAI,CAAC;CAC5B;AAED;;;;;;;;GAQG;AACH,MAAM,WAAW,4BAA6B,SAAQ,SAAS;IAC9D,kBAAkB,EAAE,CACnB,OAAO,EAAE,gCAAgC,EACzC,SAAS,EAAE,CAAC,QAAQ,EAAE,kBAAkB,KAAK,OAAO,KAChD,MAAM,CAAC;CACZ;AAED,qDAAqD;AACrD,eAAO,MAAM,iCAAiC,GAC7C,WAAW,SAAS,KAClB,SAAS,IAAI,4BAEqE,CAAC;AAEtF,qBAAa,yBAA0B,YAAW,4BAA4B;;IAC7E,QAAQ,CAAC,cAAc,EAAG,uBAAuB,CAAU;IAY3D;;;;;;;;OAQG;IACH,cAAc,CACb,EAAE,EAAE,SAAS,EACb,UAAU,EAAE,MAAM,GAAG,IAAI,EACzB,UAAU,EAAE,IAAI,EAChB,YAAY,GAAE,MAAM,GAAG,IAAW,GAChC,IAAI;IAQP;;;OAGG;IACH,iBAAiB,CAAC,EAAE,EAAE,SAAS,GAAG,IAAI;IA0BtC;;;;OAIG;IACH,yBAAyB,CAAC,UAAU,EAAE,MAAM,GAAG,MAAM;IAIrD;;;;OAIG;IACH,yBAAyB,CAAC,UAAU,EAAE,IAAI,GAAG,MAAM;IAInD;;;;;;;;OAQG;IACH,uBAAuB,CAAC,YAAY,EAAE,MAAM,GAAG,MAAM;IAsB/C,IAAI,CAAC,OAAO,EAAE,cAAc,GAAG,OAAO,CAAC,sBAAsB,CAAC;IAC9D,IAAI,CAAC,OAAO,EAAE,mBAAmB,GAAG,OAAO,CAAC,oBAAoB,GAAG,IAAI,CAAC;IA4C9E;;;;;;;;;OASG;IACH,kBAAkB,CACjB,OAAO,EAAE,gCAAgC,EACzC,SAAS,EAAE,CAAC,QAAQ,EAAE,kBAAkB,KAAK,OAAO,GAClD,MAAM;IAoBT,QAAQ,IAAI,OAAO;CAGnB"}
@@ -8,6 +8,9 @@ import { jsonrpc_error_messages } from '../http/jsonrpc_errors.js';
8
8
  import { create_jsonrpc_error_response, to_jsonrpc_message_id, is_jsonrpc_request, } from '../http/jsonrpc_helpers.js';
9
9
  import { create_uuid } from '../uuid.js';
10
10
  import { WS_CLOSE_SESSION_REVOKED } from './transports.js';
11
+ /** Type guard for `FilterableBroadcastTransport`. */
12
+ export const is_filterable_broadcast_transport = (transport) => 'broadcast_filtered' in transport &&
13
+ typeof transport.broadcast_filtered === 'function';
11
14
  export class BackendWebsocketTransport {
12
15
  transport_name = 'backend_websocket_rpc';
13
16
  // Map connection IDs to WebSocket contexts
@@ -135,6 +138,35 @@ export class BackendWebsocketTransport {
135
138
  // TODO hack - remove if not ever needed, I assume this will need to be async so let's hold that assumption
136
139
  return Promise.resolve();
137
140
  }
141
+ /**
142
+ * Broadcast to connections whose identity satisfies a predicate.
143
+ *
144
+ * Used by the broadcast API when a consumer supplies a subscription ACL hook
145
+ * (e.g. tx's `tx_run_created` only reaches the account that owns the run).
146
+ * When no ACL is needed, callers should prefer `send(message)` / `#broadcast`
147
+ * to skip the per-connection predicate overhead.
148
+ *
149
+ * @returns the number of sockets the message was sent to
150
+ */
151
+ broadcast_filtered(message, predicate) {
152
+ const serialized = JSON.stringify(message);
153
+ let count = 0;
154
+ for (const [connection_id, identity] of this.#connection_identities) {
155
+ if (!predicate(identity))
156
+ continue;
157
+ const ws = this.#connections.get(connection_id);
158
+ if (!ws)
159
+ continue;
160
+ try {
161
+ ws.send(serialized);
162
+ count++;
163
+ }
164
+ catch (error) {
165
+ console.error('[backend websocket transport] Error broadcasting filtered to client:', error);
166
+ }
167
+ }
168
+ return count;
169
+ }
138
170
  is_ready() {
139
171
  return this.#connections.size > 0;
140
172
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fuzdev/fuz_app",
3
- "version": "0.16.0",
3
+ "version": "0.17.1",
4
4
  "description": "fullstack app library",
5
5
  "glyph": "🗝",
6
6
  "logo": "logo.svg",