@fuzdev/fuz_app 0.19.0 → 0.21.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.
@@ -7,7 +7,7 @@
7
7
  // `request_response_specs`, `remote_notification_specs`, `local_call_specs`,
8
8
  // `frontend_methods`, `backend_methods`, and `methods` are used by consumers (codegen).
9
9
  // The rest are pre-built for future use. Revisit which getters to keep when the action
10
- // system matures (saes-rpc quest). Also consider lazy memoization (`??=` or derived).
10
+ // system matures. Also consider lazy memoization (`??=` or derived).
11
11
  /**
12
12
  * Utility class to manage and query action specifications.
13
13
  * Provides helper methods to get actions by various criteria.
@@ -6,7 +6,7 @@
6
6
  * `action_bridge.ts` derive `RouteSpec` and `EventSpec` from them.
7
7
  *
8
8
  * TODO @action-system-review The action system (action_spec, action_registry,
9
- * action_codegen, action_bridge) will evolve significantly with the saes-rpc quest.
9
+ * action_codegen, action_bridge) will evolve significantly as RPC patterns settle.
10
10
  * Current state: bridge is stable, registry and codegen are partially stub API.
11
11
  * Search for `@action-system-review` across the actions/ and routes/ modules.
12
12
  *
@@ -6,7 +6,7 @@
6
6
  * `action_bridge.ts` derive `RouteSpec` and `EventSpec` from them.
7
7
  *
8
8
  * TODO @action-system-review The action system (action_spec, action_registry,
9
- * action_codegen, action_bridge) will evolve significantly with the saes-rpc quest.
9
+ * action_codegen, action_bridge) will evolve significantly as RPC patterns settle.
10
10
  * Current state: bridge is stable, registry and codegen are partially stub API.
11
11
  * Search for `@action-system-review` across the actions/ and routes/ modules.
12
12
  *
@@ -12,9 +12,8 @@
12
12
  * streaming (`completion_progress`, `tx_apply` events) stays on
13
13
  * `ctx.notify` inside a handler — it's socket-scoped, not broadcast.
14
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.
15
+ * Extracted from zzz's `backend_actions_api.ts` to stop the pattern from
16
+ * drifting across zzz, tx, and undying.
18
17
  *
19
18
  * @module
20
19
  */
@@ -1 +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"}
1
+ {"version":3,"file":"broadcast_api.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/actions/broadcast_api.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;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"}
@@ -12,9 +12,8 @@
12
12
  * streaming (`completion_progress`, `tx_apply` events) stays on
13
13
  * `ctx.notify` inside a handler — it's socket-scoped, not broadcast.
14
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.
15
+ * Extracted from zzz's `backend_actions_api.ts` to stop the pattern from
16
+ * drifting across zzz, tx, and undying.
18
17
  *
19
18
  * @module
20
19
  */
@@ -143,9 +143,9 @@ export const register_action_ws = (options) => {
143
143
  await wait(artificial_delay);
144
144
  }
145
145
  // Socket-scoped notification — routes to originator only, not
146
- // broadcast. Future work (websockets quest Phase 3/4): other
147
- // audiences — account-scoped, ACL-filtered, broadcast —
148
- // likely via a transport-level policy hook.
146
+ // broadcast. Future work: other audiences account-scoped,
147
+ // ACL-filtered, broadcast — likely via a transport-level
148
+ // policy hook.
149
149
  const notify = (notify_method, notify_params) => {
150
150
  try {
151
151
  const notification = create_jsonrpc_notification(notify_method, to_jsonrpc_params(notify_params));
@@ -78,6 +78,16 @@ export declare class FrontendWebsocketClient implements WebsocketConnection, Dis
78
78
  last_close_code: number | null;
79
79
  /** Reason string from the most recent close event (may be empty). */
80
80
  last_close_reason: string | null;
81
+ /**
82
+ * The error thrown by the most recent attempted `send()`, or `null` if the
83
+ * most recent attempt succeeded or none has been attempted yet. Populated
84
+ * when the underlying `ws.send` throws (e.g., buffer full, serialization
85
+ * error); reset to `null` on the next successful send. Not touched when
86
+ * `send()` short-circuits because the socket is not connected — consult
87
+ * {@link connected} for that case. Wrappers surfacing per-message failure
88
+ * reasons can read this after a `false` return from `send()`.
89
+ */
90
+ last_send_error: Error | null;
81
91
  readonly connected: boolean;
82
92
  constructor(url: string, options?: FrontendWebsocketClientOptions);
83
93
  /**
@@ -1 +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;IASpD,QAAQ,CAAC,SAAS,EAAE,OAAO,CAAyC;gBAExD,GAAG,EAAE,MAAM,EAAE,OAAO,GAAE,8BAAmC;IAWrE;;;;;;;;;;;;;;;;;;OAkBG;IACH,aAAa,CAAC,SAAS,GAAE,OAAO,GAAG,iCAAiC,GAAG,IAAW,GAAG,IAAI;IA4CzF,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;CAiH1D"}
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;IACpD;;;;;;;;OAQG;IACH,eAAe,EAAE,KAAK,GAAG,IAAI,CAAoB;IASjD,QAAQ,CAAC,SAAS,EAAE,OAAO,CAAyC;gBAExD,GAAG,EAAE,MAAM,EAAE,OAAO,GAAE,8BAAmC;IAWrE;;;;;;;;;;;;;;;;;;OAkBG;IACH,aAAa,CAAC,SAAS,GAAE,OAAO,GAAG,iCAAiC,GAAG,IAAW,GAAG,IAAI;IA4CzF,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;IAa3B,mBAAmB,CAAC,OAAO,EAAE,oBAAoB,GAAG,MAAM,IAAI;IAK9D,iBAAiB,CAAC,OAAO,EAAE,kBAAkB,GAAG,MAAM,IAAI;CAiH1D"}
@@ -53,6 +53,16 @@ export class FrontendWebsocketClient {
53
53
  last_close_code = $state.raw(null);
54
54
  /** Reason string from the most recent close event (may be empty). */
55
55
  last_close_reason = $state.raw(null);
56
+ /**
57
+ * The error thrown by the most recent attempted `send()`, or `null` if the
58
+ * most recent attempt succeeded or none has been attempted yet. Populated
59
+ * when the underlying `ws.send` throws (e.g., buffer full, serialization
60
+ * error); reset to `null` on the next successful send. Not touched when
61
+ * `send()` short-circuits because the socket is not connected — consult
62
+ * {@link connected} for that case. Wrappers surfacing per-message failure
63
+ * reasons can read this after a `false` return from `send()`.
64
+ */
65
+ last_send_error = $state.raw(null);
56
66
  #reconnect_timeout = null;
57
67
  #reconnect_scheduled_at = null;
58
68
  #revoked = $state.raw(false);
@@ -188,10 +198,12 @@ export class FrontendWebsocketClient {
188
198
  return false;
189
199
  try {
190
200
  this.ws.send(JSON.stringify(data));
201
+ this.last_send_error = null;
191
202
  return true;
192
203
  }
193
204
  catch (error) {
194
205
  this.#log?.error('[socket] send failed:', error);
206
+ this.last_send_error = error instanceof Error ? error : new Error(String(error));
195
207
  return false;
196
208
  }
197
209
  }
@@ -0,0 +1,161 @@
1
+ /**
2
+ * In-process test helpers for WebSocket JSON-RPC round-trips.
3
+ *
4
+ * Drives `register_action_ws` without an HTTP server. Consumers supply
5
+ * specs + handlers; the harness constructs real `WSContext` instances
6
+ * backed by test-owned `send`/`close` pairs, fakes the authenticated
7
+ * Hono context (`request_context`, credential type, session id, api
8
+ * token id), and exposes a `connect()` factory returning a
9
+ * `MockWsClient` per connection.
10
+ *
11
+ * Two layers are exported:
12
+ *
13
+ * - **Primitives** (`create_fake_ws`, `create_fake_hono_context`,
14
+ * `create_stub_upgrade`, `MinimalActionEnvironment`) — used by
15
+ * fuz_app's own dispatcher tests and by consumers wiring tight
16
+ * one-off tests.
17
+ * - **Harness** (`create_ws_test_harness`, `MockWsClient`,
18
+ * `keeper_identity`) — the high-level driver. Give it specs +
19
+ * handlers, get back `{transport, connect()}`. Use this unless you
20
+ * need bare primitives.
21
+ *
22
+ * Hono's wire upgrade is skipped — the Node test runtime has no
23
+ * `@hono/node-ws` adapter — but the full dispatch path is exercised
24
+ * (per-action auth, input validation, `ctx.notify` back to the
25
+ * originating socket, broadcast via `BackendWebsocketTransport`, and
26
+ * close-on-revoke).
27
+ *
28
+ * @module
29
+ */
30
+ import type { Context } from 'hono';
31
+ import { WSContext, type UpgradeWebSocket, type WSEvents } from 'hono/ws';
32
+ import { Logger } from '@fuzdev/fuz_util/log.js';
33
+ import type { ActionSpecUnion } from '../actions/action_spec.js';
34
+ import type { ActionEventEnvironment } from '../actions/action_event_types.js';
35
+ import { type BaseHandlerContext, type RegisterActionWsOptions, type WsActionHandler } from '../actions/register_action_ws.js';
36
+ import { BackendWebsocketTransport } from '../actions/transports_ws_backend.js';
37
+ import { type RequestContext } from '../auth/request_context.js';
38
+ import { type CredentialType } from '../hono_context.js';
39
+ import { type Uuid } from '../uuid.js';
40
+ /**
41
+ * A `WSContext` paired with capture arrays. Use `sends` to assert on
42
+ * outgoing frames; use `closes` to assert on revocation / close.
43
+ */
44
+ export interface FakeWs {
45
+ ws: WSContext;
46
+ sends: Array<string>;
47
+ closes: Array<{
48
+ code?: number;
49
+ reason?: string;
50
+ }>;
51
+ }
52
+ /**
53
+ * Build a real `WSContext` backed by in-memory `send`/`close` capture.
54
+ * Parsing of outgoing frames is left to the caller — `sends` holds the
55
+ * raw strings as the dispatcher wrote them.
56
+ */
57
+ export declare const create_fake_ws: () => FakeWs;
58
+ /** Options for `create_fake_hono_context`. */
59
+ export interface FakeHonoContextOptions {
60
+ credential_type: CredentialType;
61
+ /** A single role to grant via `create_test_request_context`. */
62
+ role?: string;
63
+ auth_session_id?: string | null;
64
+ api_token_id?: string | null;
65
+ /**
66
+ * Override the `RequestContext` outright (for multi-role or custom
67
+ * account/actor fixtures). Takes precedence over `role`.
68
+ */
69
+ request_context?: RequestContext;
70
+ }
71
+ /**
72
+ * Build a fake Hono `Context` exposing the auth keys the dispatcher
73
+ * reads via `c.get(...)`. Only `.get()` is populated — no other Hono
74
+ * context surface is simulated.
75
+ */
76
+ export declare const create_fake_hono_context: (opts: FakeHonoContextOptions) => Context;
77
+ /** The return of `create_stub_upgrade` — fake `upgradeWebSocket` + factory capture. */
78
+ export interface StubUpgrade {
79
+ upgradeWebSocket: UpgradeWebSocket;
80
+ get_create_events: () => (c: Context) => WSEvents | Promise<WSEvents>;
81
+ }
82
+ /**
83
+ * Build a fake `upgradeWebSocket` that captures the `createEvents`
84
+ * callback. The returned middleware is inert — tests drive
85
+ * `createEvents` directly.
86
+ */
87
+ export declare const create_stub_upgrade: () => StubUpgrade;
88
+ /**
89
+ * Minimal `ActionEventEnvironment` for tests that instantiate an
90
+ * `ActionPeer` without pulling in the full runtime. Pre-loads a
91
+ * spec map from the supplied list.
92
+ */
93
+ export declare class MinimalActionEnvironment implements ActionEventEnvironment {
94
+ #private;
95
+ executor: 'frontend' | 'backend';
96
+ constructor(specs: ReadonlyArray<ActionSpecUnion>);
97
+ lookup_action_handler(): undefined;
98
+ lookup_action_spec(method: string): ActionSpecUnion | undefined;
99
+ }
100
+ /**
101
+ * Hono types `WSEvents.onMessage` as `() => void | Promise<void>`.
102
+ * Awaits only the Promise branch so tests observe full dispatch
103
+ * (auth, validation, handler, send).
104
+ */
105
+ export declare const dispatch_ws_message: (on_message: NonNullable<WSEvents["onMessage"]>, event: MessageEvent, ws: WSContext) => Promise<void>;
106
+ /** Auth identity for a mock connection. */
107
+ export interface WsConnectIdentity {
108
+ /** Account id for the connection. Defaults to a fresh uuid per call. */
109
+ account_id?: Uuid;
110
+ /** Credential type. Defaults to `'session'`. Keeper actions require `'daemon_token'`. */
111
+ credential_type?: CredentialType;
112
+ /** Session id (any string). Defaults to a fresh uuid. Hashed by the dispatcher. */
113
+ session_id?: string;
114
+ /** Api token id; set for bearer connections, null otherwise. */
115
+ api_token_id?: string | null;
116
+ /** Roles to grant via active permits. Pass `[ROLE_KEEPER]` for keeper actions. */
117
+ roles?: Array<string>;
118
+ }
119
+ /** A mock WS client: send requests, inspect/await incoming messages. */
120
+ export interface MockWsClient {
121
+ /** Send a JSON-RPC message (request or notification) to the server. */
122
+ send: (message: unknown) => Promise<void>;
123
+ /** Close the connection, firing `onClose`. */
124
+ close: (code?: number, reason?: string) => void;
125
+ /** Every message the server has sent, in arrival order. */
126
+ readonly messages: ReadonlyArray<unknown>;
127
+ /**
128
+ * Wait until a message satisfies `predicate`. Matches are checked
129
+ * against already-received messages first, then new arrivals until
130
+ * the timeout (defaults to 1000ms).
131
+ */
132
+ wait_for: <T = unknown>(predicate: (msg: unknown) => boolean, timeout_ms?: number) => Promise<T>;
133
+ }
134
+ /** Options for `create_ws_test_harness`. */
135
+ export interface CreateWsTestHarnessOptions<TCtx extends BaseHandlerContext> {
136
+ specs: ReadonlyArray<ActionSpecUnion>;
137
+ handlers: Record<string, WsActionHandler<TCtx>>;
138
+ extend_context?: RegisterActionWsOptions<TCtx>['extend_context'];
139
+ /** Pass a pre-created transport to share with a broadcast API. */
140
+ transport?: BackendWebsocketTransport;
141
+ /** Optional logger. Defaults to a silent `[ws-test]` logger. */
142
+ log?: Logger;
143
+ }
144
+ /** A harness instance — transport handle + connection factory. */
145
+ export interface WsTestHarness {
146
+ transport: BackendWebsocketTransport;
147
+ connect: (identity?: WsConnectIdentity) => MockWsClient;
148
+ }
149
+ /**
150
+ * Create a WebSocket test harness for the given specs + handlers.
151
+ *
152
+ * Registers against a throwaway Hono app with a fake
153
+ * `upgradeWebSocket`; the captured events factory is invoked per
154
+ * `connect()` with a synthesized Hono context carrying the requested
155
+ * auth identity. Returned clients drive the real
156
+ * `onOpen`/`onMessage`/`onClose` path against a real `WSContext`.
157
+ */
158
+ export declare const create_ws_test_harness: <TCtx extends BaseHandlerContext>(options: CreateWsTestHarnessOptions<TCtx>) => WsTestHarness;
159
+ /** Convenience: default identity for keeper-authenticated connections. */
160
+ export declare const keeper_identity: () => WsConnectIdentity;
161
+ //# sourceMappingURL=ws_round_trip.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ws_round_trip.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/testing/ws_round_trip.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,334 @@
1
+ /**
2
+ * In-process test helpers for WebSocket JSON-RPC round-trips.
3
+ *
4
+ * Drives `register_action_ws` without an HTTP server. Consumers supply
5
+ * specs + handlers; the harness constructs real `WSContext` instances
6
+ * backed by test-owned `send`/`close` pairs, fakes the authenticated
7
+ * Hono context (`request_context`, credential type, session id, api
8
+ * token id), and exposes a `connect()` factory returning a
9
+ * `MockWsClient` per connection.
10
+ *
11
+ * Two layers are exported:
12
+ *
13
+ * - **Primitives** (`create_fake_ws`, `create_fake_hono_context`,
14
+ * `create_stub_upgrade`, `MinimalActionEnvironment`) — used by
15
+ * fuz_app's own dispatcher tests and by consumers wiring tight
16
+ * one-off tests.
17
+ * - **Harness** (`create_ws_test_harness`, `MockWsClient`,
18
+ * `keeper_identity`) — the high-level driver. Give it specs +
19
+ * handlers, get back `{transport, connect()}`. Use this unless you
20
+ * need bare primitives.
21
+ *
22
+ * Hono's wire upgrade is skipped — the Node test runtime has no
23
+ * `@hono/node-ws` adapter — but the full dispatch path is exercised
24
+ * (per-action auth, input validation, `ctx.notify` back to the
25
+ * originating socket, broadcast via `BackendWebsocketTransport`, and
26
+ * close-on-revoke).
27
+ *
28
+ * @module
29
+ */
30
+ import { WSContext, createWSMessageEvent, } from 'hono/ws';
31
+ import { Logger } from '@fuzdev/fuz_util/log.js';
32
+ import { register_action_ws, } from '../actions/register_action_ws.js';
33
+ import { BackendWebsocketTransport } from '../actions/transports_ws_backend.js';
34
+ import { REQUEST_CONTEXT_KEY } from '../auth/request_context.js';
35
+ import { ROLE_KEEPER } from '../auth/role_schema.js';
36
+ import { AUTH_API_TOKEN_ID_KEY, CREDENTIAL_TYPE_KEY } from '../hono_context.js';
37
+ import { create_uuid } from '../uuid.js';
38
+ /**
39
+ * Build a real `WSContext` backed by in-memory `send`/`close` capture.
40
+ * Parsing of outgoing frames is left to the caller — `sends` holds the
41
+ * raw strings as the dispatcher wrote them.
42
+ */
43
+ export const create_fake_ws = () => {
44
+ const sends = [];
45
+ const closes = [];
46
+ const init = {
47
+ send: (data) => {
48
+ sends.push(typeof data === 'string' ? data : '<binary>');
49
+ },
50
+ close: (code, reason) => {
51
+ closes.push({ code, reason });
52
+ },
53
+ readyState: 1,
54
+ };
55
+ return { ws: new WSContext(init), sends, closes };
56
+ };
57
+ /**
58
+ * Build a fake Hono `Context` exposing the auth keys the dispatcher
59
+ * reads via `c.get(...)`. Only `.get()` is populated — no other Hono
60
+ * context surface is simulated.
61
+ */
62
+ export const create_fake_hono_context = (opts) => {
63
+ const request_context = opts.request_context ?? build_simple_request_context(opts.role);
64
+ const vars = {
65
+ [REQUEST_CONTEXT_KEY]: request_context,
66
+ [CREDENTIAL_TYPE_KEY]: opts.credential_type,
67
+ auth_session_id: opts.auth_session_id ?? (opts.credential_type === 'session' ? 's1' : null),
68
+ [AUTH_API_TOKEN_ID_KEY]: opts.api_token_id ?? null,
69
+ };
70
+ return {
71
+ get: (key) => vars[key],
72
+ };
73
+ };
74
+ /**
75
+ * Build a fake `upgradeWebSocket` that captures the `createEvents`
76
+ * callback. The returned middleware is inert — tests drive
77
+ * `createEvents` directly.
78
+ */
79
+ export const create_stub_upgrade = () => {
80
+ let captured = null;
81
+ const upgradeWebSocket = ((createEvents) => {
82
+ captured = createEvents;
83
+ return async (_c, next) => next();
84
+ });
85
+ return {
86
+ upgradeWebSocket,
87
+ get_create_events: () => {
88
+ if (!captured)
89
+ throw new Error('upgradeWebSocket was not called');
90
+ return captured;
91
+ },
92
+ };
93
+ };
94
+ /**
95
+ * Minimal `ActionEventEnvironment` for tests that instantiate an
96
+ * `ActionPeer` without pulling in the full runtime. Pre-loads a
97
+ * spec map from the supplied list.
98
+ */
99
+ export class MinimalActionEnvironment {
100
+ executor = 'backend';
101
+ #specs = new Map();
102
+ constructor(specs) {
103
+ for (const spec of specs)
104
+ this.#specs.set(spec.method, spec);
105
+ }
106
+ lookup_action_handler() {
107
+ return undefined;
108
+ }
109
+ lookup_action_spec(method) {
110
+ return this.#specs.get(method);
111
+ }
112
+ }
113
+ /**
114
+ * Hono types `WSEvents.onMessage` as `() => void | Promise<void>`.
115
+ * Awaits only the Promise branch so tests observe full dispatch
116
+ * (auth, validation, handler, send).
117
+ */
118
+ export const dispatch_ws_message = async (on_message, event, ws) => {
119
+ // eslint-disable-next-line @typescript-eslint/no-confusing-void-expression
120
+ const result = on_message(event, ws);
121
+ if (result instanceof Promise)
122
+ await result;
123
+ };
124
+ const DEFAULT_TIMEOUT_MS = 1000;
125
+ /**
126
+ * Build a `RequestContext` with a fresh UUID account/actor and permits
127
+ * for the supplied roles. Used by the high-level harness so callers can
128
+ * pass `roles: [ROLE_KEEPER, 'admin']`.
129
+ */
130
+ const build_multi_role_request_context = (account_id, roles) => {
131
+ const actor_id = create_uuid();
132
+ const now = new Date().toISOString();
133
+ return {
134
+ account: {
135
+ id: account_id,
136
+ username: 'ws-test',
137
+ email: null,
138
+ email_verified: false,
139
+ password_hash: '',
140
+ created_at: now,
141
+ created_by: null,
142
+ updated_at: now,
143
+ updated_by: null,
144
+ },
145
+ actor: {
146
+ id: actor_id,
147
+ account_id,
148
+ name: 'ws-test',
149
+ created_at: now,
150
+ updated_at: null,
151
+ updated_by: null,
152
+ },
153
+ permits: roles.map((role) => ({
154
+ id: create_uuid(),
155
+ actor_id,
156
+ role,
157
+ created_at: now,
158
+ expires_at: null,
159
+ revoked_at: null,
160
+ revoked_by: null,
161
+ granted_by: null,
162
+ })),
163
+ };
164
+ };
165
+ /**
166
+ * Stub `RequestContext` for single-role or public fakes. Hardcoded
167
+ * ids (`acc_1` / `act_1`) mirror `create_test_request_context` in
168
+ * `auth_apps.ts`.
169
+ */
170
+ const build_simple_request_context = (role) => {
171
+ const now = new Date().toISOString();
172
+ return {
173
+ account: {
174
+ id: 'acc_1',
175
+ username: 'testuser',
176
+ password_hash: 'hash',
177
+ created_at: now,
178
+ updated_at: now,
179
+ created_by: null,
180
+ updated_by: null,
181
+ email: null,
182
+ email_verified: false,
183
+ },
184
+ actor: {
185
+ id: 'act_1',
186
+ account_id: 'acc_1',
187
+ name: 'testuser',
188
+ created_at: now,
189
+ updated_at: null,
190
+ updated_by: null,
191
+ },
192
+ permits: role
193
+ ? [
194
+ {
195
+ id: 'perm_1',
196
+ actor_id: 'act_1',
197
+ role,
198
+ created_at: now,
199
+ expires_at: null,
200
+ revoked_at: null,
201
+ revoked_by: null,
202
+ granted_by: null,
203
+ },
204
+ ]
205
+ : [],
206
+ };
207
+ };
208
+ /**
209
+ * Create a WebSocket test harness for the given specs + handlers.
210
+ *
211
+ * Registers against a throwaway Hono app with a fake
212
+ * `upgradeWebSocket`; the captured events factory is invoked per
213
+ * `connect()` with a synthesized Hono context carrying the requested
214
+ * auth identity. Returned clients drive the real
215
+ * `onOpen`/`onMessage`/`onClose` path against a real `WSContext`.
216
+ */
217
+ export const create_ws_test_harness = (options) => {
218
+ const { specs, handlers, extend_context = (base) => base, transport = new BackendWebsocketTransport(), log = new Logger('[ws-test]', { level: 'off' }), } = options;
219
+ const stub = create_stub_upgrade();
220
+ // Minimal Hono stub — `register_action_ws` only needs `.get(path, handler)`.
221
+ const stub_app = { get: () => stub_app };
222
+ register_action_ws({
223
+ path: '/test/ws',
224
+ app: stub_app,
225
+ upgradeWebSocket: stub.upgradeWebSocket,
226
+ specs,
227
+ handlers,
228
+ extend_context,
229
+ transport,
230
+ log,
231
+ });
232
+ const events_factory = stub.get_create_events();
233
+ const connect = (identity = {}) => {
234
+ const account_id = identity.account_id ?? create_uuid();
235
+ const credential_type = identity.credential_type ?? 'session';
236
+ const session_id = identity.session_id ?? create_uuid();
237
+ const api_token_id = identity.api_token_id ?? null;
238
+ const roles = identity.roles ?? [];
239
+ const ctx_store = new Map([
240
+ [REQUEST_CONTEXT_KEY, build_multi_role_request_context(account_id, roles)],
241
+ [CREDENTIAL_TYPE_KEY, credential_type],
242
+ ['auth_session_id', session_id],
243
+ [AUTH_API_TOKEN_ID_KEY, api_token_id],
244
+ ]);
245
+ const fake_c = {
246
+ get: (key) => ctx_store.get(key),
247
+ };
248
+ const received = [];
249
+ const waiters = [];
250
+ let is_closed = false;
251
+ // Real WSContext backed by test-owned send/close. Parsing is done
252
+ // on receive so tests can assert against structured messages.
253
+ const ws = new WSContext({
254
+ readyState: 1,
255
+ send: (data) => {
256
+ if (is_closed)
257
+ return;
258
+ const parsed = typeof data === 'string' ? JSON.parse(data) : data;
259
+ received.push(parsed);
260
+ for (let i = waiters.length - 1; i >= 0; i--) {
261
+ const waiter = waiters[i];
262
+ if (waiter.predicate(parsed)) {
263
+ waiter.resolve(parsed);
264
+ waiters.splice(i, 1);
265
+ }
266
+ }
267
+ },
268
+ close: (code, reason) => {
269
+ if (is_closed)
270
+ return;
271
+ is_closed = true;
272
+ const close_event = new Event('close');
273
+ Object.defineProperties(close_event, {
274
+ code: { value: code ?? 1000, writable: false },
275
+ reason: { value: reason ?? '', writable: false },
276
+ wasClean: { value: true, writable: false },
277
+ });
278
+ void Promise.resolve(events).then((e) => e.onClose?.(close_event, ws));
279
+ },
280
+ });
281
+ // Resolve the (possibly async) events factory synchronously via
282
+ // a microtask chain. Tests always await `connect().send(...)`
283
+ // which sequences after.
284
+ let events = events_factory(fake_c);
285
+ const events_ready = Promise.resolve(events).then((resolved) => {
286
+ events = resolved;
287
+ resolved.onOpen?.(new Event('open'), ws);
288
+ return resolved;
289
+ });
290
+ return {
291
+ get messages() {
292
+ return received;
293
+ },
294
+ async send(message) {
295
+ const resolved = await events_ready;
296
+ if (is_closed)
297
+ throw new Error('send after close');
298
+ const message_event = createWSMessageEvent(JSON.stringify(message));
299
+ // `onMessage` is typed as returning void by Hono, but
300
+ // `register_action_ws` implements it as async — cast so
301
+ // tests await the full dispatch (auth, validation,
302
+ // handler, send).
303
+ await resolved.onMessage?.(message_event, ws);
304
+ },
305
+ close(code, reason) {
306
+ if (is_closed)
307
+ return;
308
+ ws.close(code, reason);
309
+ },
310
+ wait_for(predicate, timeout_ms = DEFAULT_TIMEOUT_MS) {
311
+ for (const msg of received) {
312
+ if (predicate(msg))
313
+ return Promise.resolve(msg);
314
+ }
315
+ return new Promise((resolve, reject) => {
316
+ const timer = setTimeout(() => reject(new Error(`wait_for timed out after ${timeout_ms}ms`)), timeout_ms);
317
+ waiters.push({
318
+ predicate,
319
+ resolve: (msg) => {
320
+ clearTimeout(timer);
321
+ resolve(msg);
322
+ },
323
+ });
324
+ });
325
+ },
326
+ };
327
+ };
328
+ return { transport, connect };
329
+ };
330
+ /** Convenience: default identity for keeper-authenticated connections. */
331
+ export const keeper_identity = () => ({
332
+ credential_type: 'daemon_token',
333
+ roles: [ROLE_KEEPER],
334
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fuzdev/fuz_app",
3
- "version": "0.19.0",
3
+ "version": "0.21.0",
4
4
  "description": "fullstack app library",
5
5
  "glyph": "🗝",
6
6
  "logo": "logo.svg",