@fuzdev/fuz_app 0.16.0 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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
+ };
@@ -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.0",
4
4
  "description": "fullstack app library",
5
5
  "glyph": "🗝",
6
6
  "logo": "logo.svg",